Files
finance-duck/web/src/Accounts.tsx
T
Lars Nolden dc767799bc Import ING and Kontist statements behind a reviewed column mapping
CSV import is now mapping-driven: N26, ING (metadata preamble, Windows-1252,
German decimals) and Kontist exports are recognized locally, and any other
layout can have its columns proposed by the configured model from a sample in
which letters are replaced by x and digits by 0. Proposals are untrusted: every
column must name a supplied header, money must come from one signed column or
one debit/credit pair, and formats must be from a closed list.

Uploading no longer imports. /api/import is replaced by prepare/confirm/cancel:
prepare parses, deduplicates and previews the exact facts, and only confirming
at the reviewed revision writes them. ING and AI-mapped facts carry no
transaction reference, because repeating SEPA mandate references must never
become a transaction identity.
2026-09-11 17:49:03 +02:00

1278 lines
40 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.
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<Account | null>(null);
return (
<>
<AccountsContent
key={props.state.revision}
{...props}
backfill={setBackfilling}
/>
{backfilling && (
<BackfillHistory
account={backfilling}
state={props.state}
acceptState={props.acceptState}
close={() => 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<Account | null>(null);
const [deleting, setDeleting] = useState<Account | null>(null);
const [error, setError] = useState("");
const [syncing, setSyncing] = useState(false);
return (
<>
<div className="section-heading">
<div>
<h2>Accounts & connections</h2>
<p>Bring your financial picture together, one account at a time.</p>
</div>
<button
className="button primary"
onClick={() =>
setEditing({
id: "",
display_name: "",
institution: "N26",
currency: "EUR",
active: true,
})
}
>
<Plus size={17} />
Add account
</button>
</div>
<ErrorMessage error={error} />
<div className="account-grid">
{state.data.accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
state={state}
edit={() => setEditing(account)}
remove={() => setDeleting(account)}
backfill={() => backfill(account)}
onError={setError}
/>
))}
</div>
{!state.data.accounts.length && (
<section className="panel">
<Empty title="Make room for your first account">
Add an account to import CSV statements, or connect your bank below.
</Empty>
</section>
)}
<div className="dashboard-grid">
<ImportForm
state={state}
acceptState={acceptState}
onError={setError}
/>
<ConnectForm state={state} onError={setError} />
</div>
<section className="panel">
<div className="panel-heading">
<div>
<h3>Bank sessions</h3>
<p>
Reconnect when a session expires. Imported journal history is
retained.
</p>
</div>
<button
className="button secondary"
disabled={
syncing ||
!state.status.banking_configured ||
!state.sessions.length
}
onClick={async () => {
setSyncing(true);
setError("");
try {
await mutate("/api/sync", {}, "Bank sync completed");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSyncing(false);
}
}}
>
<RefreshCw size={16} className={syncing ? "spin" : ""} />
{syncing ? "Syncing…" : "Sync now"}
</button>
</div>
{state.sessions.length ? (
<div className="session-list">
{state.sessions.map((session) => (
<div className="session" key={session.session_id}>
<div>
<strong>Bank connection</strong>
<small>
Valid until {session.valid_until || "not supplied"}
</small>
<code>{session.session_id}</code>
</div>
<div>
{session.accounts.map((account) => (
<span className="badge neutral" key={account.id}>
{account.display_name}
</span>
))}
</div>
</div>
))}
</div>
) : (
<div className="compact-empty">
No bank sessions. CSV imports work without a bank connection.
</div>
)}
</section>
{editing && (
<AccountEditor
account={editing}
mutate={mutate}
close={() => setEditing(null)}
/>
)}
{deleting && (
<DeleteAccount
account={deleting}
mutate={mutate}
close={() => 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<Balance[] | null>(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 (
<section className="panel account-card">
<div className="account-card-heading">
<span className="account-icon">
<Wallet size={23} />
</span>
<span className={`badge ${account.active ? "" : "neutral"}`}>
{account.active ? "Active" : "Inactive"}
</span>
</div>
<h3>{account.display_name}</h3>
<p>
{account.institution} · {account.currency}
</p>
{account.iban && <small className="account-iban">{account.iban}</small>}
<div className="connection-status">
<span
className={`badge ${needsReconnect || connection?.status === "error" ? "connection-warning" : "neutral"}`}
>
{needsReconnect
? `${institution} needs reconnection`
: connection?.status === "connected"
? "Bank connected"
: connection?.status === "error"
? "Connection error"
: connection?.status === "local"
? "Local account"
: "Connection status unavailable"}
</span>
{connection?.valid_until && (
<small>Authorization expires {connection.valid_until}</small>
)}
{connection && connection.status !== "local" && (
<small>
Initial history: {connection.history_months}{" "}
{connection.history_months === 1 ? "month" : "months"}
</small>
)}
{connection?.error && (
<small className="text-danger">{connection.error}</small>
)}
{connection && connection.status !== "local" && (
<button
className="button secondary"
disabled={connecting || !state.status.banking_configured}
onClick={async () => {
setConnecting(true);
onError("");
try {
await authorize(
institution,
connection.country || "DE",
connection.psu_type || "personal",
connection.history_months,
);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setConnecting(false);
}
}}
>
<Link2 size={14} />
{connecting ? "Opening bank…" : `Reconnect ${institution}`}
</button>
)}
<button
className="button secondary"
disabled={connecting || !!backfillReason}
title={backfillReason || undefined}
onClick={backfill}
>
<Upload size={14} />
Import older history
</button>
{backfillReason && <small>{backfillReason}</small>}
</div>
<div className="balance-area">
{balances ? (
balances.length ? (
balances.map((balance, i) => (
<div key={i}>
<small>{balance.type}</small>
<strong>{money(balance.amount, balance.currency)}</strong>
</div>
))
) : (
<span className="muted small">
No balances returned for this account.
</span>
)
) : (
<span className="muted small">
Live balance has not been requested.
</span>
)}
</div>
<div className="account-actions">
<button
className="button subtle"
disabled={
busy ||
!account.external_account_id ||
!state.status.banking_configured
}
title={
!account.external_account_id
? "Connect a provider account to load live balances"
: undefined
}
onClick={async () => {
setBusy(true);
onError("");
try {
const result = await request<Balance[]>(
`/api/balances?account_id=${encodeURIComponent(account.id)}`,
);
if (!Array.isArray(result))
throw new Error("The server returned invalid balances.");
setBalances(result);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<RefreshCw size={14} />
{busy ? "Loading…" : "Balance"}
</button>
<div className="row-actions">
<button
className="icon-button"
aria-label={`Edit ${account.display_name}`}
onClick={edit}
>
<Pencil size={16} />
</button>
<button
className="icon-button danger"
aria-label={`Delete ${account.display_name}`}
onClick={remove}
>
<Trash2 size={16} />
</button>
</div>
</div>
</section>
);
}
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 (
<Modal title="Import older history" close={closeWhenIdle}>
<form
aria-busy={busy}
onSubmit={async (e) => {
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<State>("/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);
}
}}
>
<div className="form-body">
<p>
Import bank transactions for <strong>{account.display_name}</strong>{" "}
({account.institution} · {account.currency}).
</p>
<ErrorMessage error={error} />
{success && <p role="status">{success}</p>}
<Field
label="Months back"
hint="Request history from this many calendar months ago through today. Your bank may provide less history."
>
<input
autoFocus
type="number"
required
min={1}
max={120}
step={1}
disabled={busy}
value={historyMonths}
onChange={(e) => setHistoryMonths(e.target.value)}
/>
</Field>
<p className="muted small">
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.
</p>
{unavailable && <p className="muted small">{unavailable}</p>}
{busy && (
<p className="muted small" role="status">
Importing older history Keep this dialog open while the bank
request and journal update finish.
</p>
)}
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={closeWhenIdle}
disabled={busy}
>
{success ? "Close" : "Cancel"}
</button>
<button
type="submit"
className="button primary"
disabled={busy || !!unavailable}
>
{busy ? "Importing history…" : "Import older history"}
</button>
</div>
</form>
</Modal>
);
}
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<PreparedImport | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Upload size={18} /> Import a statement
</h3>
<p>
N26, ING and Kontist CSV · other layouts are mapped with AI when
configured
</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
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<PreparedImport>("/api/import/prepare", form),
);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<Field label="Import into account">
<select
required
value={account}
onChange={(e) => setAccount(e.target.value)}
>
<option value="">Choose an account</option>
{state.data.accounts.map((a) => (
<option value={a.id} key={a.id}>
{a.display_name} ({a.currency})
</option>
))}
</select>
</Field>
<Field label="CSV statement">
<input ref={fileRef} required type="file" accept=".csv,text/csv" />
</Field>
<p className="muted small">
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.
</p>
<button
className="button primary"
disabled={busy || !state.data.accounts.length}
>
<Upload size={16} />
{busy ? "Reading statement…" : "Review statement"}
</button>
</form>
{prepared && (
<ImportReview
prepared={prepared}
acceptState={acceptState}
onError={onError}
close={() => {
setPrepared(null);
if (fileRef.current) fileRef.current.value = "";
}}
/>
)}
</section>
);
}
// 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 (
<Modal title="Review this statement" wide close={discard}>
<div className="form-body">
{prepared.mapped_by === "openrouter" ? (
<div className="alert warning">
<Sparkles size={17} />
<div>
<strong>Columns mapped by {prepared.model}</strong>
<p>
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.
</p>
</div>
</div>
) : (
<p className="muted small">
Recognized {prepared.source_label} export. Columns were mapped on
this machine, without AI.
</p>
)}
<div className="preview-summary">
<span>
<strong>{prepared.records}</strong> records
</span>
<span>
<strong>{prepared.new}</strong> new
</span>
<span>
<strong>{prepared.duplicates}</strong> already imported
</span>
</div>
<details open>
<summary>Column mapping</summary>
<dl className="facts">
{prepared.columns.map((column) => (
<div key={column.field}>
<dt>{column.field}</dt>
<dd>{column.column}</dd>
</div>
))}
</dl>
</details>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Booking date</th>
<th>Description</th>
<th>Counterparty</th>
<th className="numeric">Amount</th>
</tr>
</thead>
<tbody>
{prepared.samples.map((facts, index) => (
<tr key={index}>
<td className="nowrap">
{facts.booking_date}
{!!facts.value_date &&
facts.value_date !== facts.booking_date && (
<small>value {facts.value_date}</small>
)}
</td>
<td>
{facts.raw_description || (
<span className="muted">no description</span>
)}
</td>
<td>{facts.counterparty || ""}</td>
<td className="numeric money">
{money(facts.amount, facts.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="muted small">
{prepared.samples.length} of {prepared.records} records, including the
largest amount and both directions of money.
</p>
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={discard}
disabled={busy}
>
Cancel
</button>
<button
type="button"
className="button primary"
disabled={busy || !prepared.new}
onClick={async () => {
setBusy(true);
onError("");
try {
const response = await request<{
imported: number;
state: State;
}>("/api/import/confirm", {
id: prepared.id,
revision: prepared.revision,
});
acceptState(
response.state,
`Imported ${response.imported} new transactions. Existing transactions were not duplicated.`,
);
close();
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
close();
} finally {
setBusy(false);
}
}}
>
<Upload size={16} />
{busy
? "Importing…"
: prepared.new
? `Import ${prepared.new} transactions`
: "Nothing new to import"}
</button>
</div>
</Modal>
);
}
function ConnectForm({
state,
onError,
}: {
state: State;
onError: (error: string) => void;
}) {
const [institution, setInstitution] = useState("");
const [psuTypes, setPSUTypes] = useState<string[] | null>(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 (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Link2 size={18} /> Connect your bank
</h3>
<p>Secure authorization through Enable Banking</p>
</div>
<span
className={`badge ${state.status.banking_configured ? "" : "neutral"}`}
>
{state.status.banking_configured ? "Configured" : "Not configured"}
</span>
</div>
<div className="callback-details">
<Field
label={
state.callback_url
? "Configured callback URL"
: "Suggested callback URL (not configured)"
}
>
<input readOnly value={callback} onFocus={(e) => e.target.select()} />
</Field>
<button
className="button secondary"
onClick={async () => {
try {
if (!navigator.clipboard)
throw new Error(
"Clipboard unavailable. Select and copy the callback URL above.",
);
await navigator.clipboard.writeText(callback);
setCopied(true);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
}
}}
>
{copied ? "Copied" : "Copy callback URL"}
</button>
<p>
Configure your application in <a href="#settings">Settings</a> and
register this exact URL with Enable Banking. It must match exactly,
including scheme, hostname, port and path.
</p>
</div>
<form
className="form-body"
onSubmit={async (e) => {
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);
}
}}
>
<InstitutionSelect
country={country}
configured={state.status.banking_configured}
value={institution}
onChange={chooseInstitution}
/>
<Field
label="Account type"
hint={
psuTypes?.length === 1
? `${institution} offers account information for ${psuTypes[0]} accounts only.`
: "Business accounts must be authorized as business: the personal flow returns a consent without accounts."
}
>
<select
required
value={psuType}
onChange={(e) => setPSUType(e.target.value)}
>
{(psuTypes ?? ["personal", "business"]).map((kind) => (
<option key={kind} value={kind}>
{kind === "business" ? "Business" : "Personal"}
</option>
))}
</select>
</Field>
<Field label="Country" hint="Two-letter country code">
<input
required
pattern="[A-Z]{2}"
maxLength={2}
value={country}
onChange={(e) => {
setCountry(e.target.value.toUpperCase());
chooseInstitution("");
}}
/>
</Field>
<Field
label="History to import (months)"
hint="Initial history for newly synced accounts (1120 calendar months). Your bank may provide less. Existing accounts keep their sync cursors; this does not backfill them."
>
<input
type="number"
required
min={1}
max={120}
step={1}
value={historyMonths}
onChange={(e) => setHistoryMonths(e.target.value)}
/>
</Field>
{!state.status.banking_configured && (
<p className="muted small">
Add your Enable Banking application ID and private key in{" "}
<a href="#settings">Settings</a> first, then return here to
authorize your bank.
</p>
)}
<button
className="button primary"
disabled={busy || !state.status.banking_configured}
>
<Link2 size={16} />
{busy ? "Opening bank…" : "Authorize bank"}
</button>
</form>
</section>
);
}
// 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<Institution[] | null>(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<Institution[]>(
`/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 (
<Field
label="Institution"
hint={
loadError
? `The bank list could not be loaded: ${loadError} Enter the institution name recognized by Enable Banking, such as N26.`
: "Use the institution name recognized by Enable Banking, such as N26."
}
>
<input
required
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="N26"
/>
</Field>
);
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 (
<Field
label="Institution"
hint="Choose your bank as listed by Enable Banking."
>
<div className="bank-select">
<input
required
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
disabled={!institutions}
value={open ? query : value}
placeholder={institutions ? "Search your bank" : "Loading banks…"}
onFocus={() => {
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 && (
<img className="bank-selected-logo" src={selected.logo} alt="" />
)}
{open && institutions && (
<ul className="bank-options" role="listbox">
{shown.map((i) => (
<li key={i.name}>
<button
type="button"
className="bank-option"
role="option"
aria-selected={i.name === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(i.name, i.psu_types);
setOpen(false);
}}
>
{i.logo ? (
<img src={i.logo} alt="" loading="lazy" />
) : (
<Landmark size={16} />
)}
<span>{i.name}</span>
</button>
</li>
))}
{shown.length === 0 && (
<li className="bank-empty">No banks match {query}.</li>
)}
{matches.length > shown.length && (
<li className="bank-empty">
{matches.length - shown.length} more keep typing to narrow
down.
</li>
)}
</ul>
)}
</div>
</Field>
);
}
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 (
<Modal title={account.id ? "Edit account" : "Add an account"} close={close}>
<form
onSubmit={async (e) => {
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);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<Field label="Display name">
<input
autoFocus
required
value={value.display_name}
onChange={(e) =>
setValue({ ...value, display_name: e.target.value })
}
placeholder="Everyday account"
/>
</Field>
<div className="two-columns">
<Field label="Institution">
<input
required
value={value.institution}
onChange={(e) =>
setValue({ ...value, institution: e.target.value })
}
/>
</Field>
<Field label="Currency">
<input
required
pattern="[A-Z]{3}"
maxLength={3}
value={value.currency}
onChange={(e) =>
setValue({ ...value, currency: e.target.value.toUpperCase() })
}
/>
</Field>
</div>
<Field
label="IBAN (optional)"
hint="Used to recognize transfers between your own accounts."
>
<input
value={value.iban || ""}
onChange={(e) => setValue({ ...value, iban: e.target.value })}
/>
</Field>
<Field
label="External account ID (optional)"
hint="The provider account identifier used for connected-bank sync."
>
<input
value={value.external_account_id || ""}
onChange={(e) =>
setValue({ ...value, external_account_id: e.target.value })
}
/>
</Field>
<label className="checkbox">
<input
type="checkbox"
checked={value.active}
onChange={(e) => setValue({ ...value, active: e.target.checked })}
/>
Account is active
</label>
</div>
<FormActions
busy={busy}
close={close}
label={account.id ? "Save changes" : "Create account"}
/>
</form>
</Modal>
);
}
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 (
<Modal title={`Delete ${account.display_name}?`} close={close}>
<form
onSubmit={async (e) => {
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);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<p>
An account with transactions cannot be deleted. Deactivate it
instead to preserve its history.
</p>
<label className="checkbox">
<input
required
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
/>
Permanently delete this empty account.
</label>
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={close}
disabled={busy}
>
Cancel
</button>
<button className="button destructive" disabled={!confirm || busy}>
{busy ? "Deleting…" : "Delete account"}
</button>
</div>
</form>
</Modal>
);
}