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>
10 KiB
Fill-first multi-month payment allocation
Context
Matyáš Thér paid in two transactions:
| # | Amount | Purpose |
|---|---|---|
| 1 | 200 | 2026-02 |
| 2 | 550 | 2026-02, 2026-03 |
Total 750 = his expected fee for the two months (likely 2026-02 = 500, 2026-03 = 250 — junior tier or exception-adjusted). The app currently shows 2026-02 = 566 paid, 2026-03 = 183 paid. The user wants:
First use the second payment for the rest of 2026-02 (no more, no less), then put the remainder toward 2026-03.
Both Python (scripts/match_payments.py) and Go (go/internal/domain/reconcile/reconcile.go) have the same bug: when a single transaction's in_window_share is less than the sum of in-window expected fees, both fall into a proportional branch that splits the new transaction across months in proportion to each month's total expected fee — never consulting what prior transactions already paid into earlier months.
Trace for txn 2:
in_window = [(2026-02, 500), (2026-03, 250)],total_expected = 750,in_window_share = 550.550 < 750→ proportional:- 02 alloc =
550 × 500 / 750 = 366.67 - 03 alloc =
550 − 366.67 = 183.33
- 02 alloc =
- Combined with txn 1's 200 → 02 = 566.67, 03 = 183.33 → display
566 / 183. ✓ matches reported numbers.
The fix: replace the greedy + proportional branches with a single fill-first loop that iterates in_window in user-supplied order (already chronological by convention from scripts/infer_payments.py:151) and allocates min(remaining, max(0, expected − paid_so_far)) to each month, with any final surplus going to the credit bucket. This collapses three cases (greedy / proportional / pure overflow) into one and naturally consults the ledger's paid field which is already updated by prior transactions in the same reconcile pass.
The even-split branch (total_expected == 0, prepayment before fees known) stays untouched — different semantic, folding it in would silently change behavior.
Changes
Python — scripts/match_payments.py:471-498
Replace both total_expected > 0 branches (current lines 471–498) with a single unified loop. Keep lines 466–469 above and the even-split fallback below (lines 499–510) as-is.
if total_expected > 0:
# Fill-first: iterate in_window in matched_months order (chronological by
# convention), allocate min(remaining, deficit) to each month, where
# deficit accounts for prior transactions already credited to that month.
# Any surplus after all in-window deficits are covered → credit bucket.
remaining = in_window_share
for m, exp in in_window:
paid_so_far = ledger[member_name][m]["paid"]
deficit = max(0.0, float(exp) - paid_so_far)
alloc = min(remaining, deficit)
if alloc <= 0:
continue
ledger[member_name][m]["paid"] += alloc
ledger[member_name][m]["transactions"].append({
"amount": alloc,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
})
remaining -= alloc
if remaining > 0:
credits[member_name] = credits.get(member_name, 0) + int(remaining)
else:
# … existing even-split branch (lines 499–510) unchanged …
Note: skipping the transactions.append when alloc <= 0 (e.g. month already fully paid by a prior txn) avoids zero-amount ghost rows in the per-month transaction list. This is a small UI-visible side effect; before committing, grep tests for assertions on len(transactions) per month to confirm nothing relies on the current "one row per (txn, month) regardless of alloc" behavior.
Go — go/internal/domain/reconcile/reconcile.go:320-357
Same shape — replace both totalExpected > 0 branches. Even-split branch (lines 358–372) stays.
if totalExpected > 0 {
// Fill-first; see Python reconcile() for rationale.
remaining := inWindowShare
for _, mw := range inWindow {
md := ledger[memberName][mw.month]
deficit := float64(mw.expected) - md.Paid
if deficit < 0 {
deficit = 0
}
alloc := remaining
if deficit < alloc {
alloc = deficit
}
if alloc <= 0 {
continue
}
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
remaining -= alloc
}
if remaining > 0 {
credits[memberName] += int(remaining)
}
} else {
// … existing even-split branch (lines 358–372) unchanged …
}
Tests
Python — tests/test_reconcile_exceptions.py
-
Rewrite
test_proportional_underpayment(line 96) — its current assertions (paid_02 < 750,paid_03 < 350,paid_04 < 750, and 02/04 equal allocation) are incompatible with fill-first. Under fill-first with the same fixture (1250 across02:750, 03:350, 04:750):- 02:
min(1250, 750) = 750(full) → remaining 500 - 03:
min(500, 350) = 350(full) → remaining 150 - 04:
min(150, 750) = 150(partial) → remaining 0
Replace assertions with these exact expected values, rename to
test_underpayment_fills_earliest_first. - 02:
-
Add
test_fill_first_across_two_transactions— the Matyáš regression:def test_fill_first_across_two_transactions(self): """Prior txn fills 02 partially; later txn finishes 02 then spills to 03.""" members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})] sorted_months = ['2026-02', '2026-03'] tx1 = _tx('Matyáš', '2026-02', 200) tx2 = _tx('Matyáš', '2026-02, 2026-03', 550) result = reconcile(members, sorted_months, [tx1, tx2]) months = result['members']['Matyáš']['months'] self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2) self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2) -
test_greedy_exact_match,test_greedy_overpayment_goes_to_credit,test_single_month_unchanged,test_two_members_multi_monthshould pass unchanged — fill-first agrees with greedy when payment ≥ total expected.
Go — go/internal/domain/reconcile/reconcile_test.go
- Add
TestUnderpaymentFillsEarliestFirstmirroring the rewritten Python test. - Add
TestFillFirstAcrossTwoTransactionsmirroring the Matyáš scenario.
Parity — go/tests/parity/reconcile/reconcile_parity_test.go
Add a fixture for the Matyáš two-transaction case. Since both implementations change together and Python remains canonical, existing parity fixtures should continue to pass; verify after edits.
Changelog — CHANGELOG.md
Append top entry (use date "+%Y-%m-%d %H:%M %Z" at commit time):
## 2026-05-11 23:55 CET — fill-first multi-month payment allocation
- Multi-month payment allocation now fills the earliest in-window deficit first
and spills the remainder to later months, accounting for prior transactions'
contributions. Previously a single transaction was split proportionally to
each month's total expected fee, ignoring earlier payments — surfaced by
Matyáš Thér's two-payment 200+550 case showing 566/183 instead of 500/250.
- scripts/match_payments.py, go/internal/domain/reconcile/reconcile.go, tests.
Critical files
- scripts/match_payments.py — Python reconcile (canonical)
- go/internal/domain/reconcile/reconcile.go — Go reconcile (mirrors Python)
- tests/test_reconcile_exceptions.py — rewrite
test_proportional_underpayment+ add Matyáš test - go/internal/domain/reconcile/reconcile_test.go — add Go tests
- go/tests/parity/reconcile/reconcile_parity_test.go — add parity fixture
- CHANGELOG.md — top entry
Verification
make test— Python unit tests including the rewritten + new fill-first tests.cd go && go test ./internal/domain/reconcile/... ./tests/parity/reconcile/...— Go unit + parity tests.make web→ load/adultsor/juniors(whichever lists Matyáš Thér) → his 2026-02 row should be fully paid (no shortfall), 2026-03 fully paid, no leftover credit.- Spot-check one other multi-month-purpose member to make sure fully-covered cases still look right.
Branch & MR (per CLAUDE.md)
git checkout -b fix/fill-first-multi-month-allocation- Apply edits + tests + CHANGELOG entry.
make test && (cd go && go test ./...)— both green.- Commit:
fix(reconcile): fill earliest month deficit first in multi-month allocationswithCo-Authored-Bytrailer. git push -u origin fix/fill-first-multi-month-allocationtea pr create --title "fix(reconcile): fill earliest month deficit first" --description "<body>" --base main --head fix/fill-first-multi-month-allocation- Print MR URL. Do not merge from CLI.