Files
finance-duck/web/src/api.ts
T
Lars Nolden 3df9bda989 Replace free-text institution entry with a searchable bank picker
GET /api/banking/institutions lists the banks Enable Banking can connect
for a country (personal AIS, connectable consents only), with logos
restricted to https Enable Banking hosts to match the CSP image
allowlist. The connect form offers a filterable dropdown with bank
logos, falling back to the previous free-text input when the list is
unavailable or banking is not configured.
2026-09-11 11:17:03 +02:00

257 lines
6.3 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;
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<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",
"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: "",
};