init
This commit is contained in:
+248
@@ -0,0 +1,248 @@
|
||||
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;
|
||||
status: "local" | "connected" | "reconnect_required" | "error";
|
||||
valid_until: string;
|
||||
error: string;
|
||||
}
|
||||
export interface State {
|
||||
data: Dataset;
|
||||
revision: string;
|
||||
callback_url: 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"
|
||||
)
|
||||
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: "",
|
||||
};
|
||||
Reference in New Issue
Block a user