384 lines
14 KiB
Go
384 lines
14 KiB
Go
package banking
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"finance-duck/internal/domain"
|
|
)
|
|
|
|
func fixtureDataset() domain.Dataset {
|
|
d := domain.NewDataset()
|
|
d.Accounts = []domain.Account{{ID: "account_a", DisplayName: "N26", Currency: "EUR", IBAN: "DE02120300000000202051", Active: true}, {ID: "account_b", DisplayName: "Savings", Currency: "EUR", IBAN: "DE89370400440532013000", Active: true}}
|
|
return d
|
|
}
|
|
func fixtureFacts() domain.Facts {
|
|
return domain.Facts{Source: "n26_csv", AccountID: "account_a", BookingDate: "2026-09-01", Amount: "-12.30", Currency: "EUR", RawDescription: "Lunch", Counterparty: "Cafe", Fingerprint: "fixture"}
|
|
}
|
|
|
|
func TestN26SupportedExportSchemas(t *testing.T) {
|
|
cases := []struct{ name, csv, amount, description, party, iban, value string }{
|
|
{"English legacy quoted multiline", "Date,Payee,Account number,Payment type,Payment reference,Amount (EUR),Amount (Foreign Currency),Type Foreign Currency,Exchange Rate\r\n2026-09-01,\"Cafe, Berlin\",DE02120300000000202051,MasterCard Payment,\"Lunch, first line\nsecond line\",-12.30,-14.50,USD,0.85\r\n", "-12.30", "Lunch, first line\nsecond line", "Cafe, Berlin", "DE02120300000000202051", ""},
|
|
{"German decimal comma semicolon BOM", "\ufeffDatum;Zahlungsempfänger;Kontonummer;Transaktionstyp;Verwendungszweck;Betrag (EUR);Betrag (Fremdwährung);Fremdwährung;Wechselkurs\n01.09.2026;Arbeitgeber;DE89 3704 0044 0532 0130 00;Überweisung;Gehalt;\"1.234,56\";;;\n", "1234.56", "Gehalt", "Arbeitgeber", "DE89370400440532013000", ""},
|
|
{"English booking and value dates", "Booking Date,Value Date,Partner Name,Partner IBAN,Type,Payment Reference,Account Name,Amount (EUR),Original Amount,Original Currency,Exchange Rate\n2026-09-01,2026-08-31,Cafe,DE02120300000000202051,Card,Lunch,Main,-12.30,-14.50,USD,0.85\n", "-12.30", "Lunch", "Cafe", "DE02120300000000202051", "2026-08-31"},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rows, err := ParseCSV(strings.NewReader(tt.csv), fixtureDataset().Accounts[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("records: %d", len(rows))
|
|
}
|
|
f := rows[0]
|
|
if f.Amount.String() != tt.amount || f.RawDescription != tt.description || f.Counterparty != tt.party || f.CounterpartyIBAN != tt.iban || f.ValueDate != tt.value || f.BookingDate != "2026-09-01" || f.Currency != "EUR" {
|
|
t.Fatalf("unexpected parsed facts: %+v", f)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestCSVRejectsPartialAndMalformedImports(t *testing.T) {
|
|
for _, input := range []string{
|
|
"Date,Payee,Amount (Foreign Currency)\n2026-09-01,Cafe,-1.00\n",
|
|
"Date,Amount (EUR)\n2026-09-01,-1.00\n2026-09-02,nope\n",
|
|
"Date,Amount (EUR)\n2026-02-30,-1.00\n",
|
|
"Date,Amount (EUR),Currency\n2026-09-01,-1.00,USD\n",
|
|
"Date,Amount (EUR)\n2026-09-01,\"unterminated\n",
|
|
} {
|
|
rows, err := ParseCSV(strings.NewReader(input), fixtureDataset().Accounts[0])
|
|
if err == nil || rows != nil {
|
|
t.Fatalf("accepted malformed/partial import %q", input)
|
|
}
|
|
}
|
|
}
|
|
func TestFallbackOccurrenceMultiplicityAndRepeatImport(t *testing.T) {
|
|
d := fixtureDataset()
|
|
f := fixtureFacts()
|
|
rows, err := NormalizeAndDedupe(d, []domain.Facts{f, f})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 2 || rows[0].Facts.ID == rows[1].Facts.ID {
|
|
t.Fatalf("legitimate duplicate rows lost: %+v", rows)
|
|
}
|
|
d.Transactions = append(d.Transactions, rows...)
|
|
again, err := NormalizeAndDedupe(d, []domain.Facts{f, f})
|
|
if err != nil || len(again) != 0 {
|
|
t.Fatalf("repeat not idempotent: %v %+v", err, again)
|
|
}
|
|
added, err := NormalizeAndDedupe(d, []domain.Facts{f, f, f})
|
|
if err != nil || len(added) != 1 {
|
|
t.Fatalf("new occurrence lost: %v %+v", err, added)
|
|
}
|
|
d.Transactions = append(d.Transactions, added...)
|
|
again, err = NormalizeAndDedupe(d, []domain.Facts{f, f, f})
|
|
if err != nil || len(again) != 0 {
|
|
t.Fatalf("expanded repeat not idempotent: %v %+v", err, again)
|
|
}
|
|
if err := domain.Validate(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func TestUpstreamIdentityPreferredAndAccountScoped(t *testing.T) {
|
|
d := fixtureDataset()
|
|
a := fixtureFacts()
|
|
a.Source = "enablebanking"
|
|
a.ExternalID = "bank-entry-1"
|
|
b := a
|
|
b.AccountID = "account_b"
|
|
rows, err := NormalizeAndDedupe(d, []domain.Facts{a, a, b})
|
|
if err != nil || len(rows) != 2 {
|
|
t.Fatalf("account identity lost: %v %+v", err, rows)
|
|
}
|
|
d.Transactions = rows
|
|
a.RawDescription = "Updated upstream display"
|
|
a.ValueDate = "2026-09-02"
|
|
again, err := NormalizeAndDedupe(d, []domain.Facts{a})
|
|
if err != nil || len(again) != 0 {
|
|
t.Fatalf("upstream identity not preferred: %v %+v", err, again)
|
|
}
|
|
a.Amount = "-99.00"
|
|
again, err = NormalizeAndDedupe(d, []domain.Facts{a})
|
|
if err == nil || again != nil {
|
|
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()
|
|
a.Source = "enablebanking"
|
|
a.ExternalID = "one"
|
|
b := a
|
|
b.ExternalID = "two"
|
|
rows, err := NormalizeAndDedupe(d, []domain.Facts{a, b})
|
|
if err != nil || len(rows) != 2 {
|
|
t.Fatalf("distinct IDs collapsed: %v %+v", err, rows)
|
|
}
|
|
reverse, err := NormalizeAndDedupe(d, []domain.Facts{b, a})
|
|
if err != nil || !reflect.DeepEqual(rows, reverse) {
|
|
t.Fatalf("order changed IDs: %v", err)
|
|
}
|
|
d.Transactions = rows[:1]
|
|
added, err := NormalizeAndDedupe(d, []domain.Facts{a, b})
|
|
if err != nil || len(added) != 1 {
|
|
t.Fatalf("new equal upstream record suppressed: %v %+v", err, added)
|
|
}
|
|
}
|
|
func TestCrossSourceExactMatchAndUncertainty(t *testing.T) {
|
|
d := fixtureDataset()
|
|
csv := fixtureFacts()
|
|
rows, err := NormalizeAndDedupe(d, []domain.Facts{csv})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.Transactions = rows
|
|
api := csv
|
|
api.Source = "enablebanking"
|
|
api.ExternalID = "upstream"
|
|
matched, err := NormalizeAndDedupe(d, []domain.Facts{api})
|
|
if err != nil || len(matched) != 0 {
|
|
t.Fatalf("double counted matching cross-source transaction: %v %+v", err, matched)
|
|
}
|
|
api.RawDescription = "Different bank text"
|
|
matched, err = NormalizeAndDedupe(d, []domain.Facts{api})
|
|
if err == nil || matched != nil {
|
|
t.Fatal("uncertain overlap was silently counted")
|
|
}
|
|
api.RawDescription = csv.RawDescription
|
|
b := api
|
|
b.ExternalID = "second"
|
|
matched, err = NormalizeAndDedupe(d, []domain.Facts{api, b})
|
|
if err == nil || matched != nil {
|
|
t.Fatal("unequal cross-source multiplicity was guessed")
|
|
}
|
|
d.Transactions[0].Facts.RawDescription = ""
|
|
d.Transactions[0].Facts.Counterparty = ""
|
|
api.RawDescription = ""
|
|
api.Counterparty = ""
|
|
matched, err = NormalizeAndDedupe(d, []domain.Facts{api})
|
|
if err == nil || matched != nil {
|
|
t.Fatal("matched cross-source money without descriptive evidence")
|
|
}
|
|
}
|
|
func transferDataset() domain.Dataset {
|
|
d := fixtureDataset()
|
|
a := fixtureFacts()
|
|
a.ID = "tx_a"
|
|
a.Amount = "-10.00"
|
|
a.CounterpartyIBAN = d.Accounts[1].IBAN
|
|
b := a
|
|
b.ID = "tx_b"
|
|
b.AccountID = "account_b"
|
|
b.Amount = "10.00"
|
|
b.BookingDate = "2026-09-03"
|
|
b.CounterpartyIBAN = d.Accounts[0].IBAN
|
|
d.Transactions = []domain.Transaction{{Facts: a, Enrichment: domain.Fallback(a)}, {Facts: b, Enrichment: domain.Fallback(b)}}
|
|
return d
|
|
}
|
|
func TestTransfersRequireUniqueReciprocalOwnBankEvidence(t *testing.T) {
|
|
d := transferDataset()
|
|
MatchTransfers(&d)
|
|
if d.Transactions[0].Enrichment.TransferPeerID != "tx_b" || d.Transactions[1].Enrichment.TransferPeerID != "tx_a" {
|
|
t.Fatal("unique own-account transfer not linked")
|
|
}
|
|
if err := domain.Validate(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, change := range []func(*domain.Dataset){
|
|
func(d *domain.Dataset) {
|
|
copy := d.Transactions[1]
|
|
copy.Facts.ID = "tx_c"
|
|
d.Transactions = append(d.Transactions, copy)
|
|
},
|
|
func(d *domain.Dataset) { d.Transactions[1].Facts.CounterpartyIBAN = "" },
|
|
func(d *domain.Dataset) { d.Transactions[1].Facts.Currency = "USD" },
|
|
func(d *domain.Dataset) { d.Transactions[1].Facts.Amount = "9.99" },
|
|
func(d *domain.Dataset) { d.Transactions[1].Facts.BookingDate = "2026-09-05" },
|
|
func(d *domain.Dataset) {
|
|
d.Accounts = append(d.Accounts, domain.Account{ID: "ambiguous_account", IBAN: d.Accounts[1].IBAN})
|
|
},
|
|
} {
|
|
d := transferDataset()
|
|
change(&d)
|
|
before := domain.Clone(d)
|
|
MatchTransfers(&d)
|
|
if !reflect.DeepEqual(d, before) {
|
|
t.Fatalf("ambiguous or unsupported transfer evidence linked: %+v", d.Transactions)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMixedReferencedAndAnonymousMultiplicity(t *testing.T) {
|
|
d := fixtureDataset()
|
|
anonymous := fixtureFacts()
|
|
anonymous.Source = "enablebanking"
|
|
referenced := anonymous
|
|
referenced.ExternalID = "known-reference"
|
|
original, err := NormalizeAndDedupe(d, []domain.Facts{referenced})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.Transactions = original
|
|
for _, window := range [][]domain.Facts{{referenced, anonymous}, {anonymous, referenced}} {
|
|
added, err := NormalizeAndDedupe(d, window)
|
|
if err != nil || len(added) != 1 || added[0].Facts.ExternalID != "" {
|
|
t.Fatalf("lost additional anonymous booking beside matched reference: %+v %v", added, err)
|
|
}
|
|
if added[0].Facts.ID == original[0].Facts.ID {
|
|
t.Fatal("anonymous booking reused referenced identity")
|
|
}
|
|
}
|
|
added, err := NormalizeAndDedupe(d, []domain.Facts{referenced, anonymous})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.Transactions = append(d.Transactions, added...)
|
|
repeated, err := NormalizeAndDedupe(d, []domain.Facts{anonymous, referenced})
|
|
if err != nil || len(repeated) != 0 {
|
|
t.Fatalf("mixed repeat is not idempotent: %+v %v", repeated, err)
|
|
}
|
|
second, err := NormalizeAndDedupe(d, []domain.Facts{anonymous, referenced, anonymous})
|
|
if err != nil || len(second) != 1 || second[0].Facts.ID == added[0].Facts.ID {
|
|
t.Fatalf("second anonymous occurrence lost or ID reused: %+v %v", second, err)
|
|
}
|
|
d.Transactions = append(d.Transactions, second...)
|
|
repeated, err = NormalizeAndDedupe(d, []domain.Facts{referenced, anonymous, anonymous})
|
|
if err != nil || len(repeated) != 0 {
|
|
t.Fatalf("expanded mixed repeat is not idempotent: %+v %v", repeated, err)
|
|
}
|
|
if err := domain.Validate(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestChangingReferenceAvailabilityFailsClosed(t *testing.T) {
|
|
anonymous := fixtureFacts()
|
|
anonymous.Source = "enablebanking"
|
|
referenced := anonymous
|
|
referenced.ExternalID = "new-reference"
|
|
for _, pair := range [][2]domain.Facts{{anonymous, referenced}, {referenced, anonymous}} {
|
|
d := fixtureDataset()
|
|
original, err := NormalizeAndDedupe(d, []domain.Facts{pair[0]})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.Transactions = original
|
|
added, err := NormalizeAndDedupe(d, []domain.Facts{pair[1]})
|
|
if err == nil || added != nil {
|
|
t.Fatalf("identity availability change silently added/dropped money: %+v %v", added, err)
|
|
}
|
|
}
|
|
// A complete window containing the known anonymous booking separately proves
|
|
// that an additional referenced booking increases multiplicity.
|
|
d := fixtureDataset()
|
|
original, err := NormalizeAndDedupe(d, []domain.Facts{anonymous})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.Transactions = original
|
|
added, err := NormalizeAndDedupe(d, []domain.Facts{anonymous, referenced})
|
|
if err != nil || len(added) != 1 || added[0].Facts.ExternalID != "new-reference" {
|
|
t.Fatalf("proven additional referenced booking was lost: %+v %v", added, err)
|
|
}
|
|
}
|