Files
finance-duck/web/src/api.ts
T
Lars Nolden 77f4ea5655 Count hand-valued assets into the wealth figure
A wealth figure that ignores the house is not a wealth figure. Assets
without a market feed - a house, a car, a private loan - are now added
by hand on the Wealth page with a stated value, a currency and the day
the estimate was made; a negative value records a liability. They are
registry entities in assets.finance like everything else, join the
per-currency totals immediately, and a currency held only in an asset
earns its own line.
2026-09-14 09:29:19 +02:00

596 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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" ||
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<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: "",
};
// 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) };
}