This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
export interface Account {
id: string;
display_name: string;
institution: string;
currency: string;
external_account_id?: string;
iban?: string;
active: boolean;
}
export interface Facts {
id: string;
source: string;
account_id: string;
booking_date: string;
value_date?: string;
amount: string;
currency: string;
raw_description: string;
external_id?: string;
fingerprint: string;
counterparty?: string;
counterparty_iban?: string;
}
export interface Provenance {
source: string;
model?: string;
timestamp?: string;
error?: string;
}
export interface Enrichment {
kind: string;
merchant_id?: string;
category_id?: string;
tag_ids: string[];
transfer_peer_id?: string;
classification: Provenance;
}
export interface Transaction {
facts: Facts;
enrichment: Enrichment;
}
export interface Category {
id: string;
name: string;
parent_id?: string;
kind: string;
}
export interface Tag {
id: string;
name: string;
}
export interface Merchant {
id: string;
name: string;
aliases: string[];
default_category_id?: string;
default_tag_ids: string[];
use_defaults: boolean;
}
export interface Dataset {
accounts: Account[];
categories: Category[];
tags: Tag[];
merchants: Merchant[];
transactions: Transaction[];
}
export interface Connection {
account_id: string;
institution: string;
country: string;
status: "local" | "connected" | "reconnect_required" | "error";
valid_until: string;
error: string;
}
export interface State {
data: Dataset;
revision: string;
callback_url: string;
connections: Connection[];
status: {
sync_error: string;
index_error: string;
last_sync: string;
banking_configured: boolean;
ai_configured: boolean;
};
settings: { model: string; include_amount: boolean };
sessions: { session_id: string; valid_until: string; accounts: Account[] }[];
}
export interface Total {
currency: string;
expenses: string;
income: string;
net: string;
}
export interface Group {
id: string;
name: string;
currency: string;
period: string;
amount: string;
count: number;
}
export interface Dashboard {
totals: Total[];
previous: Total[];
monthly: Group[];
categories: Group[];
tags: Group[];
merchants: Group[];
accounts: Group[];
recurring: Group[];
}
export interface Filter {
from: string;
to: string;
currency: string;
account_id: string;
category_id: string;
tag_id: string;
merchant_id: string;
}
export interface Preview {
id: string;
revision: string;
new_merchants: Merchant[];
changes: {
id: string;
description: string;
before: Enrichment;
after: Enrichment;
}[];
analysed: number;
unchanged: number;
errors: { id: string; error: string }[];
}
export class APIError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
export async function request<T>(
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const multipart = body instanceof FormData;
const response = await fetch(path, {
method: body === undefined ? "GET" : "POST",
headers:
body === undefined || multipart
? undefined
: { "Content-Type": "application/json" },
body:
body === undefined ? undefined : multipart ? body : JSON.stringify(body),
signal,
});
const text = await response.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
throw new APIError(
`Server returned an unreadable response (${response.status}).`,
response.status,
);
}
if (!response.ok)
throw new APIError(
typeof data === "object" && data && "error" in data
? String(data.error)
: `Request failed (${response.status}).`,
response.status,
);
if (data === null)
throw new APIError(
"The server returned an empty response.",
response.status,
);
return data as T;
}
export function normalizeState(state: State): State {
// Go can encode empty slices as null; missing registry fields are an incompatible response.
if (
!state ||
!state.data ||
typeof state.revision !== "string" ||
!state.status ||
!state.settings ||
!("sessions" in state) ||
!("connections" in state) ||
typeof state.callback_url !== "string"
)
throw new Error("The server returned an incompatible state response.");
for (const key of [
"accounts",
"categories",
"tags",
"merchants",
"transactions",
] as const) {
if (!(key in state.data))
throw new Error(`The server state is missing ${key}.`);
if (state.data[key] === null) Object.assign(state.data, { [key]: [] });
else if (!Array.isArray(state.data[key]))
throw new Error(`The server state has invalid ${key}.`);
}
for (const tx of state.data.transactions) tx.enrichment.tag_ids ??= [];
for (const merchant of state.data.merchants) {
merchant.aliases ??= [];
merchant.default_tag_ids ??= [];
}
state.sessions ??= [];
state.connections ??= [];
for (const session of state.sessions) session.accounts ??= [];
return state;
}
export function money(value: string, currency: string): string {
// Keep all financial values as decimal strings, including display formatting.
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value);
if (!match) return `${value} ${currency}`;
const decimals = (match[3] || "").replace(/0+$/, "").padEnd(2, "0");
return `${match[1] === "-" ? "" : ""}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${decimals} ${currency}`;
}
export function categoryPath(data: Dataset, id?: string): string {
if (!id) return "No category";
const names: string[] = [];
const seen = new Set<string>();
let current = data.categories.find((c) => c.id === id);
while (current && !seen.has(current.id)) {
seen.add(current.id);
names.unshift(current.name);
current = data.categories.find((c) => c.id === current?.parent_id);
}
return names.length ? names.join(" / ") : `Unknown category (${id})`;
}
export const emptyFilter: Filter = {
from: "",
to: "",
currency: "",
account_id: "",
category_id: "",
tag_id: "",
merchant_id: "",
};