From 6cf83a01e347eaa39df0f0f994afc10d61617476 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Wed, 6 May 2026 00:29:19 +0200 Subject: [PATCH 1/2] docs(claude): correct stale adult fee defaults ADULT_FEE_DEFAULT is 700 CZK, not 750. The 750 appears in ADULT_FEE_MONTHLY_RATE for most current months but is not the fallback. Rephrase the member-tiers bullet to point at the dict rather than a number that drifts each season; update the fee-calc bullet to match the junior line's style (default 700 vs default 500). Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 776cf66..ea5661f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,13 +64,13 @@ Fio Bank API ──► sync_fio_to_sheets.py ──► Google Shee ### Member tiers Tiers are set in column B of the attendance sheet: -- `A` — Adult, pays fees (750 CZK/month for 2+ sessions, 200 CZK for exactly 1) +- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1) - `J` — Junior attending adult practices; their attendance is merged with the junior sheet - `X` — Excluded from junior fee calculation (coaches, etc.) ### Fee calculation -- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 750 CZK) +- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK) - Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → 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))`. From 57ec8170443cdeb62d4c6f5399756dc0bf9a5737 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Wed, 6 May 2026 00:38:09 +0200 Subject: [PATCH 2/2] feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee Ports calculate_fee and calculate_junior_fee from scripts/attendance.py into a new go/internal/domain/fees package. Introduces the Expected type (Value int, Unknown bool) for the junior "?" sentinel, keeping the Go API strictly typed instead of mirroring Python's str|int return. All 20 table-driven tests pass with -race; golangci-lint clean. Co-Authored-By: Claude Opus 4.7 --- go/internal/domain/fees/fees.go | 34 +++++++++++++++++++++++ go/internal/domain/fees/fees_test.go | 37 ++++++++++++++++++++++++++ go/internal/domain/fees/junior.go | 37 ++++++++++++++++++++++++++ go/internal/domain/fees/junior_test.go | 37 ++++++++++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 go/internal/domain/fees/fees.go create mode 100644 go/internal/domain/fees/fees_test.go create mode 100644 go/internal/domain/fees/junior.go create mode 100644 go/internal/domain/fees/junior_test.go diff --git a/go/internal/domain/fees/fees.go b/go/internal/domain/fees/fees.go new file mode 100644 index 0000000..2ebee80 --- /dev/null +++ b/go/internal/domain/fees/fees.go @@ -0,0 +1,34 @@ +// Package fees ports fee calculation from scripts/attendance.py. +package fees + +const ( + AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate + AdultFeeSingle = 200 // CZK for exactly 1 practice +) + +// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py. +// Months absent from this map fall back to AdultFeeDefault. +var AdultFeeMonthlyRate = map[string]int{ + "2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750, + "2026-01": 750, "2026-02": 750, "2026-03": 350, + "2026-04": 700, "2026-05": 700, +} + +// CalculateFee returns the adult fee in CZK for attendanceCount practices in +// the given monthKey (format "YYYY-MM"). +// +// 0 practices → 0 +// 1 practice → AdultFeeSingle (200) +// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault +func CalculateFee(attendanceCount int, monthKey string) int { + if attendanceCount == 0 { + return 0 + } + if attendanceCount == 1 { + return AdultFeeSingle + } + if rate, ok := AdultFeeMonthlyRate[monthKey]; ok { + return rate + } + return AdultFeeDefault +} diff --git a/go/internal/domain/fees/fees_test.go b/go/internal/domain/fees/fees_test.go new file mode 100644 index 0000000..ac7e9e5 --- /dev/null +++ b/go/internal/domain/fees/fees_test.go @@ -0,0 +1,37 @@ +package fees + +import "testing" + +func TestCalculateFee(t *testing.T) { + t.Parallel() + + // All expected outputs verified against live Python implementation on 2026-05-06: + // PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])' + tests := []struct { + name string + count int + month string + want int + }{ + {"zero short-circuits", 0, "2026-05", 0}, + {"zero empty month", 0, "", 0}, + {"single practice", 1, "2026-05", 200}, + {"single ignores monthKey", 1, "unknown", 200}, + {"two practices configured month", 2, "2026-05", 700}, + {"two practices reduced march", 2, "2026-03", 350}, + {"two practices early season", 2, "2025-09", 750}, + {"high count same as two", 5, "2026-05", 700}, + {"unknown future month falls back", 2, "2027-01", 700}, + {"empty month falls back", 2, "", 700}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := CalculateFee(tc.count, tc.month) + if got != tc.want { + t.Errorf("CalculateFee(%d, %q) = %d, want %d", tc.count, tc.month, got, tc.want) + } + }) + } +} diff --git a/go/internal/domain/fees/junior.go b/go/internal/domain/fees/junior.go new file mode 100644 index 0000000..b1f0c3f --- /dev/null +++ b/go/internal/domain/fees/junior.go @@ -0,0 +1,37 @@ +package fees + +const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate + +// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py. +// Months absent from this map fall back to JuniorFeeDefault. +var JuniorFeeMonthlyRate = map[string]int{ + "2025-09": 250, + "2026-03": 250, +} + +// Expected is the result of a junior fee calculation. +// When Unknown is true the fee requires manual review (Python returns "?"); +// in that case Value is meaningless — always check Unknown first. +type Expected struct { + Value int + Unknown bool +} + +// CalculateJuniorFee returns the junior fee for attendanceCount practices in +// the given monthKey (format "YYYY-MM"). +// +// 0 practices → Expected{Value: 0} +// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?") +// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault} +func CalculateJuniorFee(attendanceCount int, monthKey string) Expected { + if attendanceCount == 0 { + return Expected{Value: 0} + } + if attendanceCount == 1 { + return Expected{Unknown: true} + } + if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok { + return Expected{Value: rate} + } + return Expected{Value: JuniorFeeDefault} +} diff --git a/go/internal/domain/fees/junior_test.go b/go/internal/domain/fees/junior_test.go new file mode 100644 index 0000000..a3525ec --- /dev/null +++ b/go/internal/domain/fees/junior_test.go @@ -0,0 +1,37 @@ +package fees + +import "testing" + +func TestCalculateJuniorFee(t *testing.T) { + t.Parallel() + + // All expected outputs verified against live Python implementation on 2026-05-06: + // PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])' + tests := []struct { + name string + count int + month string + want Expected + }{ + {"zero short-circuits", 0, "2026-05", Expected{Value: 0}}, + {"zero empty month", 0, "", Expected{Value: 0}}, + {"single practice sentinel", 1, "2026-05", Expected{Unknown: true}}, + {"single ignores monthKey", 1, "unknown", Expected{Unknown: true}}, + {"two practices default month", 2, "2026-05", Expected{Value: 500}}, + {"two practices reduced sept", 2, "2025-09", Expected{Value: 250}}, + {"two practices reduced march", 2, "2026-03", Expected{Value: 250}}, + {"high count same as two", 5, "2025-09", Expected{Value: 250}}, + {"unknown future month falls back", 2, "2027-01", Expected{Value: 500}}, + {"empty month falls back", 2, "", Expected{Value: 500}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := CalculateJuniorFee(tc.count, tc.month) + if got != tc.want { + t.Errorf("CalculateJuniorFee(%d, %q) = %+v, want %+v", tc.count, tc.month, got, tc.want) + } + }) + } +}