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"`