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.
596 lines
17 KiB
TypeScript
596 lines
17 KiB
TypeScript
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) };
|
||
}
|