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:
+177
-13
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user