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:
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user