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