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
kacerr 7d55eca189 Merge pull request 'feat(infer): messages starting with "other:" are not monthly fees' (#49) from feat/infer-other-purpose into main
Build and Push / build (push) Successful in 1m10s
Build and Push / build-go (push) Successful in 2m1s
Reviewed-on: #49
2026-10-08 12:40:29 +02:00
kacerrandClaude Opus 5.5 bbad19d822 feat(infer): messages starting with "other:" are not monthly fees
When the bank message starts with "other:" (case-insensitive),
inference does not look for a month, neither in the text nor from the
payment date. The trimmed message is written to Purpose, so reconcile
books the payment as an "other" transaction for the person instead of
a monthly fee. Messages containing "other:" elsewhere are unchanged.

- matching.IsOtherPurpose / is_other_purpose shared by inference and
  reconcile
- reconcile applies the rule to rows not yet processed by inference
- no person found: Purpose is still written, row shows as unmatched
- implemented in Go and legacy Python; four new parity fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00
kacerrandClaude Opus 5.5 294c40fa42 fix(go): show "Expected: ?" in junior tooltip for unknown fees
A junior month with one session has an unknown expected fee. When such
a month had a payment, the Go tooltip printed "Expected: 0" where
Python prints "Expected: ?", which made make parity fail on
/api/juniors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00
27 changed files with 683 additions and 51 deletions
+23 -1
View File
@@ -1,5 +1,27 @@
# 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.
- If no person is found, Purpose is still written and the row shows under unmatched for a manual fix.
- Reconcile applies the same rule to rows not yet processed by inference.
- Existing rows are not re-inferred; clear Person and Purpose on a row to reprocess it.
- Go and Python: `matching.IsOtherPurpose` / `is_other_purpose`, `InferTransactionDetails` / `infer_transaction_details`, reconcile fallback; four new parity fixtures.
- Also fixed (Go): junior cell tooltip showed `Expected: 0` instead of `Expected: ?` when a month with unknown expected fee had a payment.
## 2026-10-08 12:16 CEST — feat(go): scheduled bank sync (Go only)
- The Go server now runs the bank sync (sync → infer → flush cache) by itself at the times in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`, `off` disables), read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup; an invalid value stops the server at startup with an error.
@@ -279,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
+6 -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
@@ -164,6 +167,8 @@ Both implementations check the Drive API `modifiedTime` and serve a cached JSON
`Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
**Non-fee payments ("other")**: a Purpose that starts with `other:` (case-insensitive) is attached to the person's "other transactions" and is not counted against any month. Inference produces this by itself when the bank message starts with `other:`: it copies the trimmed message into Purpose and does not look for a month (neither in the text nor from the date). A message that only contains `other:` later in the text is inferred the normal way. The shared check is `matching.IsOtherPurpose` / `is_other_purpose`.
### QR codes
`/qr?account=…&amount=…&message=…` generates a Czech QR Platba PNG (SPD format).
@@ -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.
@@ -0,0 +1,109 @@
# Inference: messages starting with "other:" are not monthly fees
## Context
Some payments are not monthly fees (tournament fees, jerseys, …). Reconcile
already supports them: a row whose **Purpose** starts with `other:` is attached
to the person's "other transactions" and is not counted against any month.
Today that only happens when someone types the purpose by hand, because
inference always tries to find a month — from the message, or failing that
from the payment date.
New rule: when the bank message for the recipient **starts with `other:`**,
inference must not look for a month. It copies the message into Purpose, so
the payment is linked to the person but not counted as a monthly fee. Messages
that merely contain `other:` somewhere else are handled the old way.
Implemented in both Go and legacy Python (this is inference behaviour, so the
dual implementation rule applies).
## Behaviour
- Trigger: message, after trimming whitespace, starts with `other:`
(case-insensitive, same test reconcile already uses for Purpose).
- Purpose written to the sheet: the trimmed message, unchanged
(e.g. `other: turnaj Praha`). No month parsing, no date fallback.
- Person: inferred exactly as today (sender + message + VS, then sender
alone), including the `[?]` review marker.
- Inferred Amount: the full amount, as today.
- No person found: Purpose is still written, Person stays empty. The row then
shows up under "unmatched" for a manual fix instead of being guessed again
on every run.
- Rows not yet processed by inference (Person and Purpose both empty) are
inferred on the fly by reconcile; that path follows the same rule, so such a
payment is never briefly counted as a monthly fee.
- Existing rows are untouched: inference only fills rows where manual fix,
Person and Purpose are all empty. To reprocess an old row, clear its Person
and Purpose cells.
## Implementation
Branch `feat/infer-other-purpose` off `main`; MR via `tea`.
The output shape of the inference function does not change: for an "other"
message the months list holds the single purpose string. That is the same
convention reconcile already uses internally (`matchedMonths = [purpose]`),
and it means the sheet-writing code needs no change in either language.
1. **Shared predicate**
- Go: `matching.IsOtherPurpose(s string) bool` in
`go/internal/domain/matching/infer.go`
(`strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")`).
- Python: `is_other_purpose(s)` in `legacy/scripts/match_payments.py`.
- Replace the two existing inline checks in reconcile with it
(`go/internal/domain/reconcile/reconcile.go`, `reconcile()` in
`match_payments.py`).
2. **Inference**
- Go `matching.InferTransactionDetails` and Python
`infer_transaction_details`: after member matching, if the message is an
other-purpose, return `months = [trimmed message]` and skip
`ParseMonthReferences` and the date fallback. Update the doc comments
(months may hold one `other:…` purpose).
- `banksync.InferPayments` (`go/internal/services/banksync/infer.go`) and
`infer_payments.py`: no logic change — they already join months into
Purpose and write the row when either members or months are non-empty.
3. **Reconcile fallback** (rows with empty Person/Purpose)
- In the inference-fallback branch of both reconcile implementations: if
the inferred months are a single other-purpose, set the row's purpose to
it and mark it as other, so it goes to the member's other-transactions
ledger instead of the month allocation.
4. **Tests and fixtures**
- Seeds in `legacy/scripts/_fixture_seeds.py`, captured with the existing
capture/scrub scripts for these two functions only:
- `infer_transaction_details`: `other_prefix` (message
`other: tournament`, member in sender → that purpose, no month),
`other_prefix_uppercase` (`Other: …`), `other_not_at_start`
(`leden other: x` → old behaviour, month parsed).
- `reconcile`: `11_other_message_fallback` (empty Person/Purpose, message
starts with `other:` → lands in `other_transactions`, month stays
unpaid).
- Go unit tests: `IsOtherPurpose` table; cases in
`matching/infer_test.go`; a case in `banksync/infer_test.go` asserting
the written row (`Person`, `Purpose = "other: …"`, amount) and one with
no matching member; a fallback case in `reconcile/reconcile_test.go`.
- Python: cases in `legacy/tests/test_match_payments.py` for
`infer_transaction_details` and the reconcile fallback.
- Existing fixtures must stay byte-identical apart from the new files.
5. **Docs**
- `CLAUDE.md`: add the rule to the payments-sheet / inference description.
- `CHANGELOG.md` entry; copy this plan to
`docs/plans/2026-10-08-HHMM-infer-other-purpose.md`.
No change to the JSON API, schemas, templates or the sheet layout.
## Verification
1. `make go-test-all` (unit + parity, including the new fixtures) and
`make test`.
2. `git status go/tests/fixtures` shows only the four new fixture files.
3. Dry run against the live sheet in both implementations
(`fuj infer --dry-run`, Python `infer_payments.py --dry-run`): confirm it
runs cleanly and proposes nothing unexpected. The live sheet probably has
no un-inferred row starting with `other:`, so the new rule itself is
proven by the tests rather than by live data.
4. `make parity` between the two servers (or the two-container run used
earlier) still reports 3/3 routes matching.
+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}},
+25 -2
View File
@@ -3,6 +3,7 @@ package matching
import (
"fmt"
"fuj-management/go/internal/domain/czech"
"strings"
"time"
)
@@ -17,12 +18,22 @@ type Transaction struct {
}
// InferredDetails is the result of InferTransactionDetails.
// Months holds "YYYY-MM" keys, or exactly one "other:…" purpose when the
// payment is not a monthly fee (see IsOtherPurpose).
type InferredDetails struct {
Members []Match
Months []string
SearchText string
}
// IsOtherPurpose reports whether s marks a payment that is not a monthly fee:
// it starts with "other:" (case-insensitive, leading whitespace ignored).
// Used for both the Purpose column and the bank message.
// Ports legacy/scripts/match_payments.py is_other_purpose.
func IsOtherPurpose(s string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")
}
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
//
// Search text for member matching: sender + message + user_id.
@@ -30,6 +41,10 @@ type InferredDetails struct {
// Fallback 1: if no members found, retry match on sender alone.
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
//
// A message that starts with "other:" is not a monthly fee: no month is
// inferred (neither from the text nor from the date) and Months holds the
// trimmed message as the single purpose.
//
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
//
@@ -38,12 +53,20 @@ func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear i
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
members := MatchMembers(searchText, memberNames)
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(members) == 0 {
members = MatchMembers(tx.Sender, memberNames)
}
if IsOtherPurpose(tx.Message) {
return InferredDetails{
Members: members,
Months: []string{strings.TrimSpace(tx.Message)},
SearchText: searchText,
}
}
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
if ym := inferMonthFromDate(tx.Date); ym != "" {
months = []string{ym}
+68
View File
@@ -32,6 +32,74 @@ import (
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
func TestIsOtherPurpose(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"other: tournament": true,
"other:shirt": true,
" Other: Tournament": true,
"OTHER:x": true,
"leden other: x": false,
"other tournament": false,
"another: x": false,
"2026-01": false,
"": false,
}
for in, want := range cases {
if got := IsOtherPurpose(in); got != want {
t.Errorf("IsOtherPurpose(%q) = %v, want %v", in, got, want)
}
}
}
func TestInferTransactionDetails_OtherMessage(t *testing.T) {
t.Parallel()
jana := []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}}
cases := []struct {
name string
tx Transaction
wantMembers []Match
wantMonths []string
}{
{
// Month words and the date must both be ignored.
name: "other prefix: message becomes the purpose, no month",
tx: Transaction{Sender: "Jana Novakova", Message: " other: turnaj leden 2026 ", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"other: turnaj leden 2026"},
},
{
name: "other prefix is case-insensitive and keeps the original text",
tx: Transaction{Sender: "Jana Novakova", Message: "Other: Dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"Other: Dres"},
},
{
name: "other prefix with no known member still yields the purpose",
tx: Transaction{Sender: "Unknown Payer", Message: "other: dres", Date: "2026-04-15"},
wantMembers: nil,
wantMonths: []string{"other: dres"},
},
{
name: "other: not at the start is handled the old way",
tx: Transaction{Sender: "Jana Novakova", Message: "leden other: dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"2026-01"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := InferTransactionDetails(tc.tx, inferMembers, 2026)
if len(got.Members) != len(tc.wantMembers) || (len(got.Members) > 0 && !reflect.DeepEqual(got.Members, tc.wantMembers)) {
t.Errorf("Members = %v, want %v", got.Members, tc.wantMembers)
}
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
t.Errorf("Months = %v, want %v", got.Months, tc.wantMonths)
}
})
}
}
func TestInferTransactionDetails(t *testing.T) {
t.Parallel()
+6 -1
View File
@@ -203,7 +203,7 @@ func Reconcile(
personStr := strings.TrimSpace(tx.Person)
purposeStr := strings.TrimSpace(tx.Purpose)
personStr = questionMarkRe.ReplaceAllString(personStr, "")
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
isOther := matching.IsOtherPurpose(purposeStr)
var matchedMembers []matching.Match
var matchedMonths []string
@@ -249,6 +249,11 @@ func Reconcile(
matchedMembers = inferred.Members
matchedMonths = inferred.Months
amount = tx.Amount
// A message starting with "other:" is inferred as a non-fee payment.
if len(matchedMonths) == 1 && matching.IsOtherPurpose(matchedMonths[0]) {
isOther = true
purposeStr = matchedMonths[0]
}
}
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
@@ -257,6 +257,33 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
}
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
// A row not yet processed by inference (empty Person/Purpose) whose message
// starts with "other:" must land in the other ledger, not in a month.
func TestReconcileOtherMessageFallback(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-01-10", Amount: 300,
Sender: "Alice", Message: "other: shirt",
}}
result := Reconcile(members, []string{"2026-01"}, txs, nil, 2026)
alice := result.Members["Alice"]
if paid := alice.Months["2026-01"].Paid; paid != 0 {
t.Errorf("2026-01 paid = %v, want 0", paid)
}
if len(alice.OtherTransactions) != 1 {
t.Fatalf("other transactions = %d, want 1", len(alice.OtherTransactions))
}
if got := alice.OtherTransactions[0]; got.Amount != 300 || got.Purpose != "other: shirt" {
t.Errorf("other entry = %+v", got)
}
if len(result.Unmatched) != 0 {
t.Errorf("unmatched = %d, want 0", len(result.Unmatched))
}
}
func TestReconcileOtherPurpose(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
@@ -53,6 +53,42 @@ func TestInferPayments_BasicMatch(t *testing.T) {
}
}
func TestInferPayments_OtherMessage(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:Z": {
paymentsHeader,
// Known member: Person filled, message copied to Purpose, no month.
{"2026-04-10", 300.0, "", "", "", "", "Jana Novakova", "", "other: dres", "", ""},
// Unknown sender: Purpose is still written, Person stays empty.
{"2026-04-11", 500.0, "", "", "", "", "Unknown Payer", "", "Other: turnaj", "", ""},
},
}}
att := &fakeAttendance{
adults: []reconcile.Member{{Name: "Jana Novakova", Tier: "A"}},
}
n, err := InferPayments(context.Background(), "SHEETID", sh, att, InferOpts{})
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("want 2 rows updated, got %d", n)
}
updates := sh.BatchUpdated[0].Updates
want := [][]any{
{"Jana Novakova", "other: dres", "300"},
{"", "Other: turnaj", "500"},
}
for i, w := range want {
got := updates[i].Values[0]
for j := range w {
if got[j] != w[j] {
t.Errorf("row %d col %d: want %q, got %q", i, j, w[j], got[j])
}
}
}
}
func TestInferPayments_SkipRule_ManualFix(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:Z": {
+6 -1
View File
@@ -242,7 +242,12 @@ func buildJuniorMemberRow(
tooltip := ""
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
// Unknown expected (one session, manual review) shows as "?", as in Python.
expectedText := strconv.Itoa(md.Expected)
if md.IsUnknown {
expectedText = "?"
}
tooltip = fmt.Sprintf("Received: %d, Expected: %s", paid, expectedText)
}
row.Months = append(row.Months, MonthCell{
@@ -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
}
}
@@ -0,0 +1,36 @@
{
"case": "other_not_at_start",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Jana Novakova",
"message": "leden other: dres",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [
{
"name": "Jana Nováková",
"confidence": "auto"
}
],
"months": [
"2026-01"
],
"search_text": "Jana Novakova leden other: dres "
}
}
@@ -0,0 +1,36 @@
{
"case": "other_prefix",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Jana Novakova",
"message": "other: tournament leden 2026",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [
{
"name": "Jana Nováková",
"confidence": "auto"
}
],
"months": [
"other: tournament leden 2026"
],
"search_text": "Jana Novakova other: tournament leden 2026 "
}
}
@@ -0,0 +1,31 @@
{
"case": "other_prefix_uppercase",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Test Payer",
"message": " Other: Dres ",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [],
"months": [
"Other: Dres"
],
"search_text": "Test Payer Other: Dres "
}
}
@@ -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": {
@@ -0,0 +1,69 @@
{
"case": "11_other_message_fallback",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-10-08",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-10",
"amount": 300,
"manual_fix": "",
"person": "",
"purpose": "",
"inferred_amount": 300,
"sender": "Member_d035d9f9",
"message": "other: tournament",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 0.0,
"transactions": []
}
},
"other_transactions": [
{
"amount": 300.0,
"date": "2026-01-10",
"sender": "Member_d035d9f9",
"message": "other: tournament",
"purpose": "other: tournament",
"confidence": "auto"
}
],
"total_balance": -750
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": -750
}
}
}
+44
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 ---
@@ -358,6 +364,36 @@ SEEDS[("infer_transaction_details", "serial_date")] = {
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix")] = {
"tx": {
"sender": "Jana Novakova",
"message": "other: tournament leden 2026",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix_uppercase")] = {
"tx": {
"sender": "Test Payer",
"message": " Other: Dres ",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_not_at_start")] = {
"tx": {
"sender": "Jana Novakova",
"message": "leden other: dres",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "no_member_no_month")] = {
"tx": {
"sender": "Unknown Person",
@@ -476,6 +512,14 @@ SEEDS[("reconcile", "07_other_purpose_split")] = _reconcile_seed(
transactions=[_tx("2026-01-10", 800, "Alice Dvořák, Bob Kratochvíl", "other:tournament", sender="Alice Dvořák")],
)
# 11 — message starts with "other:" on a row not yet inferred (empty Person/Purpose):
# goes to the other ledger, the month stays unpaid
SEEDS[("reconcile", "11_other_message_fallback")] = _reconcile_seed(
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
sorted_months=["2026-01"],
transactions=[_tx("2026-01-10", 300, "", "", sender="Alice Dvořák", message="other: tournament")],
)
# 08 — junior with attendance=1 (expected=0 in reconcile, unknown in UI)
SEEDS[("reconcile", "08_junior_question_mark")] = _reconcile_seed(
members=[_member("Karel Junior", "A", {"2026-01": (0, 1)})],
+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]]]:
+31 -6
View File
@@ -150,27 +150,48 @@ def match_members(
# Reconciliation
# ---------------------------------------------------------------------------
def is_other_purpose(text) -> bool:
"""True when text marks a payment that is not a monthly fee.
It starts with "other:" (case-insensitive, leading whitespace ignored).
Used for both the Purpose column and the bank message.
"""
return str(text or "").strip().lower().startswith("other:")
def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
"""Infer member(s) and month(s) for a single transaction.
A message that starts with "other:" is not a monthly fee: no month is
inferred (neither from the text nor from the date) and 'months' holds the
trimmed message as the single purpose.
Returns:
{
'members': [(name, confidence)],
'months': [YYYY-MM],
'months': [YYYY-MM] or ['other:…'],
'matched_text': str
}
"""
# Combine sender + message for searching
search_text = f"{tx.get('sender', '')} {tx.get('message', '')} {tx.get('user_id', '')}"
matched_members = match_members(search_text, member_names)
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_members:
# Try matching sender name alone with more lenient matching
matched_members = match_members(tx.get("sender", ""), member_names)
if is_other_purpose(tx.get("message", "")):
return {
"members": matched_members,
"months": [str(tx.get("message", "")).strip()],
"search_text": search_text
}
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_months:
# If no month specified, try to infer from payment date
tx_date = tx.get("date")
@@ -394,7 +415,7 @@ def reconcile(
# Strip markers like [?]
person_str = re.sub(r"\[\?\]\s*", "", person_str)
is_other = purpose_str.lower().startswith("other:")
is_other = is_other_purpose(purpose_str)
if person_str and purpose_str:
# We have pre-matched data (either from script or manual)
@@ -415,6 +436,10 @@ def reconcile(
inference = infer_transaction_details(tx, member_names)
matched_members = inference["members"]
matched_months = inference["months"]
# A message starting with "other:" is inferred as a non-fee payment.
if len(matched_months) == 1 and is_other_purpose(matched_months[0]):
is_other = True
purpose_str = matched_months[0]
amount = tx.get("amount", 0)
try:
amount = float(amount)
+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__":
+44 -1
View File
@@ -1,7 +1,9 @@
import unittest
from unittest.mock import MagicMock, patch
from scripts.match_payments import canonical_member_key, fetch_sheet_data, reconcile
from scripts.match_payments import (
canonical_member_key, fetch_sheet_data, infer_transaction_details, is_other_purpose, reconcile,
)
class TestCanonicalMemberKey(unittest.TestCase):
@@ -88,3 +90,44 @@ class TestFetchSheetDataVS(unittest.TestCase):
def test_string_vs_is_kept(self):
self.assertEqual(self._fetch("0308")[0]["vs"], "0308")
class TestOtherPurposeInference(unittest.TestCase):
"""A message starting with "other:" is not a monthly fee."""
MEMBERS = ["Jana Nováková", "Adam Novák"]
def test_is_other_purpose(self):
for text in ("other: tournament", "other:shirt", " Other: Tournament", "OTHER:x"):
self.assertTrue(is_other_purpose(text), text)
for text in ("leden other: x", "other tournament", "another: x", "2026-01", "", None):
self.assertFalse(is_other_purpose(text), text)
def test_message_becomes_purpose_and_no_month_is_inferred(self):
tx = {"sender": "Jana Novakova", "message": " other: turnaj leden 2026 ", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["members"], [("Jana Nováková", "auto")])
self.assertEqual(result["months"], ["other: turnaj leden 2026"])
def test_unknown_member_still_yields_purpose(self):
tx = {"sender": "Unknown Payer", "message": "Other: Dres", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["members"], [])
self.assertEqual(result["months"], ["Other: Dres"])
def test_other_not_at_start_is_handled_the_old_way(self):
tx = {"sender": "Jana Novakova", "message": "leden other: dres", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["months"], ["2026-01"])
def test_reconcile_fallback_puts_it_in_other_ledger(self):
members = [("Jana Nováková", "A", {"2026-01": (750, 3)})]
tx = {"date": "2026-01-10", "amount": 300, "person": "", "purpose": "",
"sender": "Jana Novakova", "message": "other: dres", "inferred_amount": ""}
result = reconcile(members, ["2026-01"], [tx], {})
member = result["members"]["Jana Nováková"]
self.assertEqual(member["months"]["2026-01"]["paid"], 0)
self.assertEqual(len(member["other_transactions"]), 1)
self.assertEqual(member["other_transactions"][0]["amount"], 300)
self.assertEqual(member["other_transactions"][0]["purpose"], "other: dres")
self.assertEqual(result["unmatched"], [])