import { useEffect, useRef, useState } from "react"; import { Plus, Upload, Link2, Wallet, Pencil, Trash2, RefreshCw, Landmark, Sparkles, } from "lucide-react"; import type { Account, Institution, PreparedImport, State } from "./api"; import { money, request } from "./api"; import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui"; import type { Mutate } from "./ui"; interface Balance { amount: string; currency: string; type: string; } interface AccountsProps { state: State; mutate: Mutate; acceptState: (state: State, message?: string) => void; } export function Accounts(props: AccountsProps) { const [backfilling, setBackfilling] = useState(null); return ( <> {backfilling && ( setBackfilling(null)} /> )} ); } // Ordinary account forms reset on a new revision; the backfill dialog must // survive its own imports and error recovery to retain its range and result. function AccountsContent({ state, mutate, acceptState, backfill, }: AccountsProps & { backfill: (account: Account) => void }) { const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const [error, setError] = useState(""); const [syncing, setSyncing] = useState(false); return ( <>

Accounts & connections

Bring your financial picture together, one account at a time.

{state.data.accounts.map((account) => ( setEditing(account)} remove={() => setDeleting(account)} backfill={() => backfill(account)} onError={setError} /> ))}
{!state.data.accounts.length && (
Add an account to import CSV statements, or connect your bank below.
)}

Bank sessions

Reconnect when a session expires. Imported journal history is retained.

{state.sessions.length ? (
{state.sessions.map((session) => (
Bank connection Valid until {session.valid_until || "not supplied"} {session.session_id}
{session.accounts.map((account) => ( {account.display_name} ))}
))}
) : (
No bank sessions. CSV imports work without a bank connection.
)}
{editing && ( setEditing(null)} /> )} {deleting && ( setDeleting(null)} /> )} ); } async function authorize( institution: string, country: string, psuType: string, historyMonths: number, ) { const response = await request<{ url: string }>("/api/banking/authorize", { institution, country, psu_type: psuType, history_months: historyMonths, }); const url = new URL(response.url); if (url.protocol !== "https:") throw new Error("Bank authorization returned an unsafe redirect URL."); window.location.assign(url.href); } function backfillUnavailable(account: Account, state: State): string { if (!state.status.banking_configured) return "Configure Enable Banking in Settings before importing older history."; const connection = state.connections.find((c) => c.account_id === account.id); if (connection?.status === "reconnect_required") return "Reconnect this account before importing older history."; if ( !account.id || !account.external_account_id || !connection || (connection.status !== "connected" && connection.status !== "error") ) return "Connect this account to your bank before importing older history."; for (let i = state.sessions.length - 1; i >= 0; i--) { const session = state.sessions[i]; if ( session.accounts.some( (linked) => linked.id === account.id && linked.external_account_id === account.external_account_id, ) ) return session.session_id && Date.parse(session.valid_until) > Date.now() ? "" : "Reconnect this account to renew its saved bank authorization."; } return "Reconnect this account to save a matching bank connection."; } function AccountCard({ account, state, edit, remove, backfill, onError, }: { account: Account; state: State; edit: () => void; remove: () => void; backfill: () => void; onError: (error: string) => void; }) { const [balances, setBalances] = useState(null); const [busy, setBusy] = useState(false); const [connecting, setConnecting] = useState(false); const connection = state.connections.find((c) => c.account_id === account.id); const institution = connection?.institution || account.institution; const needsReconnect = connection?.status === "reconnect_required"; const backfillReason = backfillUnavailable(account, state); return (
{account.active ? "Active" : "Inactive"}

{account.display_name}

{account.institution} · {account.currency}

{account.iban && {account.iban}}
{needsReconnect ? `${institution} needs reconnection` : connection?.status === "connected" ? "Bank connected" : connection?.status === "error" ? "Connection error" : connection?.status === "local" ? "Local account" : "Connection status unavailable"} {connection?.valid_until && ( Authorization expires {connection.valid_until} )} {connection && connection.status !== "local" && ( Initial history: {connection.history_months}{" "} {connection.history_months === 1 ? "month" : "months"} )} {connection?.error && ( {connection.error} )} {connection && connection.status !== "local" && ( )} {backfillReason && {backfillReason}}
{balances ? ( balances.length ? ( balances.map((balance, i) => (
{balance.type} {money(balance.amount, balance.currency)}
)) ) : ( No balances returned for this account. ) ) : ( Live balance has not been requested. )}
); } function BackfillHistory({ account, state, acceptState, close, }: { account: Account; state: State; acceptState: (state: State, message?: string) => void; close: () => void; }) { const [historyMonths, setHistoryMonths] = useState("12"); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const submitting = useRef(false); const currentAccount = state.data.accounts.find((a) => a.id === account.id); const unavailable = currentAccount ? backfillUnavailable(currentAccount, state) : "This account is no longer saved. Close this dialog and choose an account."; const closeWhenIdle = () => { if (!submitting.current) close(); }; return (
{ e.preventDefault(); if (submitting.current || !e.currentTarget.reportValidity()) return; const months = Number(historyMonths); if (!Number.isInteger(months) || months < 1 || months > 120) { setError("Choose a whole number of months from 1 to 120."); return; } if (unavailable) { setError(unavailable); return; } submitting.current = true; setBusy(true); setError(""); setSuccess(""); try { const response = await request<{ imported: number; requested_from?: string; earliest_fetched?: string; state: State; }>("/api/backfill", { revision: state.revision, account_id: account.id, history_months: months, }); let message = response.imported === 0 ? `No new transactions imported for ${account.display_name}. Existing transactions were not duplicated.` : `Imported ${response.imported} new transactions for ${account.display_name}. Existing transactions were not duplicated.`; if (!response.earliest_fetched) { message += " The bank returned no transactions for this range."; } else if ( response.requested_from && response.earliest_fetched > response.requested_from ) { message += ` The bank provided history starting ${response.earliest_fetched}, not the requested ${response.requested_from}; banks often limit how far back an existing connection can read. Older transactions can be added via CSV import.`; } acceptState(response.state, message); setSuccess(message); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); try { acceptState(await request("/api/state")); setError( `${message} The journal has been refreshed in case any records were saved. Review it before trying again; no import was automatically retried.`, ); } catch (refreshError) { setError( `${message} Could not refresh the journal: ${refreshError instanceof Error ? refreshError.message : String(refreshError)} Reload the page to check for saved records before trying again. No import was automatically retried.`, ); } } finally { submitting.current = false; setBusy(false); } }} >

Import bank transactions for {account.display_name}{" "} ({account.institution} · {account.currency}).

{success &&

{success}

} setHistoryMonths(e.target.value)} />

Repeating or overlapping a date range skips transactions already imported. Normal sync and its cursor stay unchanged, as does the initial-history setting. Inactive connected accounts can also import older history.

{unavailable &&

{unavailable}

} {busy && (

Importing older history… Keep this dialog open while the bank request and journal update finish.

)}
); } function ImportForm({ state, acceptState, onError, }: { state: State; acceptState: (state: State, message?: string) => void; onError: (error: string) => void; }) { const [account, setAccount] = useState(""); const [busy, setBusy] = useState(false); const [prepared, setPrepared] = useState(null); const fileRef = useRef(null); return (

Import a statement

N26, ING and Kontist CSV · other layouts are mapped with AI when configured

{ e.preventDefault(); const file = fileRef.current?.files?.[0]; if (!file) return; setBusy(true); onError(""); const form = new FormData(); form.set("account_id", account); form.set("revision", state.revision); form.set("file", file); try { setPrepared( await request("/api/import/prepare", form), ); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }} >

Nothing is imported until you review the detected columns and a sample of the transactions. Original descriptions and amounts are preserved, and reimporting the same statement safely skips transactions already in your journal.

{prepared && ( { setPrepared(null); if (fileRef.current) fileRef.current.value = ""; }} /> )}
); } // The mapping and a sample of the parsed transactions must be reviewed before // anything reaches the journal: a misread sign, date convention or currency is // only obvious against real records. function ImportReview({ prepared, acceptState, onError, close, }: { prepared: PreparedImport; acceptState: (state: State, message?: string) => void; onError: (error: string) => void; close: () => void; }) { const [busy, setBusy] = useState(false); const discard = () => { // Free the server's prepared statement; an expiring one is harmless. void request("/api/import/cancel", { id: prepared.id }).catch(() => {}); close(); }; return (
{prepared.mapped_by === "openrouter" ? (
Columns mapped by {prepared.model}

Only the column names and a redacted sample, with letters replaced by x and digits by 0, were sent to your AI provider. Check the dates, amount signs and currency below before importing.

) : (

Recognized {prepared.source_label} export. Columns were mapped on this machine, without AI.

)}
{prepared.records} records {prepared.new} new {prepared.duplicates} already imported
Column mapping
{prepared.columns.map((column) => (
{column.field}
{column.column}
))}
{prepared.samples.map((facts, index) => ( ))}
Booking date Description Counterparty Amount
{facts.booking_date} {!!facts.value_date && facts.value_date !== facts.booking_date && ( value {facts.value_date} )} {facts.raw_description || ( no description )} {facts.counterparty || ""} {money(facts.amount, facts.currency)}

{prepared.samples.length} of {prepared.records} records, including the largest amount and both directions of money.

); } function ConnectForm({ state, onError, }: { state: State; onError: (error: string) => void; }) { const [institution, setInstitution] = useState(""); const [psuTypes, setPSUTypes] = useState(null); const [psuType, setPSUType] = useState("personal"); const [country, setCountry] = useState("DE"); const [historyMonths, setHistoryMonths] = useState("12"); // Supported account types come from the bank listing. Authorizing a business // account with the personal flow yields a consent that shares no accounts, // so keep the choice inside what the selected bank actually offers. const chooseInstitution = (name: string, supported?: string[]) => { setInstitution(name); setPSUTypes(supported ?? null); if (supported?.length && !supported.includes(psuType)) setPSUType(supported[0]); }; const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false); const callback = state.callback_url || `${window.location.origin}/api/banking/callback`; return (

Connect your bank

Secure authorization through Enable Banking

{state.status.banking_configured ? "Configured" : "Not configured"}
e.target.select()} />

Configure your application in Settings and register this exact URL with Enable Banking. It must match exactly, including scheme, hostname, port and path.

{ e.preventDefault(); if (!e.currentTarget.reportValidity()) return; setBusy(true); onError(""); try { await authorize( institution.trim(), country, psuType, Number(historyMonths), ); } catch (err) { onError(err instanceof Error ? err.message : String(err)); setBusy(false); } }} > { setCountry(e.target.value.toUpperCase()); chooseInstitution(""); }} /> setHistoryMonths(e.target.value)} /> {!state.status.banking_configured && (

Add your Enable Banking application ID and private key in{" "} Settings first, then return here to authorize your bank.

)}
); } // InstitutionSelect offers the banks Enable Banking can actually connect for // the chosen country, with their logos. When the list cannot be loaded, it // degrades to the previous free-text institution input instead of blocking. function InstitutionSelect({ country, configured, value, onChange, }: { country: string; configured: boolean; value: string; onChange: (name: string, psuTypes?: string[]) => void; }) { const [institutions, setInstitutions] = useState(null); const [loadError, setLoadError] = useState(""); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); useEffect(() => { setInstitutions(null); setLoadError(""); if (!configured || !/^[A-Z]{2}$/.test(country)) return; const controller = new AbortController(); request( `/api/banking/institutions?country=${country}`, undefined, controller.signal, ) .then(setInstitutions) .catch((err) => { if (!controller.signal.aborted) setLoadError(err instanceof Error ? err.message : String(err)); }); return () => controller.abort(); }, [country, configured]); if (!configured || loadError) return ( onChange(e.target.value)} placeholder="N26" /> ); const filter = query.trim().toLowerCase(); const matches = (institutions ?? []).filter((i) => i.name.toLowerCase().includes(filter), ); const exact = filter ? matches.find((i) => i.name.toLowerCase() === filter) : undefined; const shown = exact ? [exact, ...matches.filter((i) => i !== exact).slice(0, 59)] : matches.slice(0, 60); const selected = institutions?.find((i) => i.name === value); return (
{ setQuery(""); setOpen(true); }} onChange={(e) => { setQuery(e.target.value); setOpen(true); }} onBlur={() => setOpen(false)} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); if (e.key === "Enter" && open) { e.preventDefault(); if (shown.length === 1) { onChange(shown[0].name, shown[0].psu_types); setOpen(false); } } }} /> {selected?.logo && !open && ( )} {open && institutions && (
    {shown.map((i) => (
  • ))} {shown.length === 0 && (
  • No banks match “{query}”.
  • )} {matches.length > shown.length && (
  • {matches.length - shown.length} more — keep typing to narrow down.
  • )}
)}
); } function AccountEditor({ account, mutate, close, }: { account: Account; mutate: Mutate; close: () => void; }) { const [value, setValue] = useState(account); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); return (
{ e.preventDefault(); setBusy(true); setError(""); try { await mutate( "/api/accounts", { account: { ...value, display_name: value.display_name.trim(), institution: value.institution.trim(), iban: value.iban?.replaceAll(" ", ""), }, }, "Account saved", ); close(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }} >
setValue({ ...value, display_name: e.target.value }) } placeholder="Everyday account" />
setValue({ ...value, institution: e.target.value }) } /> setValue({ ...value, currency: e.target.value.toUpperCase() }) } />
setValue({ ...value, iban: e.target.value })} /> setValue({ ...value, external_account_id: e.target.value }) } />
); } function DeleteAccount({ account, mutate, close, }: { account: Account; mutate: Mutate; close: () => void; }) { const [confirm, setConfirm] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); return (
{ e.preventDefault(); setBusy(true); setError(""); try { await mutate( "/api/manage", { entity: "account", action: "delete", id: account.id }, "Account deleted", ); close(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }} >

An account with transactions cannot be deleted. Deactivate it instead to preserve its history.

); }