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.
1278 lines
40 KiB
TypeScript
1278 lines
40 KiB
TypeScript
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 (1–120 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>
|
||
);
|
||
}
|