package reconcile // Expected values verified against scripts/match_payments.py on 2026-05-06: // // PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v // // All Python test cases are ported below. Additional Go-only cases are marked with [Go]. import ( "math" "testing" ) const defaultYear = 2026 // tx builds a pre-matched Transaction (person+purpose already filled in). // InferredAmount is left nil so Amount is used directly, matching the Python // _tx helper where inferred_amount == amount. func tx(person, purpose string, amount float64) Transaction { return Transaction{ Date: "2026-01-01", Amount: amount, Person: person, Purpose: purpose, Sender: "Sender", Message: "fee", } } func TestReconcileExceptionOverride(t *testing.T) { t.Parallel() members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}} exceptions := map[ExceptionKey]Exception{ {Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"}, } txs := []Transaction{{ Date: "2026-01-05", Amount: 400, Person: "Alice", Purpose: "2026-01", Sender: "Alice Sender", Message: "fee", }} result := Reconcile(members, []string{"2026-01"}, txs, exceptions, defaultYear) jan := result.Members["Alice"].Months["2026-01"] if jan.Expected != 400 { t.Errorf("Expected override to 400, got %d", jan.Expected) } if jan.Paid != 400 { t.Errorf("Paid want 400, got %f", jan.Paid) } if result.Members["Alice"].TotalBalance != 0 { t.Errorf("TotalBalance want 0, got %d", result.Members["Alice"].TotalBalance) } } func TestReconcileFallbackToAttendance(t *testing.T) { t.Parallel() members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}} result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) if result.Members["Alice"].Months["2026-01"].Expected != 750 { t.Errorf("Expected 750 when no exception, got %d", result.Members["Alice"].Months["2026-01"].Expected) } } func TestReconcileGreedyExactMatch(t *testing.T) { t.Parallel() members := []Member{{ Name: "Alice", Tier: "A", Fees: map[string]FeeData{ "2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 150, Attendance: 2}, }, }} sortedMonths := []string{"2026-02", "2026-03", "2026-04"} result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear) months := result.Members["Alice"].Months if int(months["2026-02"].Paid) != 750 { t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid) } if int(months["2026-03"].Paid) != 350 { t.Errorf("2026-03 paid want 350, got %f", months["2026-03"].Paid) } if int(months["2026-04"].Paid) != 150 { t.Errorf("2026-04 paid want 150, got %f", months["2026-04"].Paid) } } func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) { t.Parallel() members := []Member{{ Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}}, }} sortedMonths := []string{"2026-01", "2026-02"} result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 2000)}, nil, defaultYear) months := result.Members["Alice"].Months if int(months["2026-01"].Paid) != 750 { t.Errorf("2026-01 paid want 750, got %f", months["2026-01"].Paid) } if int(months["2026-02"].Paid) != 750 { t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid) } if result.Credits["Alice"] != 500 { t.Errorf("credits want 500, got %d", result.Credits["Alice"]) } } func TestReconcileProportionalUnderpayment(t *testing.T) { t.Parallel() members := []Member{{ Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}}, }} sortedMonths := []string{"2026-02", "2026-03", "2026-04"} amount := 1250.0 result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear) months := result.Members["Alice"].Months paid02 := months["2026-02"].Paid paid03 := months["2026-03"].Paid paid04 := months["2026-04"].Paid if paid02 >= 750 { t.Errorf("2026-02 should be underpaid, got %f", paid02) } if paid03 >= 350 { t.Errorf("2026-03 should be underpaid, got %f", paid03) } if paid04 >= 750 { t.Errorf("2026-04 should be underpaid, got %f", paid04) } if math.Abs(paid02+paid03+paid04-amount) > 0.01 { t.Errorf("sum of paid want %f, got %f", amount, paid02+paid03+paid04) } if math.Abs(paid02-paid04) > 0.01 { t.Errorf("02 and 04 have equal expected, want equal paid: %f vs %f", paid02, paid04) } } func TestReconcileSingleMonthUnchanged(t *testing.T) { t.Parallel() members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear) if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-750) > 0.01 { t.Errorf("single month want 750, got %f", result.Members["Alice"].Months["2026-01"].Paid) } } func TestReconcileTwoMembersMultiMonth(t *testing.T) { t.Parallel() members := []Member{ {Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}}, {Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}}, } sortedMonths := []string{"2026-01", "2026-02"} result := Reconcile(members, sortedMonths, []Transaction{tx("Alice, Bob", "2026-01, 2026-02", 2200)}, nil, defaultYear) for _, name := range []string{"Alice", "Bob"} { months := result.Members[name].Months if math.Abs(months["2026-01"].Paid-750) > 0.01 { t.Errorf("%s 2026-01 paid want 750, got %f", name, months["2026-01"].Paid) } if math.Abs(months["2026-02"].Paid-350) > 0.01 { t.Errorf("%s 2026-02 paid want 350, got %f", name, months["2026-02"].Paid) } } } func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) { t.Parallel() members := []Member{{ Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}}, }} sortedMonths := []string{"2026-01", "2026-02"} result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 300)}, nil, defaultYear) months := result.Members["Alice"].Months if math.Abs(months["2026-01"].Paid-150) > 0.01 { t.Errorf("2026-01 paid want 150, got %f", months["2026-01"].Paid) } if math.Abs(months["2026-02"].Paid-150) > 0.01 { t.Errorf("2026-02 paid want 150, got %f", months["2026-02"].Paid) } } func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) { t.Parallel() members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}} txFn := func(person string) Transaction { return Transaction{ Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04", Sender: "Maco Family", Message: "fee", } } cases := []struct { name string person string }{ {"without diacritics", "Maria Maco"}, {"extra whitespace", "Mária Maco"}, {"lowercase", "mária maco"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() result := Reconcile(members, []string{"2026-04"}, []Transaction{txFn(tc.person)}, nil, defaultYear) paid := result.Members["Mária Maco"].Months["2026-04"].Paid if paid != 750 { t.Errorf("%s: paid want 750, got %f", tc.name, paid) } if len(result.Unmatched) != 0 { t.Errorf("%s: want no unmatched, got %v", tc.name, result.Unmatched) } }) } } func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) { t.Parallel() members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}} txs := []Transaction{{ Date: "2026-04-15", Amount: 750, Person: "Někdo Neznámý", Purpose: "2026-04", Sender: "Neznámý", Message: "fee", }} result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear) if result.Members["Mária Maco"].Months["2026-04"].Paid != 0 { t.Errorf("unknown person must not credit the member") } if len(result.Unmatched) != 1 { t.Errorf("want 1 unmatched, got %d", len(result.Unmatched)) } } // [Go] Test that [?] markers are stripped from the Person field before lookup. func TestReconcileQuestionMarkMarkerStripped(t *testing.T) { t.Parallel() members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 750, Person: "[?] Alice", Purpose: "2026-01", Sender: "Bank", Message: "fee", }} result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) if result.Members["Alice"].Months["2026-01"].Paid != 750 { t.Errorf("[?] stripping: want 750 paid, got %f", result.Members["Alice"].Months["2026-01"].Paid) } } // [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger. func TestReconcileOtherPurpose(t *testing.T) { t.Parallel() members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 300, Person: "Alice", Purpose: "other:shirt", Sender: "Bank", Message: "shirt order", }} result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) if result.Members["Alice"].Months["2026-01"].Paid != 0 { t.Errorf("other: purpose must not touch month ledger") } others := result.Members["Alice"].OtherTransactions if len(others) != 1 { t.Fatalf("want 1 OtherTransaction, got %d", len(others)) } if math.Abs(others[0].Amount-300) > 0.01 { t.Errorf("OtherEntry.Amount want 300, got %f", others[0].Amount) } if others[0].Purpose != "other:shirt" { t.Errorf("OtherEntry.Purpose want %q, got %q", "other:shirt", others[0].Purpose) } } // [Go] Months outside sortedMonths go to credit, not to the window ledger. func TestReconcileOutOfWindowGoesToCredit(t *testing.T) { t.Parallel() // Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out). members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 1200, Person: "Alice", Purpose: "2026-01, 2026-02", Sender: "Bank", Message: "Q1", }} result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) // member_share = 1200 (one member) // out_credit = 1200 / 2 * 1 = 600 // in_window_share = 600 // in_window = [(2026-01, 600)], total_expected = 600 → greedy: paid = 600, no overflow if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-600) > 0.01 { t.Errorf("in-window paid want 600, got %f", result.Members["Alice"].Months["2026-01"].Paid) } // total_balance = int(600) - 600 (window) + 600 (out credit) = 600 if result.Members["Alice"].TotalBalance != 600 { t.Errorf("TotalBalance want 600, got %d", result.Members["Alice"].TotalBalance) } } // [Go] No person/purpose → inference fallback resolves sender name and date month. func TestReconcileInferenceFallback(t *testing.T) { t.Parallel() members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-04-15", Amount: 750, // Person and Purpose are empty → inference path Sender: "Tomas Nemecek", Message: "clenske 04/2026", }} result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear) if math.Abs(result.Members["Tomáš Němeček"].Months["2026-04"].Paid-750) > 0.01 { t.Errorf("inference fallback: want 750 paid, got %f", result.Members["Tomáš Němeček"].Months["2026-04"].Paid) } } // [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged. func TestReconcileNoMatchGoesToUnmatched(t *testing.T) { t.Parallel() members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 500, // empty person+purpose and sender name not matching any member Sender: "Unknown Corp", Message: "invoice", }} result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) if len(result.Unmatched) != 1 { t.Errorf("want 1 unmatched, got %d", len(result.Unmatched)) } if result.Members["Alice"].Months["2026-01"].Paid != 0 { t.Errorf("unmatched tx must not touch ledger") } } // [Go] Empty transaction list leaves every month at paid=0 and balance=–expected. func TestReconcileNoTransactionsAllUnpaid(t *testing.T) { t.Parallel() members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) if result.Members["Alice"].Months["2026-01"].Paid != 0 { t.Errorf("no txs: want paid=0, got %f", result.Members["Alice"].Months["2026-01"].Paid) } if result.Members["Alice"].TotalBalance != -750 { t.Errorf("no txs: want balance -750, got %d", result.Members["Alice"].TotalBalance) } if len(result.Unmatched) != 0 { t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched) } }