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; history_months: number; status: "local" | "connected" | "reconnect_required" | "error"; valid_until: string; error: string; } export interface Institution { name: string; country: string; logo?: string; } export interface State { data: Dataset; revision: string; callback_url: string; banking_app_id: 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( path: string, body?: unknown, signal?: AbortSignal, ): Promise { 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" || typeof state.banking_app_id !== "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(); 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: "", };