Files
finance-duck/web/src/Accounts.tsx
T
Lars Nolden a8722d58c3 Import the longest bank-permitted history and surface real provider errors
Manual history imports failed opaquely once a bank capped lookback on an
established consent (N26 rejects date_from beyond ~90 days with
WRONG_TRANSACTIONS_PERIOD). Backfill now requests the documented longest
fetching strategy, reports the coverage the bank actually provided, and
non-2xx responses surface allowlisted documented error codes instead of a
generic fallback. Dead-session codes map to reconnection. Failed syncs
retry hourly so a stale sync banner no longer persists for a day.
2026-09-10 22:58:03 +02:00

936 lines
29 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 { useRef, useState } from "react";
import {
Plus,
Upload,
Link2,
Wallet,
Pencil,
Trash2,
RefreshCw,
} from "lucide-react";
import type { Account, 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,
historyMonths: number,
) {
const response = await request<{ url: string }>("/api/banking/authorize", {
institution,
country,
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.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 fileRef = useRef<HTMLInputElement>(null);
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Upload size={18} /> Import a statement
</h3>
<p>N26 CSV · German and English exports supported</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 {
const response = await request<{ imported: number; state: State }>(
"/api/import",
form,
);
acceptState(
response.state,
`Imported ${response.imported} new transactions. Existing transactions were not duplicated.`,
);
if (fileRef.current) fileRef.current.value = "";
} 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">
Original descriptions and amounts are preserved. 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 ? "Importing statement…" : "Import CSV"}
</button>
</form>
</section>
);
}
function ConnectForm({
state,
onError,
}: {
state: State;
onError: (error: string) => void;
}) {
const [institution, setInstitution] = useState("");
const [country, setCountry] = useState("DE");
const [historyMonths, setHistoryMonths] = useState("12");
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, Number(historyMonths));
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
}}
>
<Field
label="Institution"
hint="Use the institution name recognized by Enable Banking, such as N26."
>
<input
required
value={institution}
onChange={(e) => setInstitution(e.target.value)}
placeholder="N26"
/>
</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())}
/>
</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>
);
}
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>
);
}