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.
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
+13 -4
View File
@@ -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.
//
+98
View File
@@ -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()