Files
fuj-management/docs/plans/2026-05-11-2353-fill-first-multi-month-allocation.md
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

10 KiB
Raw Blame History

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
  • 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 471498) with a single unified loop. Keep lines 466469 above and the even-split fallback below (lines 499510) 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 499510) 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 358372) 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 358372) unchanged …
            }

Tests

Python — tests/test_reconcile_exceptions.py

  1. 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 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:

    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

  • Add TestUnderpaymentFillsEarliestFirst mirroring the rewritten Python test.
  • Add TestFillFirstAcrossTwoTransactions mirroring 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

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.