From bec6d0b4441b430b336f1fec996796b17c036e99 Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Thu, 10 Sep 2026 15:48:42 +0200 Subject: [PATCH] Distinguish debit and credit postings sharing an upstream reference --- OPERATIONS.txt | 12 ++-- internal/banking/import.go | 17 ++++-- internal/banking/import_test.go | 98 +++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/OPERATIONS.txt b/OPERATIONS.txt index 7f085a2..f3f88cc 100644 --- a/OPERATIONS.txt +++ b/OPERATIONS.txt @@ -227,10 +227,14 @@ Use the original export, not spreadsheet-reformatted dates/numbers. Foreign original amounts/exchange-rate columns are not mistaken for account amounts. The initial application preserves currency but never converts or sums currencies. -Stable provider entry references are preferred. Enable Banking transaction_id -is NOT guaranteed stable and is not used as the primary identity. Fallback -fingerprints retain identical-record occurrence counts: two identical rows -remain two transactions, and repeat imports do not add two more. Without stable +Stable provider entry references are scoped by account, source and debit/credit +direction: a debit and credit can share a reference without being collapsed. +Conflicting booking dates, amounts or currencies within one direction still +fail closed. Existing journal IDs and raw references are retained. +Enable Banking transaction_id is NOT guaranteed stable and is not used as the +primary identity. Fallback fingerprints retain identical-record occurrence +counts: two identical rows remain two transactions, and repeat imports do not +add two more. Without stable IDs, identical records from separately truncated exports are intrinsically ambiguous. Import consistent overlapping/full exports. Uncertain cross-source collisions are rejected rather than silently double counted; retain the error diff --git a/internal/banking/import.go b/internal/banking/import.go index e6bb2e2..93b1078 100644 --- a/internal/banking/import.go +++ b/internal/banking/import.go @@ -18,7 +18,15 @@ func digest(parts ...string) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } -func identity(f domain.Facts) string { return digest(f.AccountID, f.Source, f.ExternalID) } +func identity(f domain.Facts) string { + // Banks can reuse a reference for a debit and its credit counterpart. + // Facts are normalized before lookup; never rewrite stored journal IDs. + direction := "credit" + if strings.HasPrefix(string(f.Amount), "-") { + direction = "debit" + } + return digest(f.AccountID, f.Source, f.ExternalID, direction) +} func fingerprint(f domain.Facts) string { return digest(f.AccountID, f.BookingDate, f.ValueDate, f.Amount.String(), f.Currency, strings.Join(strings.Fields(f.RawDescription), " "), strings.ToLower(strings.Join(strings.Fields(f.Counterparty), " ")), f.CounterpartyIBAN) } @@ -30,9 +38,10 @@ func sameBookedMoney(a, b domain.Facts) bool { } // NormalizeAndDedupe returns new records without mutating the input. Stable bank -// entry references take precedence over text. CSV rows without references use -// occurrence counts, not a set: two identical rows remain two transactions and -// importing the same export again creates none. For overlapping partial exports, +// entry references, scoped by account, source and debit/credit direction, take +// precedence over text. Rows without references use occurrence counts, not a set: +// two identical rows remain two transactions; reimporting creates none. +// For overlapping partial exports, // indistinguishable rows cannot prove an additional occurrence; import complete // overlapping date windows to establish multiplicity. // diff --git a/internal/banking/import_test.go b/internal/banking/import_test.go index be0d5b8..a92a237 100644 --- a/internal/banking/import_test.go +++ b/internal/banking/import_test.go @@ -105,6 +105,104 @@ func TestUpstreamIdentityPreferredAndAccountScoped(t *testing.T) { t.Fatal("changed immutable upstream money accepted") } } +func TestUpstreamReferenceSeparatesDebitAndCredit(t *testing.T) { + d := fixtureDataset() + debit := fixtureFacts() + debit.Source = "enablebanking" + debit.ExternalID = "shared-bank-reference" + credit := debit + credit.Amount = "12.30" + rows, err := NormalizeAndDedupe(d, []domain.Facts{debit, credit, debit, credit}) + if err != nil { + t.Fatal(err) + } + amounts := map[domain.Money]int{} + for _, row := range rows { + amounts[row.Facts.Amount]++ + } + if !reflect.DeepEqual(amounts, map[domain.Money]int{"-12.30": 1, "12.30": 1}) { + t.Fatalf("debit/credit postings lost or duplicated: %+v", rows) + } + d.Transactions = rows + if err := domain.Validate(d); err != nil { + t.Fatal(err) + } + again, err := NormalizeAndDedupe(d, []domain.Facts{credit, debit}) + if err != nil || len(again) != 0 { + t.Fatalf("repeated debit/credit pair was not idempotent: %+v %v", again, err) + } +} + +func TestUpstreamDirectionPreservesPreviouslyStoredIdentity(t *testing.T) { + for _, amount := range []domain.Money{"-12.30", "12.30"} { + t.Run(string(amount), func(t *testing.T) { + d := fixtureDataset() + stored := fixtureFacts() + stored.Source = "enablebanking" + stored.ExternalID = "shared-bank-reference" + stored.Amount = amount + // Reproduce the journal identity written before direction scoping. + stored.ID = "tx_" + digest(stored.AccountID, stored.Source, stored.ExternalID) + stored.Fingerprint = fingerprint(stored) + d.Transactions = []domain.Transaction{{Facts: stored, Enrichment: domain.Fallback(stored)}} + opposite := stored + opposite.Amount = "12.30" + if amount == "12.30" { + opposite.Amount = "-12.30" + } + incoming := stored + incoming.RawDescription = "Updated upstream display" + added, err := NormalizeAndDedupe(d, []domain.Facts{opposite, incoming}) + if err != nil || len(added) != 1 || added[0].Facts.Amount != opposite.Amount { + t.Fatalf("stored posting duplicated or opposite posting lost: %+v %v", added, err) + } + if !reflect.DeepEqual(d.Transactions[0].Facts, stored) { + t.Fatal("previously stored bank facts were rewritten") + } + d.Transactions = append(d.Transactions, added...) + if err := domain.Validate(d); err != nil { + t.Fatal(err) + } + again, err := NormalizeAndDedupe(d, []domain.Facts{incoming, opposite}) + if err != nil || len(again) != 0 { + t.Fatalf("upgraded journal was not idempotent: %+v %v", again, err) + } + }) + } +} + +func TestUpstreamReferenceRejectsSameDirectionConflicts(t *testing.T) { + original := fixtureFacts() + original.Source = "enablebanking" + original.ExternalID = "shared-bank-reference" + original.Amount = "12.30" + for _, tc := range []struct { + name string + change func(*domain.Facts) + }{ + {"amount", func(f *domain.Facts) { f.Amount = "99.00" }}, + {"booking date", func(f *domain.Facts) { f.BookingDate = "2026-09-02" }}, + {"currency", func(f *domain.Facts) { f.Currency = "USD" }}, + } { + t.Run(tc.name, func(t *testing.T) { + d := fixtureDataset() + changed := original + tc.change(&changed) + if added, err := NormalizeAndDedupe(d, []domain.Facts{original, changed}); err == nil || added != nil { + t.Fatal("conflicting incoming postings were accepted") + } + var err error + d.Transactions, err = NormalizeAndDedupe(d, []domain.Facts{original}) + if err != nil { + t.Fatal(err) + } + if added, err := NormalizeAndDedupe(d, []domain.Facts{changed}); err == nil || added != nil { + t.Fatal("changed stored booking facts were accepted") + } + }) + } +} + func TestDistinctUpstreamIDsPreserveEqualTransactions(t *testing.T) { d := fixtureDataset() a := fixtureFacts()