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.
This commit is contained in:
Lars Nolden
2026-09-11 17:49:03 +02:00
parent 6f791b1277
commit dc767799bc
16 changed files with 2182 additions and 301 deletions
+177 -13
View File
@@ -8,8 +8,9 @@ import {
Trash2,
RefreshCw,
Landmark,
Sparkles,
} from "lucide-react";
import type { Account, Institution, State } from "./api";
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";
@@ -557,6 +558,7 @@ function ImportForm({
}) {
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">
@@ -565,7 +567,10 @@ function ImportForm({
<h3>
<Upload size={18} /> Import a statement
</h3>
<p>N26 CSV · German and English exports supported</p>
<p>
N26, ING and Kontist CSV · other layouts are mapped with AI when
configured
</p>
</div>
</div>
<form
@@ -581,15 +586,9 @@ function ImportForm({
form.set("revision", state.revision);
form.set("file", file);
try {
const response = await request<{ imported: number; state: State }>(
"/api/import",
form,
setPrepared(
await request<PreparedImport>("/api/import/prepare", 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 {
@@ -615,20 +614,185 @@ function ImportForm({
<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.
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 ? "Importing statement…" : "Import CSV"}
{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,