All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
- Add web/api/handler.go: Handler struct wiring Sources+Config into ServeAdults, ServeJuniors, ServePayments, ServeVersion - Add web/api/build_common.go: getMonthLabels, groupRawPaymentsByPerson, settledBalance, domain-to-wire converters, ensureSlice generic helper - Add web/api/build_adults.go: buildAdultsResponse + buildAdultMemberRow mirroring scripts/views.py:build_adults_view_model - Add web/api/build_juniors.go: buildJuniorsResponse + buildJuniorMemberRow mirroring scripts/views.py:build_juniors_view_model, including "?" sentinel and :NJ,MA breakdown - Add web/api/build_payments.go: buildPaymentsResponse with Unmatched/Unknown bucket - Extend reconcile.FeeData/MonthData with IsUnknown, JuniorAttendance, AdultAttendance - Extend reconcile.Transaction with ManualFix, VS, BankID, SyncID for raw_payments wire field - Export membership.AdultMergedMonths and JuniorMergedMonths - Update sources.go to propagate new FeeData fields and parse extra transaction columns - Wire sources+cfg into web.Run; register /api/* routes via Go 1.22 method+path patterns - Fix pre-existing gofumpt formatting in fio_test.go and fio_table.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
377 lines
13 KiB
Go
377 lines
13 KiB
Go
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)
|
||
}
|
||
}
|