From 21db86395878cf342189155da4f26deff18da8c3 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 8 Oct 2026 21:27:10 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++++++ CLAUDE.md | 2 +- go/internal/domain/fees/junior.go | 15 +++++++++------ go/internal/domain/fees/junior_test.go | 3 +++ .../one_session_free_august.json | 13 +++++++++++++ .../two_sessions_free_august.json | 13 +++++++++++++ legacy/scripts/_fixture_seeds.py | 6 ++++++ legacy/scripts/attendance.py | 10 ++++++---- 8 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 go/tests/fixtures/pure/calculate_junior_fee/one_session_free_august.json create mode 100644 go/tests/fixtures/pure/calculate_junior_fee/two_sessions_free_august.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fac71bf..51d4a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 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. diff --git a/CLAUDE.md b/CLAUDE.md index ff45745..9ebb3b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,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 diff --git a/go/internal/domain/fees/junior.go b/go/internal/domain/fees/junior.go index b48aa1f..0d86508 100644 --- a/go/internal/domain/fees/junior.go +++ b/go/internal/domain/fees/junior.go @@ -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} } diff --git a/go/internal/domain/fees/junior_test.go b/go/internal/domain/fees/junior_test.go index c0ed3b8..fb0285e 100644 --- a/go/internal/domain/fees/junior_test.go +++ b/go/internal/domain/fees/junior_test.go @@ -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}}, diff --git a/go/tests/fixtures/pure/calculate_junior_fee/one_session_free_august.json b/go/tests/fixtures/pure/calculate_junior_fee/one_session_free_august.json new file mode 100644 index 0000000..083fc1f --- /dev/null +++ b/go/tests/fixtures/pure/calculate_junior_fee/one_session_free_august.json @@ -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 + } +} diff --git a/go/tests/fixtures/pure/calculate_junior_fee/two_sessions_free_august.json b/go/tests/fixtures/pure/calculate_junior_fee/two_sessions_free_august.json new file mode 100644 index 0000000..2e747c3 --- /dev/null +++ b/go/tests/fixtures/pure/calculate_junior_fee/two_sessions_free_august.json @@ -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 + } +} diff --git a/legacy/scripts/_fixture_seeds.py b/legacy/scripts/_fixture_seeds.py index 3810031..cf85f77 100644 --- a/legacy/scripts/_fixture_seeds.py +++ b/legacy/scripts/_fixture_seeds.py @@ -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 --- diff --git a/legacy/scripts/attendance.py b/legacy/scripts/attendance.py index 20ceb61..a028850 100644 --- a/legacy/scripts/attendance.py +++ b/legacy/scripts/attendance.py @@ -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]]]: -- 2.54.0