fix(reconcile): fill earliest month deficit first in multi-month allocations
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
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>
This commit is contained in:
@@ -111,36 +111,26 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileProportionalUnderpayment(t *testing.T) {
|
||||
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"}
|
||||
amount := 1250.0
|
||||
|
||||
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear)
|
||||
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, 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)
|
||||
// 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 paid03 >= 350 {
|
||||
t.Errorf("2026-03 should be underpaid, got %f", paid03)
|
||||
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
|
||||
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
|
||||
}
|
||||
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)
|
||||
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
|
||||
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,3 +364,52 @@ func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user