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:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations
|
||||
|
||||
- Multi-month payment allocation now fills the earliest in-window deficit first and spills
|
||||
any remainder to later months, accounting for prior transactions' contributions to each month.
|
||||
Previously a single transaction was split proportionally to each month's total expected fee,
|
||||
ignoring what earlier transactions had already paid — surfaced by Matyáš Thér's 200+550 case
|
||||
showing 566/183 instead of 500/250.
|
||||
- Files: `scripts/match_payments.py`, `go/internal/domain/reconcile/reconcile.go`, tests, parity fixtures.
|
||||
|
||||
## 2026-05-11 22:56 CEST — fix(python): parse Fio 2-digit-year dates + add `make sync-debug` dry-run tool
|
||||
|
||||
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
|
||||
|
||||
184
docs/plans/2026-05-11-2353-fill-first-multi-month-allocation.md
Normal file
184
docs/plans/2026-05-11-2353-fill-first-multi-month-allocation.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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](scripts/match_payments.py)) and Go ([go/internal/domain/reconcile/reconcile.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`
|
||||
- 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](scripts/infer_payments.py#L151)) 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](scripts/match_payments.py#L471-L498)
|
||||
|
||||
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.
|
||||
|
||||
```python
|
||||
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](go/internal/domain/reconcile/reconcile.go#L320-L357)
|
||||
|
||||
Same shape — replace both `totalExpected > 0` branches. Even-split branch (lines 358–372) stays.
|
||||
|
||||
```go
|
||||
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](tests/test_reconcile_exceptions.py)
|
||||
|
||||
1. **Rewrite `test_proportional_underpayment`** ([line 96](tests/test_reconcile_exceptions.py#L96)) — 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 across `02: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`.
|
||||
|
||||
2. **Add `test_fill_first_across_two_transactions`** — the Matyáš regression:
|
||||
|
||||
```python
|
||||
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)
|
||||
```
|
||||
|
||||
3. `test_greedy_exact_match`, `test_greedy_overpayment_goes_to_credit`, `test_single_month_unchanged`, `test_two_members_multi_month` should pass unchanged — fill-first agrees with greedy when payment ≥ total expected.
|
||||
|
||||
#### Go — [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go)
|
||||
|
||||
- Add `TestUnderpaymentFillsEarliestFirst` mirroring the rewritten Python test.
|
||||
- Add `TestFillFirstAcrossTwoTransactions` mirroring the Matyáš scenario.
|
||||
|
||||
#### Parity — [go/tests/parity/reconcile/reconcile_parity_test.go](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](CHANGELOG.md)
|
||||
|
||||
Append top entry (use `date "+%Y-%m-%d %H:%M %Z"` at commit time):
|
||||
|
||||
```markdown
|
||||
## 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](scripts/match_payments.py) — Python reconcile (canonical)
|
||||
- [go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go) — Go reconcile (mirrors Python)
|
||||
- [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py) — rewrite `test_proportional_underpayment` + add Matyáš test
|
||||
- [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go) — add Go tests
|
||||
- [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go) — add parity fixture
|
||||
- [CHANGELOG.md](CHANGELOG.md) — top entry
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make test` — Python unit tests including the rewritten + new fill-first tests.
|
||||
2. `cd go && go test ./internal/domain/reconcile/... ./tests/parity/reconcile/...` — Go unit + parity tests.
|
||||
3. `make web` → load `/adults` or `/juniors` (whichever lists Matyáš Thér) → his 2026-02 row should be fully paid (no shortfall), 2026-03 fully paid, no leftover credit.
|
||||
4. Spot-check one other multi-month-purpose member to make sure fully-covered cases still look right.
|
||||
|
||||
## Branch & MR (per CLAUDE.md)
|
||||
|
||||
1. `git checkout -b fix/fill-first-multi-month-allocation`
|
||||
2. Apply edits + tests + CHANGELOG entry.
|
||||
3. `make test && (cd go && go test ./...)` — both green.
|
||||
4. Commit: `fix(reconcile): fill earliest month deficit first in multi-month allocations` with `Co-Authored-By` trailer.
|
||||
5. `git push -u origin fix/fill-first-multi-month-allocation`
|
||||
6. `tea pr create --title "fix(reconcile): fill earliest month deficit first" --description "<body>" --base main --head fix/fill-first-multi-month-allocation`
|
||||
7. Print MR URL. Do not merge from CLI.
|
||||
@@ -115,10 +115,11 @@ type monthExpected struct {
|
||||
expected int
|
||||
}
|
||||
|
||||
// Reconcile matches transactions to members and months using three allocation phases:
|
||||
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
|
||||
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
|
||||
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||
// Reconcile matches transactions to members and months using two allocation phases:
|
||||
// 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
|
||||
// deficit) to each month where deficit = expected − already-paid. Surplus → credit.
|
||||
// Handles both the "greedy" (payment covers all) and "partial" cases in one pass.
|
||||
// 2. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||
//
|
||||
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
||||
@@ -317,34 +318,26 @@ func Reconcile(
|
||||
totalExpected += mw.expected
|
||||
}
|
||||
|
||||
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
|
||||
// Greedy: payment covers all expected fees; overflow → credit
|
||||
credits[memberName] += int(inWindowShare - float64(totalExpected))
|
||||
for _, mw := range inWindow {
|
||||
alloc := float64(mw.expected)
|
||||
md := ledger[memberName][mw.month]
|
||||
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
|
||||
}
|
||||
} else if totalExpected > 0 {
|
||||
// Proportional: distribute by each month's share; last month absorbs float remainder
|
||||
if totalExpected > 0 {
|
||||
// Fill-first: iterate inWindow in matched-months order (chronological by
|
||||
// convention), allocating min(remaining, deficit) to each month. Deficit
|
||||
// is net of what prior transactions already paid, so a second payment on
|
||||
// the same months correctly fills only what remains due. Any surplus after
|
||||
// all deficits are covered goes to the credit bucket.
|
||||
remaining := inWindowShare
|
||||
for i, mw := range inWindow {
|
||||
var alloc float64
|
||||
if i == len(inWindow)-1 {
|
||||
alloc = remaining
|
||||
} else {
|
||||
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
|
||||
}
|
||||
remaining -= alloc
|
||||
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,
|
||||
@@ -354,6 +347,10 @@ func Reconcile(
|
||||
Confidence: string(m.Confidence),
|
||||
})
|
||||
ledger[memberName][mw.month] = md
|
||||
remaining -= alloc
|
||||
}
|
||||
if remaining > 0 {
|
||||
credits[memberName] += int(remaining)
|
||||
}
|
||||
} else {
|
||||
// Even-split fallback: prepayment before attendance recorded
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"case": "03_proportional_remainder",
|
||||
"func": "scripts.match_payments.reconcile",
|
||||
"captured_at": "2026-05-06",
|
||||
"captured_at": "2026-05-11",
|
||||
"input": {
|
||||
"members": [
|
||||
{
|
||||
@@ -54,10 +54,10 @@
|
||||
"original_expected": 750,
|
||||
"attendance_count": 3,
|
||||
"exception": null,
|
||||
"paid": 324.3243243243243,
|
||||
"paid": 750.0,
|
||||
"transactions": [
|
||||
{
|
||||
"amount": 324.3243243243243,
|
||||
"amount": 750.0,
|
||||
"date": "2026-03-10",
|
||||
"sender": "Member_d035d9f9",
|
||||
"message": "",
|
||||
@@ -70,10 +70,10 @@
|
||||
"original_expected": 750,
|
||||
"attendance_count": 2,
|
||||
"exception": null,
|
||||
"paid": 324.3243243243243,
|
||||
"paid": 50.0,
|
||||
"transactions": [
|
||||
{
|
||||
"amount": 324.3243243243243,
|
||||
"amount": 50.0,
|
||||
"date": "2026-03-10",
|
||||
"sender": "Member_d035d9f9",
|
||||
"message": "",
|
||||
@@ -86,25 +86,17 @@
|
||||
"original_expected": 350,
|
||||
"attendance_count": 2,
|
||||
"exception": null,
|
||||
"paid": 151.35135135135135,
|
||||
"transactions": [
|
||||
{
|
||||
"amount": 151.35135135135135,
|
||||
"date": "2026-03-10",
|
||||
"sender": "Member_d035d9f9",
|
||||
"message": "",
|
||||
"confidence": "auto"
|
||||
}
|
||||
]
|
||||
"paid": 0,
|
||||
"transactions": []
|
||||
}
|
||||
},
|
||||
"other_transactions": [],
|
||||
"total_balance": -1051
|
||||
"total_balance": -1050
|
||||
}
|
||||
},
|
||||
"unmatched": [],
|
||||
"credits": {
|
||||
"Member_d035d9f9": -1051
|
||||
"Member_d035d9f9": -1050
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"case": "09_multiperson_multimonth",
|
||||
"func": "scripts.match_payments.reconcile",
|
||||
"captured_at": "2026-05-06",
|
||||
"captured_at": "2026-05-11",
|
||||
"input": {
|
||||
"members": [
|
||||
{
|
||||
@@ -63,10 +63,10 @@
|
||||
"original_expected": 750,
|
||||
"attendance_count": 3,
|
||||
"exception": null,
|
||||
"paid": 500.0,
|
||||
"paid": 750.0,
|
||||
"transactions": [
|
||||
{
|
||||
"amount": 500.0,
|
||||
"amount": 750.0,
|
||||
"date": "2026-02-15",
|
||||
"sender": "Member_d035d9f9",
|
||||
"message": "",
|
||||
@@ -79,10 +79,10 @@
|
||||
"original_expected": 750,
|
||||
"attendance_count": 2,
|
||||
"exception": null,
|
||||
"paid": 500.0,
|
||||
"paid": 250.0,
|
||||
"transactions": [
|
||||
{
|
||||
"amount": 500.0,
|
||||
"amount": 250.0,
|
||||
"date": "2026-02-15",
|
||||
"sender": "Member_d035d9f9",
|
||||
"message": "",
|
||||
@@ -102,10 +102,10 @@
|
||||
"original_expected": 750,
|
||||
"attendance_count": 2,
|
||||
"exception": null,
|
||||
"paid": 681.8181818181819,
|
||||
"paid": 750.0,
|
||||
"transactions": [
|
||||
{
|
||||
"amount": 681.8181818181819,
|
||||
"amount": 750.0,
|
||||
"date": "2026-02-15",
|
||||
"sender": "Member_d035d9f9",
|
||||
"message": "",
|
||||
@@ -118,10 +118,10 @@
|
||||
"original_expected": 350,
|
||||
"attendance_count": 2,
|
||||
"exception": null,
|
||||
"paid": 318.18181818181813,
|
||||
"paid": 250.0,
|
||||
"transactions": [
|
||||
{
|
||||
"amount": 318.18181818181813,
|
||||
"amount": 250.0,
|
||||
"date": "2026-02-15",
|
||||
"sender": "Member_d035d9f9",
|
||||
"message": "",
|
||||
@@ -131,13 +131,13 @@
|
||||
}
|
||||
},
|
||||
"other_transactions": [],
|
||||
"total_balance": -101
|
||||
"total_balance": -100
|
||||
}
|
||||
},
|
||||
"unmatched": [],
|
||||
"credits": {
|
||||
"Member_d035d9f9": -500,
|
||||
"Member_f4a93e46": -101
|
||||
"Member_f4a93e46": -100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,26 +468,19 @@ def reconcile(
|
||||
|
||||
total_expected = sum(e for _, e in in_window)
|
||||
|
||||
if total_expected > 0 and in_window_share >= total_expected:
|
||||
# Greedy phase: payment covers all in-window fees; overflow → credit.
|
||||
credits[member_name] = credits.get(member_name, 0) + int(in_window_share - total_expected)
|
||||
for m, exp in in_window:
|
||||
alloc = float(exp)
|
||||
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,
|
||||
})
|
||||
elif total_expected > 0:
|
||||
# Proportional phase: distribute in_window_share by each month's expected fee.
|
||||
# Last month absorbs any float remainder so the sum equals in_window_share exactly.
|
||||
if total_expected > 0:
|
||||
# Fill-first: iterate in_window in matched_months order (chronological by
|
||||
# convention from infer_payments.py), allocating min(remaining, deficit) to
|
||||
# each month. Deficit is net of what prior transactions already paid, so a
|
||||
# second payment on the same months correctly fills only what remains due.
|
||||
# Any surplus after all deficits are covered goes to the credit bucket.
|
||||
remaining = in_window_share
|
||||
for i, (m, exp) in enumerate(in_window):
|
||||
alloc = remaining if i == len(in_window) - 1 else in_window_share * exp / total_expected
|
||||
remaining -= alloc
|
||||
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,
|
||||
@@ -496,6 +489,9 @@ def reconcile(
|
||||
"message": tx["message"],
|
||||
"confidence": confidence,
|
||||
})
|
||||
remaining -= alloc
|
||||
if remaining > 0:
|
||||
credits[member_name] = credits.get(member_name, 0) + int(remaining)
|
||||
else:
|
||||
# Fallback: no expected fees (prepayment before attendance recorded); even split.
|
||||
per_month = in_window_share / len(in_window)
|
||||
|
||||
@@ -93,8 +93,8 @@ class TestMultiMonthAllocation(unittest.TestCase):
|
||||
self.assertEqual(int(months['2026-02']['paid']), 750)
|
||||
self.assertEqual(result['credits'].get('Alice', 0), 500)
|
||||
|
||||
def test_proportional_underpayment(self):
|
||||
"""Payment < total expected → proportional split; sum of paid == payment amount."""
|
||||
def test_underpayment_fills_earliest_first(self):
|
||||
"""Payment < total expected → fill earliest months first, spill remainder to later."""
|
||||
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
|
||||
sorted_months = ['2026-02', '2026-03', '2026-04']
|
||||
amount = 1250
|
||||
@@ -103,18 +103,28 @@ class TestMultiMonthAllocation(unittest.TestCase):
|
||||
result = reconcile(members, sorted_months, [tx])
|
||||
months = result['members']['Alice']['months']
|
||||
|
||||
paid_02 = months['2026-02']['paid']
|
||||
paid_03 = months['2026-03']['paid']
|
||||
paid_04 = months['2026-04']['paid']
|
||||
# 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||
self.assertAlmostEqual(months['2026-02']['paid'], 750, places=2)
|
||||
self.assertAlmostEqual(months['2026-03']['paid'], 350, places=2)
|
||||
self.assertAlmostEqual(months['2026-04']['paid'], 150, places=2)
|
||||
# No CZK lost
|
||||
self.assertAlmostEqual(
|
||||
months['2026-02']['paid'] + months['2026-03']['paid'] + months['2026-04']['paid'],
|
||||
amount, places=2,
|
||||
)
|
||||
|
||||
# All months should be partial (underpaid)
|
||||
self.assertLess(paid_02, 750)
|
||||
self.assertLess(paid_03, 350)
|
||||
self.assertLess(paid_04, 750)
|
||||
# Sum must equal the original payment (no CZK lost)
|
||||
self.assertAlmostEqual(paid_02 + paid_03 + paid_04, amount, places=2)
|
||||
# 02 and 04 have equal expected → equal allocation
|
||||
self.assertAlmostEqual(paid_02, paid_04, places=2)
|
||||
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)
|
||||
|
||||
def test_single_month_unchanged(self):
|
||||
"""Single-month payment: full amount goes to that month (regression guard)."""
|
||||
|
||||
Reference in New Issue
Block a user