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
+27 -4
View File
@@ -44,7 +44,9 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
s.mux.HandleFunc("POST /api/merchants", s.merchant)
s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction)
s.mux.HandleFunc("POST /api/manage", s.manage)
s.mux.HandleFunc("POST /api/import", s.importCSV)
s.mux.HandleFunc("POST /api/import/prepare", s.importPrepare)
s.mux.HandleFunc("POST /api/import/confirm", s.importConfirm)
s.mux.HandleFunc("POST /api/import/cancel", s.importCancel)
s.mux.HandleFunc("POST /api/backfill", s.backfill)
s.mux.HandleFunc("POST /api/rebuild", func(w http.ResponseWriter, r *http.Request) { v, e := a.Rebuild(r.Context()); respond(w, v, e) })
s.mux.HandleFunc("POST /api/sync", func(w http.ResponseWriter, r *http.Request) { v, e := a.Sync(s.manualBankContext(r)); respond(w, v, e) })
@@ -139,7 +141,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
media, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if r.URL.Path != "/api/import" && media != "application/json" {
if r.URL.Path != "/api/import/prepare" && media != "application/json" {
http.Error(w, "application/json required", http.StatusUnsupportedMediaType)
return
}
@@ -309,7 +311,7 @@ func (s *Server) manage(w http.ResponseWriter, r *http.Request) {
v, e := s.app.ManageRegistry(r.Context(), b.Revision, b.Entity, b.Action, b.ID, b.TargetID)
respond(w, v, e)
}
func (s *Server) importCSV(w http.ResponseWriter, r *http.Request) {
func (s *Server) importPrepare(w http.ResponseWriter, r *http.Request) {
if e := r.ParseMultipartForm(2 << 20); e != nil {
respond(w, nil, e)
return
@@ -321,9 +323,30 @@ func (s *Server) importCSV(w http.ResponseWriter, r *http.Request) {
return
}
defer f.Close()
v, e := s.app.ImportCSV(r.Context(), r.FormValue("revision"), r.FormValue("account_id"), f)
v, e := s.app.PrepareCSVImport(r.Context(), r.FormValue("revision"), r.FormValue("account_id"), f)
respond(w, v, e)
}
func (s *Server) importConfirm(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Revision string `json:"revision"`
}
if !decode(w, r, &b) {
return
}
v, e := s.app.ConfirmCSVImport(r.Context(), b.ID, b.Revision)
respond(w, v, e)
}
func (s *Server) importCancel(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
}
if !decode(w, r, &b) {
return
}
s.app.CancelCSVImport(b.ID)
respond(w, map[string]bool{"ok": true}, nil)
}
func (s *Server) backfill(w http.ResponseWriter, r *http.Request) {
var b struct {
Revision string `json:"revision"`
+108
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"encoding/pem"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
@@ -307,3 +308,110 @@ func TestManualBankContextUsesOnlyTrustedPeerAndBrowserMetadata(t *testing.T) {
})
}
}
// The whole import must travel through prepare -> review -> confirm over HTTP,
// and only the multipart upload may bypass the JSON content-type CSRF guard.
func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
h, err := New(a, fstest.MapFS{}, "")
if err != nil {
t.Fatal(err)
}
const origin = "http://localhost:8080"
send := func(path, contentType, body, origin string, want int) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080"+path, strings.NewReader(body))
r.Header.Set("Content-Type", contentType)
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != want {
t.Fatalf("POST %s: got %d, want %d: %s", path, w.Code, want, w.Body.String())
}
return w
}
state := func(w *httptest.ResponseRecorder) app.State {
t.Helper()
var s app.State
if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil {
t.Fatal(err)
}
return s
}
get := func(path string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080"+path, nil))
if w.Code != http.StatusOK {
t.Fatalf("GET %s: got %d: %s", path, w.Code, w.Body.String())
}
return w
}
account := `{"revision":"` + state(get("/api/state")).Revision + `","account":{"display_name":"ING","institution":"ING","currency":"EUR","active":true}}`
current := state(send("/api/accounts", "application/json", account, origin, http.StatusOK))
if len(current.Data.Accounts) != 1 {
t.Fatalf("account was not created: %+v", current.Data.Accounts)
}
statement := "Buchung;Wertstellungsdatum;Auftraggeber/Empfänger;Buchungstext;Verwendungszweck;Betrag;Währung\n" +
"09.12.2025;09.12.2025;VISA Firma;Lastschrift;NR XXXX 4025 KAUFUMSATZ;-13,98;EUR\n" +
"28.11.2025;28.11.2025;Rente;Gehalt/Rente;RV-RENTE 11.2025;2.647,74;EUR\n"
upload := func(revision, accountID string) (string, string) {
t.Helper()
var body strings.Builder
form := multipart.NewWriter(&body)
for name, value := range map[string]string{"revision": revision, "account_id": accountID} {
if err := form.WriteField(name, value); err != nil {
t.Fatal(err)
}
}
file, err := form.CreateFormFile("file", "umsatzanzeige.csv")
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(file, statement); err != nil {
t.Fatal(err)
}
if err := form.Close(); err != nil {
t.Fatal(err)
}
return form.FormDataContentType(), body.String()
}
contentType, body := upload(current.Revision, current.Data.Accounts[0].ID)
// A multipart upload from another origin is still refused.
send("/api/import/prepare", contentType, body, "https://attacker.example", http.StatusForbidden)
var prepared app.CSVImport
if err := json.Unmarshal(send("/api/import/prepare", contentType, body, origin, http.StatusOK).Body.Bytes(), &prepared); err != nil {
t.Fatal(err)
}
if prepared.SourceLabel != "ING" || prepared.MappedBy != "preset" || prepared.New != 2 || len(prepared.Samples) != 2 {
t.Fatalf("unexpected prepared import: %+v", prepared)
}
if transactions := state(get("/api/state")).Data.Transactions; len(transactions) != 0 {
t.Fatalf("preparing an import wrote %d transactions", len(transactions))
}
// Confirmation is an ordinary JSON mutation, guarded like every other one.
confirm := `{"id":"` + prepared.ID + `","revision":"` + prepared.Revision + `"}`
send("/api/import/confirm", "text/plain", confirm, origin, http.StatusUnsupportedMediaType)
send("/api/import/cancel", "text/plain", `{"id":"`+prepared.ID+`"}`, origin, http.StatusUnsupportedMediaType)
var result app.ImportResult
if err := json.Unmarshal(send("/api/import/confirm", "application/json", confirm, origin, http.StatusOK).Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Imported != 2 || len(result.State.Data.Transactions) != 2 {
t.Fatalf("confirmation did not import the reviewed statement: %+v", result)
}
for _, tx := range result.State.Data.Transactions {
if tx.Facts.Source != "ing_csv" || tx.Facts.Currency != "EUR" {
t.Fatalf("unexpected imported facts: %+v", tx.Facts)
}
}
send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest)
}