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.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user