fix(reconcile): handle '?' expected fee in fill-first allocation
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>
This commit is contained in:
90
docs/plans/2026-06-08-1110-junior-expected-fix.md
Normal file
90
docs/plans/2026-06-08-1110-junior-expected-fix.md
Normal file
@@ -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/<ts>-junior-expected-fix.md` per
|
||||
the repo plan convention.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user