# Include junior members in payment inference roster ## Context A bank payment from sender `JIŘÍ KUBÍK` with the message `Jáchym Kubík: 01/2026+03/2026+04/2026` is being inferred as `[?] Jáchym Hrušák (G)` instead of the obvious `Jáchym Kubík`, even though the message contains his exact full name. **Root cause** (confirmed with the user): `Jáchym Kubík` is in the **junior** attendance sheet only — he does not appear on the main/adults sheet. But [scripts/infer_payments.py:101-102](scripts/infer_payments.py#L101-L102) builds `member_names` by calling `get_members_with_fees()` ([scripts/attendance.py:170](scripts/attendance.py#L170)), which reads only `EXPORT_URL` (the adults sheet). Junior-only members are therefore invisible to the matcher. With Kubík absent from `member_names`, the matcher in [scripts/match_payments.py:65](scripts/match_payments.py#L65) processes the combined text `jiri kubik jachym kubik: 01/2026+03/2026+04/2026` against an adults-only roster: - The exact-full-name short-circuit (`match_payments.py:75-84`) finds nothing — no adult's full name is in the text. - Hrušák `(G)` is the only adult with first name `Jáchym`. He fails the auto-rules (his surname isn't in the text) but hits the partial-first-name review rule (`match_payments.py:123-125`) → returned as `("Jáchym Hrušák (G)", "review")`, rendered as `[?] Jáchym Hrušák (G)`. The user's original framing — "exact match in message should win over everything" — is already implemented for any candidate that **is** in the roster (the May-04 short-circuit). The bug is upstream: the right candidate was never even considered. **Goal:** make `infer_payments` consider junior members as candidates, so junior-only names like `Jáchym Kubík` get matched correctly. ## Approach Single-file change in [scripts/infer_payments.py](scripts/infer_payments.py). Replace the adults-only roster lookup with a union of the adult and junior rosters. `attendance.py` already exposes both: [`get_members_with_fees()`](scripts/attendance.py#L170) for adults (and tier-J juniors who train with adults) and [`get_junior_members_with_fees()`](scripts/attendance.py#L208) for everyone in the junior sheet. ### Edit at [scripts/infer_payments.py:15](scripts/infer_payments.py#L15) ```python from attendance import get_members_with_fees, get_junior_members_with_fees ``` ### Edit at [scripts/infer_payments.py:99-102](scripts/infer_payments.py#L99-L102) ```python print("Fetching member list for matching...") adult_members, _ = get_members_with_fees() junior_members, _ = get_junior_members_with_fees() # Union rosters, preserving first-seen order, deduping by canonical key seen: set[str] = set() member_names: list[str] = [] for m in adult_members + junior_members: name = m[0] key = canonical_member_key(name) if key in seen: continue seen.add(key) member_names.append(name) ``` `canonical_member_key` already lives in [scripts/match_payments.py:20](scripts/match_payments.py#L20) — import it alongside `infer_transaction_details`. It normalizes diacritics/case/whitespace, so `"Maria Maco"` and `"Mária Maco"` collapse to the same key. ### Why downstream reconciliation still works `reconcile()` is invoked twice per page — once with the adults roster ([app.py:200](app.py#L200)) and once with the juniors roster ([app.py:384](app.py#L384)). Each call resolves the `Person` cell against its own roster; a junior name resolves cleanly in the juniors call and lands in "unmatched" in the adults call. That's already the existing behavior for any junior payment manually entered into the `Person` column, so no further changes are needed. ### Files to modify - [scripts/infer_payments.py](scripts/infer_payments.py) — only the import + roster construction. ~10-line change. ### Files to read for confidence (no edits) - [scripts/attendance.py:208-289](scripts/attendance.py#L208-L289) — `get_junior_members_with_fees` returns `(name, tier, …)` tuples just like the adults version, so `m[0]` works for both. - [scripts/match_payments.py:65-137](scripts/match_payments.py#L65-L137) — `match_members` already handles the precedence the user wants (exact full-name short-circuit), so once Kubík is in `member_names`, the case will be auto-matched with no `[?]`. ## Verification 1. **Manual sanity** — re-run inference on the offending row: - Clear `Person`/`Purpose` for the Kubík row in the payments sheet. - `make infer`. - Expect `Person = Jáchym Kubík`, `Purpose = 2026-01, 2026-03, 2026-04`, no `[?]`. 2. **Unit test** — extend [tests/test_match_members.py](tests/test_match_members.py) (or add a small `tests/test_infer_payments.py`) to assert that, given a roster that includes `Jáchym Hrušák (G)` and `Jáchym Kubík`, the message `Jáchym Kubík: 01/2026+03/2026+04/2026` resolves to `[("Jáchym Kubík", "auto")]` only. This is really a regression test for the May-04 short-circuit — the new behavior under test is just that `infer_payments` now feeds in juniors. 3. **Run the suite**: `make test`. 4. **Dashboard smoke** — `make web`, open `/payments`, confirm the row now shows the correct member; open `/juniors`, confirm the payment is credited to Kubík for the three months listed. 5. **Changelog** — once the user confirms the fix, append an entry to [CHANGELOG.md](CHANGELOG.md) per [CLAUDE.md](CLAUDE.md): `## YYYY-MM-DD HH:MM TZ — fix: include juniors in payment-inference roster`.