Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53b57f976 | ||
|
|
bc49a8286d | ||
|
|
21db863958 | ||
|
|
7d55eca189 |
+14
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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]]]:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user