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
-37
View File
@@ -2,7 +2,6 @@ package banking
import (
"reflect"
"strings"
"testing"
"finance-duck/internal/domain"
@@ -17,42 +16,6 @@ func fixtureFacts() domain.Facts {
return domain.Facts{Source: "n26_csv", AccountID: "account_a", BookingDate: "2026-09-01", Amount: "-12.30", Currency: "EUR", RawDescription: "Lunch", Counterparty: "Cafe", Fingerprint: "fixture"}
}
func TestN26SupportedExportSchemas(t *testing.T) {
cases := []struct{ name, csv, amount, description, party, iban, value string }{
{"English legacy quoted multiline", "Date,Payee,Account number,Payment type,Payment reference,Amount (EUR),Amount (Foreign Currency),Type Foreign Currency,Exchange Rate\r\n2026-09-01,\"Cafe, Berlin\",DE02120300000000202051,MasterCard Payment,\"Lunch, first line\nsecond line\",-12.30,-14.50,USD,0.85\r\n", "-12.30", "Lunch, first line\nsecond line", "Cafe, Berlin", "DE02120300000000202051", ""},
{"German decimal comma semicolon BOM", "\ufeffDatum;Zahlungsempfänger;Kontonummer;Transaktionstyp;Verwendungszweck;Betrag (EUR);Betrag (Fremdwährung);Fremdwährung;Wechselkurs\n01.09.2026;Arbeitgeber;DE89 3704 0044 0532 0130 00;Überweisung;Gehalt;\"1.234,56\";;;\n", "1234.56", "Gehalt", "Arbeitgeber", "DE89370400440532013000", ""},
{"English booking and value dates", "Booking Date,Value Date,Partner Name,Partner IBAN,Type,Payment Reference,Account Name,Amount (EUR),Original Amount,Original Currency,Exchange Rate\n2026-09-01,2026-08-31,Cafe,DE02120300000000202051,Card,Lunch,Main,-12.30,-14.50,USD,0.85\n", "-12.30", "Lunch", "Cafe", "DE02120300000000202051", "2026-08-31"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
rows, err := ParseCSV(strings.NewReader(tt.csv), fixtureDataset().Accounts[0])
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("records: %d", len(rows))
}
f := rows[0]
if f.Amount.String() != tt.amount || f.RawDescription != tt.description || f.Counterparty != tt.party || f.CounterpartyIBAN != tt.iban || f.ValueDate != tt.value || f.BookingDate != "2026-09-01" || f.Currency != "EUR" {
t.Fatalf("unexpected parsed facts: %+v", f)
}
})
}
}
func TestCSVRejectsPartialAndMalformedImports(t *testing.T) {
for _, input := range []string{
"Date,Payee,Amount (Foreign Currency)\n2026-09-01,Cafe,-1.00\n",
"Date,Amount (EUR)\n2026-09-01,-1.00\n2026-09-02,nope\n",
"Date,Amount (EUR)\n2026-02-30,-1.00\n",
"Date,Amount (EUR),Currency\n2026-09-01,-1.00,USD\n",
"Date,Amount (EUR)\n2026-09-01,\"unterminated\n",
} {
rows, err := ParseCSV(strings.NewReader(input), fixtureDataset().Accounts[0])
if err == nil || rows != nil {
t.Fatalf("accepted malformed/partial import %q", input)
}
}
}
func TestFallbackOccurrenceMultiplicityAndRepeatImport(t *testing.T) {
d := fixtureDataset()
f := fixtureFacts()