Distinguish debit and credit postings sharing an upstream reference
This commit is contained in:
+8
-4
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user