Distinguish debit and credit postings sharing an upstream reference
This commit is contained in:
@@ -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