Compare commits

...
Author SHA1 Message Date
kacerrandClaude Opus 5.5 e53b57f976 fix(pii): remove real member names tied to payments
- Scrub three parity fixtures that still held raw bank messages
- Use fictional names in the junior regression test, its changelog
  entry and its plan file
- State the rule in CLAUDE.md: a real name as a bare test input is
  fine; a real name next to payment details, or any junior or parent
  name, is not
- Tick T4 in the cleanup task list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:22:15 +02:00
kacerr bc49a8286d Merge pull request 'feat(fees): junior rate for 2026-08 is 0' (#50) from feat/junior-fee-2026-08 into main
Build and Push / build (push) Successful in 10s
Build and Push / build-go (push) Successful in 1m54s
Reviewed-on: #50
2026-10-08 21:27:57 +02:00
kacerrandClaude Opus 5.5 21db863958 feat(fees): junior rate for 2026-08 is 0
- 2026-08 added to the junior rate table at 0 CZK, same as adults
- A single practice in a 0-rate month is 0 instead of "?", since a
  free month needs no manual review
- Mirrored in Go and legacy Python; new calculate_junior_fee parity
  fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:27:10 +02:00
14 changed files with 110 additions and 40 deletions
+14 -1
View File
@@ -1,5 +1,18 @@
# Changelog
## 2026-10-09 00:21 CEST — fix(pii): remove real member names tied to payments
- Three parity fixtures held raw bank messages with real names; the names are now `Member_<hash>` pseudonyms, as the scrubber produces.
- The junior regression test, its changelog entry and its plan file used a real junior's and parent's name with paid months; replaced with fictional names.
- `CLAUDE.md` now states the rule: a real name as a bare test input is fine, a real name next to payment details or any junior/parent name is not.
- Git history still contains the old text.
## 2026-10-08 21:27 CEST — feat(fees): junior rate for 2026-08 is 0
- Junior monthly rate for 2026-08: 500 → 0 CZK, same as adults.
- A single practice in a 0-rate month is now 0 instead of `?`, since there is nothing to review in a free month. Other months are unchanged.
- Mirrored in `go/internal/domain/fees/junior.go` and `legacy/scripts/attendance.py`; two new parity fixtures for `calculate_junior_fee`.
## 2026-10-08 12:37 CEST — feat(infer): messages starting with "other:" are not monthly fees
- When the bank message starts with `other:` (case-insensitive), inference no longer looks for a month. It copies the trimmed message into Purpose, so the payment is linked to the person as an "other" transaction and not counted as a monthly fee. Messages that contain `other:` elsewhere are inferred as before.
@@ -288,7 +301,7 @@
## 2026-05-06 16:38 CEST — fix: include juniors in payment-inference roster
- `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher.
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Dvořák were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
- Two regression tests added to `tests/test_match_members.py`.
## 2026-05-06 16:05 CEST — feat(go/M2.10): port domain/reconcile.Reconcile
+4 -1
View File
@@ -29,6 +29,9 @@ These exist only in the Go app on purpose. Do not port them to the legacy Python
## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- Tests may use a real member's name as a bare matching input (diacritics, nicknames, word order).
- Never commit a real name together with payment details or raw bank messages, and never commit names of juniors or their parents. Use fictional names there.
- Parity fixtures under `go/tests/fixtures/` must be scrubbed (`Member_<hash>`); check new or regenerated fixtures before committing.
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `go/internal/config/config.go` (mirrored in `legacy/scripts/config.py`). Domain-specific constants (fees, merged months) stay in their respective modules.
## Development Setup
@@ -140,7 +143,7 @@ Tiers are set in column B of the attendance sheet:
### Fee calculation
- Adults: 0 sessions → 0, 1 session → 200 CZK (never more than the month's rate, so a 0-rate month is free), 2+ sessions → monthly rate (default 600 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required; 0 in a 0-rate month), 2+ → monthly rate from `JuniorFeeMonthlyRate` / `JUNIOR_MONTHLY_RATE` (default 500 CZK)
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
### Merged months
@@ -2,12 +2,12 @@
## 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
A bank payment from sender `JIŘÍ DVOŘÁK` with the message
`Jáchym Dvořák: 01/2026+03/2026+04/2026` is being inferred as
`[?] Jáchym Novotný (G)` instead of the obvious `Jáchym Dvořák`, even though
the message contains his exact full name.
**Root cause** (confirmed with the user): `Jáchym Kubík` is in the **junior**
**Root cause** (confirmed with the user): `Jáchym Dvořá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()`
@@ -15,17 +15,17 @@ builds `member_names` by calling `get_members_with_fees()`
`EXPORT_URL` (the adults sheet). Junior-only members are therefore invisible
to the matcher.
With Kubík absent from `member_names`, the matcher in
With Dvořá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
combined text `jiri dvorak jachym dvorak: 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
- Novotný `(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)`.
review rule (`match_payments.py:123-125`) → returned as `("Jáchym Novotný (G)",
"review")`, rendered as `[?] Jáchym Novotný (G)`.
The user's original framing — "exact match in message should win over
everything" — is already implemented for any candidate that **is** in the
@@ -33,7 +33,7 @@ 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.
junior-only names like `Jáchym Dvořák` get matched correctly.
## Approach
@@ -98,23 +98,23 @@ changes are needed.
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
short-circuit), so once Dvořá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.
- Clear `Person`/`Purpose` for the Dvořák row in the payments sheet.
- `make infer`.
- Expect `Person = Jáchym Kubík`, `Purpose = 2026-01, 2026-03, 2026-04`,
- Expect `Person = Jáchym Dvořá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
includes `Jáchym Novotný (G)` and `Jáchym Dvořák`, the message
`Jáchym Dvořák: 01/2026+03/2026+04/2026` resolves to
`[("Jáchym Dvořá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.
@@ -122,7 +122,7 @@ changes are needed.
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.
credited to Dvořá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):
@@ -58,7 +58,8 @@ before new feature work starts. Companion to the Go rewrite tracker,
current year.
- Same applies to the `sync-2026` Makefile target.
- Moot if Python is retired before then.
- [ ] **T4** Remove member names from tracked files (PII rule in `CLAUDE.md`).
- [x] **T4** Remove member names from tracked files (PII rule in `CLAUDE.md`).
Done 2026-10-09 in a narrower form, see Notes & decisions. Original task:
- `CHANGELOG.md` line 29 names a member in a fix description.
- `tests/test_match_payments.py`, `tests/test_reconcile_exceptions.py` and
`go/internal/domain/reconcile/reconcile_test.go` use names that may be
@@ -172,3 +173,16 @@ Plan: [2026-10-08-1103-api-parity-vs-months-to-show.md](2026-10-08-1103-api-pari
## Notes & decisions
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
- 2026-10-09 — T4 scope decided by the owner: a real name as a bare test
input is acceptable; a real name next to payment details, raw bank
messages, or any junior or parent name is not. Replaced the junior
regression-test names (test, changelog, plan) with fictional ones and
scrubbed three parity fixtures that still held raw bank messages. The
other test names stay. Git history was not rewritten. Rule recorded in
`CLAUDE.md`.
- 2026-10-09 — The `real_*` parse_month_references seeds pick rows from the
live payments cache by index (`_REAL_PMR_INDICES`). The sheet has changed
since capture, so `make capture-fixtures` now rewrites those fixtures with
different messages. Commit only the fixtures a change needs, or replace
the index-based seeds with fixed synthetic ones.
+9 -6
View File
@@ -7,6 +7,7 @@ const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in
var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250,
"2026-03": 250,
"2026-08": 0,
}
// Expected is the result of a junior fee calculation.
@@ -21,17 +22,19 @@ type Expected struct {
// the given monthKey (format "YYYY-MM").
//
// 0 practices → Expected{Value: 0}
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?")
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?"),
// or Expected{Value: 0} when the month's rate is 0
// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault}
func CalculateJuniorFee(attendanceCount int, monthKey string) Expected {
if attendanceCount == 0 {
return Expected{Value: 0}
}
if attendanceCount == 1 {
rate, ok := JuniorFeeMonthlyRate[monthKey]
if !ok {
rate = JuniorFeeDefault
}
if attendanceCount == 1 && rate > 0 {
return Expected{Unknown: true}
}
if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok {
return Expected{Value: rate}
}
return Expected{Value: JuniorFeeDefault}
return Expected{Value: rate}
}
+3
View File
@@ -33,6 +33,9 @@ func TestCalculateJuniorFee(t *testing.T) {
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
// A 0-rate month is free, and a single practice needs no manual review.
{"two in free month is free", 2, "2026-08", Expected{Value: 0}},
{"single in free month is free", 1, "2026-08", Expected{Value: 0}},
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
@@ -0,0 +1,13 @@
{
"case": "one_session_free_august",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 1,
"month_key": "2026-08"
},
"output": {
"value": 0,
"unknown": false
}
}
@@ -0,0 +1,13 @@
{
"case": "two_sessions_free_august",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2026-08"
},
"output": {
"value": 0,
"unknown": false
}
}
@@ -3,7 +3,7 @@
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Filip Halamka - prosinec, leden, unor",
"text": "Member_607b748d - prosinec, leden, unor",
"default_year": 2026
},
"output": {
@@ -3,7 +3,7 @@
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Jáchym Kubík: 01/2026+03/2026+04/2026",
"text": "Member_71343342: 01/2026+03/2026+04/2026",
"default_year": 2026
},
"output": {
@@ -3,7 +3,7 @@
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Martin Bolvansky Pausal Prosinec Leden",
"text": "Member_51a8ec2d Pausal Prosinec Leden",
"default_year": 2026
},
"output": {
+6
View File
@@ -146,6 +146,12 @@ SEEDS[("calculate_junior_fee", "two_sessions_reduced_sep")] = {
SEEDS[("calculate_junior_fee", "two_sessions_default_fallback")] = {
"attendance_count": 2, "month_key": "2099-06"
}
SEEDS[("calculate_junior_fee", "two_sessions_free_august")] = {
"attendance_count": 2, "month_key": "2026-08"
}
SEEDS[("calculate_junior_fee", "one_session_free_august")] = {
"attendance_count": 1, "month_key": "2026-08"
}
# --- parse_czk_amount ---
+6 -4
View File
@@ -34,7 +34,8 @@ ADULT_FEE_MONTHLY_RATE = {
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
JUNIOR_MONTHLY_RATE = {
"2025-09": 250,
"2026-03": 250 # reduced fee for March 2026
"2026-03": 250, # reduced fee for March 2026
"2026-08": 0,
}
ADULT_MERGED_MONTHS = {
"2025-09": "2025-10", # keys are merged into values
@@ -105,12 +106,13 @@ def calculate_fee(attendance_count: int, month_key: str) -> int:
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
"""Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500)."""
"""Apply junior fee rules: 0 → 0, 1 → '?' (0 in a 0-rate month), 2+ → Configured Rate (default 500)."""
if attendance_count == 0:
return 0
if attendance_count == 1:
rate = JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
if attendance_count == 1 and rate > 0:
return "?"
return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
return rate
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
+7 -7
View File
@@ -51,21 +51,21 @@ class TestMatchMembersExact(unittest.TestCase):
def test_shared_first_name_junior_in_roster_wins_exact(self):
# Regression: two members share first name "Jáchym"; message has full name
# of the junior-only member → exact match must win, no [?] on the adult.
roster = ["Jáchym Hrušák (G)", "Jáchym Kubík"]
roster = ["Jáchym Novotný (G)", "Jáchym Dvořák"]
result = match_members(
"JIŘÍ KUBÍK Jáchym Kubík: 01/2026+03/2026+04/2026", roster
"JIŘÍ DVOŘÁK Jáchym Dvořák: 01/2026+03/2026+04/2026", roster
)
self.assertEqual(result, [("Jáchym Kubík", "auto")])
self.assertEqual(result, [("Jáchym Dvořák", "auto")])
def test_shared_first_name_without_junior_in_roster_falls_back(self):
# Without Kubík in the roster (old behaviour), Hrušák wins via first-name
# Without Dvořák in the roster (old behaviour), Novotný wins via first-name
# partial match — confirms the roster-expansion fix is the real solution.
roster = ["Jáchym Hrušák (G)"]
roster = ["Jáchym Novotný (G)"]
result = match_members(
"JIŘÍ KUBÍK Jáchym Kubík: 01/2026+03/2026+04/2026", roster
"JIŘÍ DVOŘÁK Jáchym Dvořák: 01/2026+03/2026+04/2026", roster
)
names = [r[0] for r in result]
self.assertIn("Jáchym Hrušák (G)", names)
self.assertIn("Jáchym Novotný (G)", names)
if __name__ == "__main__":