Compare commits

...
Author SHA1 Message Date
kacerrandClaude Opus 5.5 62c6dc2ca0 fix(sync): dashboard sync covers current year plus Nov-Dec of previous
/sync-bank now uses one shared default window in both implementations:
1 November of the previous year to 31 December of the current year.
Payments dated around New Year are picked up in January; duplicates
are still filtered by Sync ID.

Python used a hardcoded 2026 window and would have stopped syncing on
2027-01-01; Go used the current calendar year only.

Adds make sync-year [YEAR=N] with the same window. The CLI default
without flags (last 30 days) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:04:59 +02:00
kacerr 23633f8cb2 Merge pull request 'feat(fees): adult rates for 2026-07 to 2026-12, default 600' (#46) from feat/adult-fees-2026-h2 into main
Reviewed-on: #46
2026-10-08 11:53:55 +02:00
kacerrandClaude Opus 5.5 2d8aada8d6 feat(fees): adult rates for 2026-07 to 2026-12, default 600
- Default adult monthly rate 700 -> 600 CZK
- 2026-07 and 2026-08 set to 0; 2026-09 through 2026-12 added at 600
- Single-session fee is capped at the month's rate, so a 0-rate month
  is free for one session as well
- Mirrored in Go and legacy Python; new calculate_fee parity fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:53:18 +02:00
kacerr 14cdbeac21 Merge pull request 'fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs' (#45) from fix/remove-leaky-k8s-deploy-workflow into main
Reviewed-on: #45
2026-10-08 11:49:02 +02:00
19 changed files with 176 additions and 33 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## 2026-10-08 12:04 CEST — fix(sync): dashboard sync covers the current year plus Nov–Dec of the previous one
- `/sync-bank` now uses one shared default window in both implementations: 1 November of the previous year to 31 December of the current year. Payments dated around New Year are picked up in January; duplicates are still filtered by Sync ID.
- Python previously used a hardcoded `2026-01-01`..`2026-12-31` and would have stopped syncing on 2027-01-01; Go used the current calendar year only.
- Added `make sync-year [YEAR=N]` with the same window; `make sync-2026` is kept. The CLI default without flags (last 30 days) is unchanged.
- Key files: `go/internal/services/banksync/sync.go` (`DefaultWindow`), `go/cmd/fuj/main.go`, `legacy/scripts/sync_fio_to_sheets.py` (`default_sync_window`), `legacy/app.py`.
## 2026-10-08 11:53 CEST — feat(fees): adult rates for 2026-07 through 2026-12, default 600
- Adult default monthly rate (months not in the table): 700 → 600 CZK.
- 2026-07 and 2026-08: 600 → 0 CZK; 2026-09 through 2026-12 added at 600 CZK (September and October were previously billed at the old 700 default).
- The single-session fee (200 CZK) is now capped at the month's rate, so a 0-rate month is free for one session too.
- Mirrored in `go/internal/domain/fees/fees.go` and `legacy/scripts/attendance.py`; three new parity fixtures for `calculate_fee`.
## 2026-10-08 11:48 CEST — fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs ## 2026-10-08 11:48 CEST — fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs
- Deleted `.gitea/workflows/kubernetes-deploy.yaml`. It ran on every push to every branch, deployed nothing, and echoed the Vault login response, the Kanidm API token response and the OIDC token response to the job log. - Deleted `.gitea/workflows/kubernetes-deploy.yaml`. It ran on every push to every branch, deployed nothing, and echoed the Vault login response, the Kanidm API token response and the OIDC token response to the job log.
+3 -3
View File
@@ -64,7 +64,7 @@ make web-debug # Same with FLASK_DEBUG=1
make test # Run all tests (unittest discover) make test # Run all tests (unittest discover)
make test-v # Tests with verbose output make test-v # Tests with verbose output
make fees # Print fee report from attendance sheet make fees # Print fee report from attendance sheet
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets make sync-year # Sync Fio transactions for the current year plus Nov-Dec of the previous one (YEAR=N to override)
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
make reconcile # Print balance report from Google Sheets data make reconcile # Print balance report from Google Sheets data
make image # Build the legacy Python Docker image make image # Build the legacy Python Docker image
@@ -126,13 +126,13 @@ Fio Bank API ──► bank sync ──► Google Sheets (payments
### Member tiers ### Member tiers
Tiers are set in column B of the attendance sheet: Tiers are set in column B of the attendance sheet:
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1) - `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 600 CZK for 2+ sessions; 200 CZK for exactly 1, capped at the month's rate)
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet - `J` — Junior attending adult practices; their attendance is merged with the junior sheet
- `X` — Excluded from junior fee calculation (coaches, etc.) - `X` — Excluded from junior fee calculation (coaches, etc.)
### Fee calculation ### Fee calculation
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK) - 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), 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))`. - 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))`.
+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs .PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 sync-year test test-v docs
# Legacy Python implementation lives in legacy/ (Go in go/ is primary). # Legacy Python implementation lives in legacy/ (Go in go/ is primary).
LEGACY := legacy LEGACY := legacy
@@ -39,6 +39,7 @@ help:
@echo " make sync - Sync Fio transactions to Google Sheets" @echo " make sync - Sync Fio transactions to Google Sheets"
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)" @echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026" @echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
@echo " make sync-year [YEAR=N] - Sync Fio transactions for YEAR plus Nov-Dec of the year before (default: current year)"
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)" @echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet" @echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
@echo " make reconcile - Show balance report using Google Sheets data" @echo " make reconcile - Show balance report using Google Sheets data"
@@ -130,6 +131,10 @@ sync-2025: $(PYTHON)
sync-2026: $(PYTHON) sync-2026: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
YEAR ?= $(shell date +%Y)
sync-year: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from $(shell expr $(YEAR) - 1)-11-01 --to $(YEAR)-12-31 --sort-by-date
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30) sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
@@ -42,13 +42,17 @@ before new feature work starts. Companion to the Go rewrite tracker,
- After deletion the repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and - After deletion the repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and
`K8S_CA_CERT` are unused and can be removed. Old job logs still contain `K8S_CA_CERT` are unused and can be removed. Old job logs still contain
the leaked tokens until they are deleted or expire. the leaked tokens until they are deleted or expire.
- [ ] **T2** Confirm the fee rates for 2026-09 onward. - [x] **T2** Confirm the fee rates for 2026-09 onward. Done 2026-10-08:
adult default 600, 2026-07/08 at 0, 2026-09 to 2026-12 at 600. Junior
rates were not changed.
- `ADULT_FEE_MONTHLY_RATE` ends at 2026-08 in `scripts/attendance.py` and - `ADULT_FEE_MONTHLY_RATE` ends at 2026-08 in `scripts/attendance.py` and
`go/internal/domain/fees/fees.go`. `go/internal/domain/fees/fees.go`.
- September and October 2026 are billed at the defaults: 700 CZK adults, - September and October 2026 are billed at the defaults: 700 CZK adults,
500 CZK juniors. 500 CZK juniors.
- If those are not the intended rates, add the months in both files. - If those are not the intended rates, add the months in both files.
- [ ] **T3** Remove the hardcoded 2026 window from Python `/sync-bank` - [x] **T3** Done 2026-10-08, extended: both dashboards now sync the
current year plus November–December of the previous one. Original task:
remove the hardcoded 2026 window from Python `/sync-bank`
(`app.py`, the `sync_to_sheets` call with `2026-01-01` / `2026-12-31`). (`app.py`, the `sync_to_sheets` call with `2026-01-01` / `2026-12-31`).
- It stops picking up transactions on 2027-01-01. Go already uses the - It stops picking up transactions on 2027-01-01. Go already uses the
current year. current year.
+1 -3
View File
@@ -92,9 +92,7 @@ func serverCmd(args []string) {
actions := web.ActionHandlers{ actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error { BankSync: func(ctx context.Context, out io.Writer) error {
yr := time.Now().Year() from, to := banksync.DefaultWindow(time.Now())
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
fmt.Fprintln(out, "=== Sync Fio Transactions ===") fmt.Fprintln(out, "=== Sync Fio Transactions ===")
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli, n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
+10 -7
View File
@@ -2,7 +2,7 @@
package fees package fees
const ( const (
AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate AdultFeeDefault = 600 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
AdultFeeSingle = 200 // CZK for exactly 1 practice AdultFeeSingle = 200 // CZK for exactly 1 practice
) )
@@ -11,24 +11,27 @@ const (
var AdultFeeMonthlyRate = map[string]int{ var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750, "2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
"2026-01": 750, "2026-02": 750, "2026-03": 350, "2026-01": 750, "2026-02": 750, "2026-03": 350,
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 600, "2026-08": 600, "2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 0, "2026-08": 0,
"2026-09": 600, "2026-10": 600, "2026-11": 600, "2026-12": 600,
} }
// CalculateFee returns the adult fee in CZK for attendanceCount practices in // CalculateFee returns the adult fee in CZK for attendanceCount practices in
// the given monthKey (format "YYYY-MM"). // the given monthKey (format "YYYY-MM").
// //
// 0 practices → 0 // 0 practices → 0
// 1 practice → AdultFeeSingle (200) // 1 practice → AdultFeeSingle (200), capped at the month's rate so a
// 0-rate month is free for everyone
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault // 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
func CalculateFee(attendanceCount int, monthKey string) int { func CalculateFee(attendanceCount int, monthKey string) int {
if attendanceCount == 0 { if attendanceCount == 0 {
return 0 return 0
} }
rate, ok := AdultFeeMonthlyRate[monthKey]
if !ok {
rate = AdultFeeDefault
}
if attendanceCount == 1 { if attendanceCount == 1 {
return AdultFeeSingle return min(AdultFeeSingle, rate)
} }
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
return rate return rate
} }
return AdultFeeDefault
}
+4 -2
View File
@@ -26,9 +26,11 @@ func TestCalculateFee(t *testing.T) {
// Zero attendance always returns 0. // Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", 0}, {"zero short-circuits", 0, "2026-05", 0},
{"zero empty month", 0, "", 0}, {"zero empty month", 0, "", 0},
// Single practice returns AdultFeeSingle regardless of month. // Single practice returns AdultFeeSingle, capped at the month's rate.
{"single practice", 1, "2026-05", AdultFeeSingle}, {"single practice", 1, "2026-05", AdultFeeSingle},
{"single ignores monthKey", 1, "unknown", AdultFeeSingle}, {"single unknown month", 1, "unknown", AdultFeeSingle},
{"single in free month is free", 1, "2026-07", 0},
{"two in free month is free", 2, "2026-08", 0},
// Two+ practices for a configured month: must use the map value, not the default. // Two+ practices for a configured month: must use the map value, not the default.
// Expected values are read from AdultFeeMonthlyRate so this test stays correct // Expected values are read from AdultFeeMonthlyRate so this test stays correct
// when rates are updated — the assertion verifies dispatch logic, not rate values. // when rates are updated — the assertion verifies dispatch logic, not rate values.
+12
View File
@@ -36,6 +36,18 @@ type SyncOpts struct {
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
} }
// DefaultWindow is the window used by the dashboard sync: the whole current
// calendar year plus the last two months of the previous one, so payments
// dated around New Year are still picked up in January. Re-fetching is safe
// because rows are deduplicated by Sync ID.
// Mirrors default_sync_window in legacy/scripts/sync_fio_to_sheets.py.
func DefaultWindow(now time.Time) (from, to time.Time) {
yr := now.Year()
from = time.Date(yr-1, time.November, 1, 0, 0, 0, 0, time.UTC)
to = time.Date(yr, time.December, 31, 23, 59, 59, 0, time.UTC)
return from, to
}
// SyncToSheets fetches Fio transactions from all provided clients and appends // SyncToSheets fetches Fio transactions from all provided clients and appends
// new ones to the payments sheet. Returns the number of rows appended. // new ones to the payments sheet. Returns the number of rows appended.
// Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets. // Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
@@ -193,3 +193,23 @@ func syncIDFor(tx fio.Transaction) string {
Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID, Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID,
}) })
} }
func TestDefaultWindow(t *testing.T) {
cases := []struct {
now time.Time
from, to string
}{
{time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC), "2025-11-01", "2026-12-31"},
{time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
{time.Date(2027, 12, 31, 23, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
}
for _, tc := range cases {
from, to := DefaultWindow(tc.now)
if got := from.Format("2006-01-02"); got != tc.from {
t.Errorf("DefaultWindow(%s) from: want %s, got %s", tc.now.Format("2006-01-02"), tc.from, got)
}
if got := to.Format("2006-01-02"); got != tc.to {
t.Errorf("DefaultWindow(%s) to: want %s, got %s", tc.now.Format("2006-01-02"), tc.to, got)
}
}
}
@@ -0,0 +1,12 @@
{
"case": "one_session_free_july",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 1,
"month_key": "2026-07"
},
"output": {
"fee": 0
}
}
@@ -1,12 +1,12 @@
{ {
"case": "two_sessions_default_fallback", "case": "two_sessions_default_fallback",
"func": "scripts.attendance.calculate_fee", "func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06", "captured_at": "2026-10-08",
"input": { "input": {
"attendance_count": 2, "attendance_count": 2,
"month_key": "2099-01" "month_key": "2099-01"
}, },
"output": { "output": {
"fee": 700 "fee": 600
} }
} }
@@ -0,0 +1,12 @@
{
"case": "two_sessions_free_july",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2026-07"
},
"output": {
"fee": 0
}
}
@@ -0,0 +1,12 @@
{
"case": "two_sessions_october",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2026-10"
},
"output": {
"fee": 600
}
}
+6 -5
View File
@@ -30,7 +30,7 @@ from views import (
adapt_junior_members, adapt_junior_members,
) )
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
from sync_fio_to_sheets import sync_to_sheets from sync_fio_to_sheets import sync_to_sheets, default_sync_window
from infer_payments import infer_payments from infer_payments import infer_payments
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
@@ -128,13 +128,14 @@ def sync_bank():
success = True success = True
try: try:
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
# sync_to_sheets: equivalent of make sync-2026 # sync_to_sheets: equivalent of make sync-year (same default window as Go)
output.write("=== Syncing Fio transactions (2026) ===\n") date_from, date_to = default_sync_window()
output.write(f"=== Syncing Fio transactions ({date_from} to {date_to}) ===\n")
sync_to_sheets( sync_to_sheets(
spreadsheet_id=PAYMENTS_SHEET_ID, spreadsheet_id=PAYMENTS_SHEET_ID,
credentials_path=CREDENTIALS_PATH, credentials_path=CREDENTIALS_PATH,
date_from_str="2026-01-01", date_from_str=date_from,
date_to_str="2026-12-31", date_to_str=date_to,
sort_by_date=True, sort_by_date=True,
) )
output.write("\n=== Inferring payment details ===\n") output.write("\n=== Inferring payment details ===\n")
+9
View File
@@ -115,6 +115,15 @@ SEEDS[("calculate_fee", "two_sessions_reduced_march")] = {
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = { SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
"attendance_count": 2, "month_key": "2099-01" "attendance_count": 2, "month_key": "2099-01"
} }
SEEDS[("calculate_fee", "two_sessions_free_july")] = {
"attendance_count": 2, "month_key": "2026-07"
}
SEEDS[("calculate_fee", "one_session_free_july")] = {
"attendance_count": 1, "month_key": "2026-07"
}
SEEDS[("calculate_fee", "two_sessions_october")] = {
"attendance_count": 2, "month_key": "2026-10"
}
# --- calculate_junior_fee --- # --- calculate_junior_fee ---
+11 -6
View File
@@ -10,7 +10,7 @@ from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0" EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}" JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
ADULT_FEE_DEFAULT = 700 # CZK, for 2+ practices in a month ADULT_FEE_DEFAULT = 600 # CZK, for 2+ practices in a month
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
ADULT_FEE_MONTHLY_RATE = { ADULT_FEE_MONTHLY_RATE = {
"2025-09": 750, "2025-09": 750,
@@ -23,8 +23,12 @@ ADULT_FEE_MONTHLY_RATE = {
"2026-04": 700, "2026-04": 700,
"2026-05": 450, "2026-05": 450,
"2026-06": 600, "2026-06": 600,
"2026-07": 600, "2026-07": 0,
"2026-08": 600, "2026-08": 0,
"2026-09": 600,
"2026-10": 600,
"2026-11": 600,
"2026-12": 600,
} }
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
@@ -91,12 +95,13 @@ def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, s
def calculate_fee(attendance_count: int, month_key: str) -> int: def calculate_fee(attendance_count: int, month_key: str) -> int:
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750).""" """Apply fee rules: 0 → 0, 1 → 200 (capped at the month's rate), 2+ → configured rate (default 600)."""
if attendance_count == 0: if attendance_count == 0:
return 0 return 0
rate = ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
if attendance_count == 1: if attendance_count == 1:
return ADULT_FEE_SINGLE return min(ADULT_FEE_SINGLE, rate)
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT) return rate
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int: def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
+12
View File
@@ -59,6 +59,18 @@ def get_sheets_service(credentials_path: str):
return _SHEETS_SERVICE return _SHEETS_SERVICE
def default_sync_window(today=None) -> tuple[str, str]:
"""Window used by the dashboard sync, as (from, to) in YYYY-MM-DD.
The whole current calendar year plus the last two months of the previous
one, so payments dated around New Year are still picked up in January.
Re-fetching is safe because rows are deduplicated by Sync ID.
Mirrors DefaultWindow in go/internal/services/banksync/sync.go.
"""
year = (today or datetime.now()).year
return f"{year - 1}-11-01", f"{year}-12-31"
def as_text_cell(value) -> str: def as_text_cell(value) -> str:
"""Mark a value to be stored as text when written with USER_ENTERED. """Mark a value to be stored as text when written with USER_ENTERED.
+14
View File
@@ -23,6 +23,20 @@ class TestWebApp(unittest.TestCase):
app.config['TESTING'] = True app.config['TESTING'] = True
self.client = app.test_client() self.client = app.test_client()
@patch('app.flush_cache', return_value=0)
@patch('app.infer_payments')
@patch('app.sync_to_sheets')
def test_sync_bank_uses_default_window(self, mock_sync, mock_infer, mock_flush):
"""/sync-bank syncs the current year plus Nov-Dec of the previous one, not a hardcoded year."""
from datetime import datetime
year = datetime.now().year
response = self.client.get('/sync-bank')
self.assertEqual(response.status_code, 200)
kwargs = mock_sync.call_args.kwargs
self.assertEqual(kwargs['date_from_str'], f"{year - 1}-11-01")
self.assertEqual(kwargs['date_to_str'], f"{year}-12-31")
mock_infer.assert_called_once()
def test_index_page(self): def test_index_page(self):
"""Test that / returns the refresh meta tag""" """Test that / returns the refresh meta tag"""
response = self.client.get('/') response = self.client.get('/')
+9 -1
View File
@@ -1,9 +1,17 @@
import contextlib import contextlib
import io import io
import unittest import unittest
from datetime import datetime
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from scripts.sync_fio_to_sheets import COLUMN_LABELS, as_text_cell, sync_to_sheets from scripts.sync_fio_to_sheets import COLUMN_LABELS, as_text_cell, default_sync_window, sync_to_sheets
class TestDefaultSyncWindow(unittest.TestCase):
def test_current_year_plus_last_two_months_of_previous(self):
self.assertEqual(default_sync_window(datetime(2026, 10, 8)), ("2025-11-01", "2026-12-31"))
self.assertEqual(default_sync_window(datetime(2027, 1, 1)), ("2026-11-01", "2027-12-31"))
self.assertEqual(default_sync_window(datetime(2027, 12, 31)), ("2026-11-01", "2027-12-31"))
class TestAsTextCell(unittest.TestCase): class TestAsTextCell(unittest.TestCase):