diff --git a/docs/plans/2026-06-08-1110-junior-expected-fix.md b/docs/plans/2026-06-08-1110-junior-expected-fix.md new file mode 100644 index 0000000..31e8c9e --- /dev/null +++ b/docs/plans/2026-06-08-1110-junior-expected-fix.md @@ -0,0 +1,90 @@ +# Fix `/juniors` 500: `int + str` in reconcile allocation + +## Context + +The `/juniors` route crashes with: + +``` +File ".../scripts/match_payments.py", line 469, in reconcile + total_expected = sum(e for _, e in in_window) +TypeError: unsupported operand type(s) for +: 'int' and 'str' +``` + +**Why it happens:** A junior with exactly **1 session** in a month gets an +expected fee of the string `"?"` (manual-review marker), set in +[attendance.py:107](scripts/attendance.py#L107). That value flows unchanged into +the ledger as `ledger[name][m]["expected"]` +([match_payments.py:370](scripts/match_payments.py#L370)). + +When a bank payment is matched to such a junior+month, the new **fill-first +allocation** block builds `in_window` from those `expected` values and then sums +them ([match_payments.py:469](scripts/match_payments.py#L469)) and later does +`float(exp)` ([match_payments.py:480](scripts/match_payments.py#L480)) — both +blow up on the string `"?"`. Adults never hit this because adults never get +`"?"`, which is why only `/juniors` 500s. + +Note the final-balance code at +[match_payments.py:511-512](scripts/match_payments.py#L511-L512) **already** +handles this defensively (`mdata["expected"] if isinstance(..., int) else 0`). +The allocation block added in the recent `fill-first` work simply missed the +same guard. The intended convention is clear: a `"?"` (unknown) expected counts +as **0** for arithmetic, so any payment landing on such a month becomes surplus +/ positive balance rather than filling a deficit. + +## Approach + +Add one small helper and apply the existing "non-numeric expected → 0" +convention consistently inside `reconcile()`. + +### 1. Helper (near the top of `scripts/match_payments.py`) + +```python +def _expected_amount(value): + """Numeric value of an 'expected' fee; non-numeric markers like '?' → 0.""" + return value if isinstance(value, (int, float)) else 0 +``` + +### 2. Apply it in the allocation block + +- [match_payments.py:469](scripts/match_payments.py#L469): + `total_expected = sum(_expected_amount(e) for _, e in in_window)` +- [match_payments.py:480](scripts/match_payments.py#L480): + `deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)` + +With expected coerced to 0 for `"?"` months: +- A `"?"`-only payment falls into the existing `total_expected == 0` fallback + ([match_payments.py:495-499](scripts/match_payments.py#L495-L499)) and is + recorded as paid (prepayment behaviour) → shows as positive balance. +- Mixed with real months, the `"?"` month gets deficit 0 (skipped), real months + fill first, surplus → credit. Consistent with line 512. + +### 3. Reuse the helper at line 512 (optional consistency tidy) + +Replace the inline `isinstance(mdata["expected"], int)` check at +[match_payments.py:512](scripts/match_payments.py#L512) with +`_expected_amount(mdata["paid"]/expected)` form, i.e. use `_expected_amount(...)`. +This also closes a latent gap where a **float** exception amount would be treated +as 0 (current check only accepts `int`). + +`print_report` (line 552+) iterates adults only, so it's unaffected and needs no +change. + +## Verification + +1. `make web` and open `http://localhost:5001/juniors` — page renders 200 (was + 500). Confirm a junior with a single-session `"?"` month who also has a + matched payment shows a sensible balance. +2. `/adults` still renders unchanged (regression check). +3. `make test` — existing reconcile tests still pass; if there is a + reconcile test fixture, add a case where a junior month has `expected == "?"` + plus a matched transaction and assert no exception + payment counted as + surplus. + +## Notes + +- Single-file change: [scripts/match_payments.py](scripts/match_payments.py). +- This is a bug fix; per CLAUDE.md a small fix may go straight to `main`, but + confirm with the user whether to branch (`fix/junior-expected-question-mark`). +- Add a CHANGELOG.md entry once confirmed working. +- On execution, copy this plan to `docs/plans/-junior-expected-fix.md` per + the repo plan convention. diff --git a/scripts/match_payments.py b/scripts/match_payments.py index 443f9c6..8fc445c 100644 --- a/scripts/match_payments.py +++ b/scripts/match_payments.py @@ -26,6 +26,15 @@ def canonical_member_key(name: str) -> str: return re.sub(r"\s+", " ", normalize(name)).strip() +def _expected_amount(value) -> float: + """Numeric value of an expected fee; non-numeric markers like '?' → 0. + + Juniors with exactly 1 session get expected='?' (manual-review marker). + Treat those as 0 for arithmetic so payments become surplus/credit. + """ + return value if isinstance(value, (int, float)) else 0 + + # --------------------------------------------------------------------------- # Name matching # --------------------------------------------------------------------------- @@ -466,7 +475,7 @@ def reconcile( if not in_window: continue - total_expected = sum(e for _, e in in_window) + total_expected = sum(_expected_amount(e) for _, e in in_window) if total_expected > 0: # Fill-first: iterate in_window in matched_months order (chronological by @@ -477,7 +486,7 @@ def reconcile( 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) + deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far) alloc = min(remaining, deficit) if alloc <= 0: continue @@ -509,7 +518,7 @@ def reconcile( final_balances: dict[str, int] = {} for name in member_names: window_balance = sum( - int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0) + int(mdata["paid"]) - _expected_amount(mdata["expected"]) for mdata in ledger[name].values() ) final_balances[name] = window_balance + credits.get(name, 0)