Juniors with exactly 1 session get expected='?' (manual-review marker from attendance.py). The fill-first allocation block summed and cast expected values numerically without guarding against this, causing a TypeError: unsupported operand type(s) for +: 'int' and 'str' on the /juniors route whenever any matched payment landed on such a month. Add _expected_amount() helper that coerces non-numeric markers to 0 (same convention the final-balance calculation at line 512 already used) and apply it in the two failing spots plus the existing isinstance check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.9 KiB
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. That value flows unchanged into
the ledger as ledger[name][m]["expected"]
(match_payments.py:370).
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) and later does
float(exp) (match_payments.py:480) — 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 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)
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:
total_expected = sum(_expected_amount(e) for _, e in in_window) - match_payments.py:480:
deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)
With expected coerced to 0 for "?" months:
- A
"?"-only payment falls into the existingtotal_expected == 0fallback (match_payments.py:495-499) 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 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
make weband openhttp://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./adultsstill renders unchanged (regression check).make test— existing reconcile tests still pass; if there is a reconcile test fixture, add a case where a junior month hasexpected == "?"plus a matched transaction and assert no exception + payment counted as surplus.
Notes
- Single-file change: 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/<ts>-junior-expected-fix.mdper the repo plan convention.