Files
fuj-management/go/internal/domain/reconcile/reconcile_test.go
Jan Novak c53bf5a1c3 feat(go/M2.10): port domain/reconcile.Reconcile
Three-phase payment allocation (greedy / proportional / even-split)
ported verbatim from scripts/match_payments.py reconcile().
Includes 12 unit tests covering all Python test cases plus Go-only
extras: [?] stripping, other: purpose, out-of-window credit, inference
fallback, and no-match/empty-transaction guards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:05:00 +02:00

377 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": {750, 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": {750, 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": {750, 3},
"2026-03": {350, 3},
"2026-04": {150, 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": {750, 3}, "2026-02": {750, 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": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 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": {750, 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": {750, 3}, "2026-02": {350, 3}}},
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 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": {0, 0}, "2026-02": {0, 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": {750, 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": {750, 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": {750, 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": {750, 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": {600, 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": {750, 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": {750, 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": {750, 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)
}
}