fix: include juniors in payment-inference roster
infer_payments was building member_names from get_members_with_fees() (adults sheet only). Junior-only members were invisible to the matcher, so a payment message containing an exact junior name would produce a fuzzy review match against a different adult sharing the same first name. Fix: union the adult and junior rosters (deduped via canonical_member_key) so all members are candidates. The existing exact-name short-circuit in match_members then handles precedence correctly. Two regression tests added for the Jáchym Kubík / Jáchym Hrušák case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
129
docs/plans/2026-05-06-1626-infer-payments-junior-roster.md
Normal file
129
docs/plans/2026-05-06-1626-infer-payments-junior-roster.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user