export interface Account { id: string; display_name: string; institution: string; currency: string; // kind is "cash" or "investment"; an absent kind is a cash account. kind?: string; external_account_id?: string; iban?: string; // reference_iban is the counterpart an investment account settles cash // against: a broker export carries no counterparty, so its deposits and // withdrawals pair with the funding account through this IBAN. reference_iban?: string; active: boolean; } // Instrument is a security held in an investment account. The ISIN is the // identity; the name is editable display text. export interface Instrument { id: string; isin: string; name: string; currency: string; // symbol is the market listing this security is quoted under, chosen once by // hand: one ISIN lists in several currencies and the wrong one misstates // wealth. quote is the last price the daily job fetched for it. symbol?: string; quote?: string; quoted_at?: string; } // Asset is a possession valued by hand: a house, a car, anything without a // market feed. value is what the owner states it is worth and valued_at the // day that estimate was made. A negative value records a liability. export interface Asset { id: string; name: string; kind?: string; currency: string; value: string; valued_at: string; } // Investment is the broker-native leg of a fact. Cash movement always stays in // Facts.amount, so a position-only event carries a zero amount. Quantity is an // exact signed decimal, not money: negative removes from the holding. export interface Investment { event: string; instrument_id?: string; quantity?: string; price?: string; gross?: string; fee?: string; tax?: string; } 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; investment?: Investment; } export interface Provenance { source: string; model?: string; confidence?: "high" | "medium" | "low" | string; timestamp?: string; error?: string; } // VerifiedModel is a model the server confirmed against the provider's public // catalog: it has a live zero-data-retention endpoint with strict structured // outputs, so classification requests can actually route to it. export interface VerifiedModel { id: string; name: 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; hint?: string; } export interface Tag { id: string; name: string; hint?: 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[]; instruments: Instrument[]; assets: Asset[]; transactions: Transaction[]; } export interface Connection { account_id: string; institution: string; country: string; psu_type: string; history_months: number; status: | "local" | "connected" | "reconnect_required" | "rate_limited" | "error"; valid_until: string; error: string; // retry_at is set while the bank rate limits this connection. retry_at?: string; } export interface Institution { name: string; country: string; logo?: string; psu_types: string[]; } export interface State { data: Dataset; revision: string; callback_url: string; banking_app_id: string; connections: Connection[]; status: { sync_error: string; // sync_retry_at is set only when every failure is a bank rate limit that // clears by itself. sync_retry_at?: string; index_error: string; last_sync: string; banking_configured: boolean; ai_configured: boolean; }; settings: { model: string; private_names: string[]; classify_on_import: 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; } // MonthlyPoint mirrors the analytics row: income and expenses are both positive // magnitudes, net is the only signed figure. export interface MonthlyPoint { period: string; currency: string; income: string; expenses: string; net: string; count: number; } export interface Dashboard { totals: Total[]; previous: Total[]; monthly: MonthlyPoint[]; categories: Group[]; previous_categories: Group[]; tags: Group[]; merchants: Group[]; accounts: Group[]; recurring: Group[]; largest: 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; counterparty: string; amount: string; currency: string; before: Enrichment; after: Enrichment; }[]; analysed: number; unchanged: number; errors: { id: string; error: string }[]; } // PreviewProgress is the live state of a background classification run. // Errors accumulate as they happen; preview is present only when done // without a fatal error. export interface PreviewProgress { id: string; total: number; analysed: number; changes: number; unchanged: number; errors: { id: string; error: string }[]; done: boolean; error?: string; preview?: Preview; } export interface ProposedCategory { name: string; parent?: string; kind: string; hint?: string; because: string[]; } export interface ProposedTag { name: string; hint?: string; } export interface ProposedMerchant { name: string; aliases: string[]; } export interface TaxonomyProposal { categories: ProposedCategory[]; tags: ProposedTag[]; merchants: ProposedMerchant[]; } export interface TaxonomyPreview { id: string; revision: string; sample: { date: string; amount: string; currency: string; kind: string; description: string; counterparty: string; }[]; proposal: TaxonomyProposal; } export interface CSVColumn { field: string; column: string; } // BrokerReview is what the broker parser decided about an export that has not // been imported yet: which securities it would register, which rows it skipped // and which figures it deliberately did not apply. export interface BrokerReview { instruments: Instrument[]; // cancelled counts rows the broker did not execute. cancelled: number; // rounded counts rows whose money carried more than four decimal places; // rounding is the exact total adjustment, to eight places. rounded: number; rounding: string; // unapplied lists cash rows carrying a fee or tax. A broker cash amount is // already net of both, so subtracting them again would double-count. unapplied: { record: number; date: string; description: string; fee?: string; tax?: string; }[]; } // PreparedImport is a parsed statement that has not been imported yet: the // mapping and sample must be confirmed before any transaction is written. export interface PreparedImport { id: string; revision: string; account_id: string; source: string; source_label: string; mapped_by: "preset" | "openrouter"; model?: string; columns: CSVColumn[]; records: number; new: number; duplicates: number; samples: Facts[]; broker?: BrokerReview; } // WealthHolding is one instrument's position in one account. quantity is an // exact signed decimal and never money; invested and received are money. export interface WealthHolding { instrument_id: string; isin: string; name: string; quantity: string; invested: string; received: string; // value is the holding at its own quote. priced is false when no quote is // known, and then value and result are absent rather than guessed from cost. quote?: string; quoted_at?: string; value?: string; priced: boolean; // result is the value now plus everything the position returned, less // everything put into it: the outcome to date, realised and not. result?: string; records: number; } // WealthCheck is one named verification with its evidence. failed marks a // disagreement inside the journal; the rest are notes that explain a figure. export interface WealthCheck { name: string; detail: string; failed: boolean; } // WealthFlow is the cash one kind of record moved. Every flow sums to the // account's balance, so a total that disagrees with a broker's own figure // localises to one class of row. export interface WealthFlow { event: string; label: string; cash: string; records: number; } export interface WealthAccount { account_id: string; display_name: string; institution: string; currency: string; kind: string; active: boolean; records: number; first_booking?: string; last_booking?: string; // cash is every recorded movement summed. It equals the real balance only // when the journal holds that account's complete history. cash: string; // positions is the market value of every priced holding, and wealth the two // together. unpriced counts the holdings left out for want of a quote. positions: string; wealth: string; unpriced: number; flows: WealthFlow[]; holdings: WealthHolding[]; checks: WealthCheck[]; } // QuoteResult is what one run of the price job did. A failure names the // instrument it could not price and leaves that instrument's last quote alone, // so one unreachable listing never blanks a whole portfolio. export interface QuoteFailure { instrument_id: string; isin: string; symbol: string; error: string; } export interface QuoteResult { updated: number; unchanged: number; skipped: number; failures: QuoteFailure[]; state: State; } export interface WealthTotal { currency: string; cash: string; positions: string; // assets is the stated value of every hand-valued asset in this currency, // and wealth is cash, positions and assets together. assets: string; wealth: string; unpriced: number; } // WealthAsset is one hand-valued asset as the journal records it: the value is // stated, never quoted, and carries the day it was stated. export interface WealthAsset { asset_id: string; name: string; kind?: string; currency: string; value: string; valued_at: string; } // Wealth is a reconciliation report computed from the journal rather than the // analytics index, so it can be checked against a bank or broker's own screen. export interface Wealth { accounts: WealthAccount[]; assets: WealthAsset[]; totals: WealthTotal[]; } 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", "instruments", "assets", "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}.`); } state.settings.private_names ??= []; 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; } // Retry deadlines are instants, not calendar days: show them in the viewer's // own time zone rather than the server's UTC string. export function localInstant(iso: string): string { const at = new Date(iso); if (!iso || Number.isNaN(at.getTime())) return iso; const sameDay = at.toDateString() === new Date().toDateString(); return at.toLocaleString( undefined, sameDay ? { hour: "2-digit", minute: "2-digit" } : { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", }, ); } 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}`; } // compactMoney is for chart axes and ticks, where an exact figure would not // fit: it rounds to at most one fractional digit and abbreviates thousands. // Every figure a user might act on is still rendered by money(). export function compactMoney(value: string, currency = ""): string { const n = Number(value); if (!Number.isFinite(n)) return value; const sign = n < 0 ? "−" : ""; const abs = Math.abs(n); const [scaled, unit]: [number, string] = abs >= 1e9 ? [abs / 1e9, "b"] : abs >= 1e6 ? [abs / 1e6, "m"] : abs >= 1000 ? [abs / 1000, "k"] : [abs, ""]; const digits = unit ? (scaled < 10 ? 1 : 0) : abs > 0 && abs < 10 ? 2 : 0; const text = scaled.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits, }); return `${sign}${text}${unit}${currency ? ` ${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: "", }; // A six-month window is the default view: long enough to show a trend and a // seasonal bill, short enough that the current month still matters. The window // starts on the first day of the month, so month buckets are whole. export const DEFAULT_MONTHS = 6; export function monthStart(monthsBack: number): string { const now = new Date(); const day = new Date( Date.UTC(now.getFullYear(), now.getMonth() - monthsBack, 1), ); return day.toISOString().slice(0, 10); } export function yearStart(): string { return `${new Date().getFullYear()}-01-01`; } export function defaultFilter(): Filter { return { ...emptyFilter, from: monthStart(DEFAULT_MONTHS - 1) }; }