Files
fuj-management/go/internal/domain/reconcile/reconcile_test.go
Jan Novak 8734089223
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
fix(reconcile): fill earliest month deficit first in multi-month allocations
Replace proportional split with a fill-first loop that allocates
min(remaining, deficit) to each matched month in user-supplied order,
where deficit = expected - already_paid. Prior transactions' contributions
are now properly accounted for, so a second payment on overlapping months
fills only what's still owed instead of splitting proportionally by total
expected. Surplus after all deficits are covered goes to the credit bucket.

Fixes: Matyáš Thér 200+550 showing 566/183 instead of 500/250.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 23:59:36 +02:00

416 lines
15 KiB
Go
Raw Permalink 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": {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 TestReconcileUnderpaymentFillsEarliestFirst(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"}
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear)
months := result.Members["Alice"].Months
// 02 filled first (750), then 03 (350), then remainder 150 to 04
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
}
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
}
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
}
}
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)
}
}
// Payment < total expected → fill earliest months first, spill remainder to later.
func TestUnderpaymentFillsEarliestFirst(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},
}}}
txs := []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}
result := Reconcile(members, []string{"2026-02", "2026-03", "2026-04"}, txs, nil, defaultYear)
months := result.Members["Alice"].Months
// 02 filled first (750), then 03 (350), then remainder 150 to 04
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
}
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
}
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
}
}
// Prior txn fills 02 partially; later txn finishes 02 then spills to 03.
func TestFillFirstAcrossTwoTransactions(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Matyáš", Tier: "A", Fees: map[string]FeeData{
"2026-02": {Expected: 500, Attendance: 2},
"2026-03": {Expected: 250, Attendance: 1},
}}}
sortedMonths := []string{"2026-02", "2026-03"}
txs := []Transaction{
tx("Matyáš", "2026-02", 200),
tx("Matyáš", "2026-02, 2026-03", 550),
}
result := Reconcile(members, sortedMonths, txs, nil, defaultYear)
months := result.Members["Matyáš"].Months
if math.Abs(months["2026-02"].Paid-500) > 0.01 {
t.Errorf("02: want 500, got %f", months["2026-02"].Paid)
}
if math.Abs(months["2026-03"].Paid-250) > 0.01 {
t.Errorf("03: want 250, got %f", months["2026-03"].Paid)
}
}