Distinguish debit and credit postings sharing an upstream reference

This commit is contained in:
Lars Nolden
2026-09-10 15:48:42 +02:00
parent 964b9dfc15
commit bec6d0b444
3 changed files with 119 additions and 8 deletions
+8 -4
View File
@@ -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. original amounts/exchange-rate columns are not mistaken for account amounts.
The initial application preserves currency but never converts or sums currencies. The initial application preserves currency but never converts or sums currencies.
Stable provider entry references are preferred. Enable Banking transaction_id Stable provider entry references are scoped by account, source and debit/credit
is NOT guaranteed stable and is not used as the primary identity. Fallback direction: a debit and credit can share a reference without being collapsed.
fingerprints retain identical-record occurrence counts: two identical rows Conflicting booking dates, amounts or currencies within one direction still
remain two transactions, and repeat imports do not add two more. Without stable 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 IDs, identical records from separately truncated exports are intrinsically
ambiguous. Import consistent overlapping/full exports. Uncertain cross-source ambiguous. Import consistent overlapping/full exports. Uncertain cross-source
collisions are rejected rather than silently double counted; retain the error collisions are rejected rather than silently double counted; retain the error
+13 -4
View File
@@ -18,7 +18,15 @@ func digest(parts ...string) string {
h := sha256.Sum256(b) h := sha256.Sum256(b)
return hex.EncodeToString(h[:]) 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 { 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) 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 // NormalizeAndDedupe returns new records without mutating the input. Stable bank
// entry references take precedence over text. CSV rows without references use // entry references, scoped by account, source and debit/credit direction, take
// occurrence counts, not a set: two identical rows remain two transactions and // precedence over text. Rows without references use occurrence counts, not a set:
// importing the same export again creates none. For overlapping partial exports, // two identical rows remain two transactions; reimporting creates none.
// For overlapping partial exports,
// indistinguishable rows cannot prove an additional occurrence; import complete // indistinguishable rows cannot prove an additional occurrence; import complete
// overlapping date windows to establish multiplicity. // overlapping date windows to establish multiplicity.
// //
+98
View File
@@ -105,6 +105,104 @@ func TestUpstreamIdentityPreferredAndAccountScoped(t *testing.T) {
t.Fatal("changed immutable upstream money accepted") 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) { func TestDistinctUpstreamIDsPreserveEqualTransactions(t *testing.T) {
d := fixtureDataset() d := fixtureDataset()
a := fixtureFacts() a := fixtureFacts()