# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Status Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard. - **Go is the primary implementation** (`go/`). It is the version that is built, deployed and developed first. - **Python is legacy** (`legacy/`). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on. ## Dual implementation rule Until the Python version is retired, **every new feature and every behaviour change must be implemented in both Go and Python**: 1. Implement and test in Go first (`go/`). 2. Mirror the change in the legacy Python code (`legacy/`), including its tests. 3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (`/api/adults`, `/api/juniors`, `/api/payments`) must stay identical. The HTML frontends are allowed to diverge. 4. If a pure function's behaviour changes, add or update seeds in `legacy/scripts/_fixture_seeds.py`, run `make capture-fixtures`, and make sure `make go-test-all` passes. With both servers running, `make parity` diffs the live JSON APIs. Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change). ### Deliberately Go-only features These exist only in the Go app on purpose. Do not port them to the legacy Python app, which is being phased out: - **Scheduled bank sync** — the Go server runs sync → infer → flush-cache by itself at the times in `SYNC_SCHEDULE`. Python's multi-worker gunicorn setup would need a cross-process lock for this, which is not worth building in code that is going away. In Python the sync still runs only from `/sync-bank` or the CLI. ## Key Constraints - **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one. - **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 Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`. ```bash make go-build # builds bin/fuj ``` Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`. ```bash make venv # uv sync --project legacy → legacy/.venv ``` The Makefile sets `PYTHONPATH` (`legacy/scripts:legacy`) and `CREDENTIALS_PATH` (`.secret/fuj-management-bot-credentials.json` at the repo root) for the Python targets. Set both yourself when running Python scripts directly. ## Commands Go (primary): ```bash make web-go # Build and start dashboard at http://localhost:8080 make go-build # Build bin/fuj make go-test # Unit tests (go test -race ./...) make go-parity # Parity tests against the captured Python fixtures make go-test-all # Both of the above make go-lint # golangci-lint make go-run ARGS="fees" # Run a subcommand: server, fees, reconcile, sync, infer, version make go-sync-debug [DAYS=N] # Dry-run bank sync with debug logs ``` Python (legacy): ```bash make web-py # Start dashboard at http://localhost:5001 (make web is an alias) make web-debug # Same with FLASK_DEBUG=1 make test # Run all tests (unittest discover) make test-v # Tests with verbose output make fees # Print fee report from attendance sheet 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 reconcile # Print balance report from Google Sheets data make image # Build the legacy Python Docker image ``` Cross-implementation: ```bash make capture-fixtures # Regenerate go/tests/fixtures from the Python implementation make parity # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running ``` Run a single test: ```bash cd go && go test ./internal/domain/reconcile -run TestName cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route ``` ## Architecture ### Data flow ``` Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates Google Sheets (payments) ──► payments loader ──┘ Fio Bank API ──► bank sync ──► Google Sheets (payments) ``` ### Repository layout - `go/` — primary implementation (single binary `fuj`, templates and static assets embedded). - `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`). - `docs/` — documentation; `docs/plans/` holds plans and task lists. - `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR. ### Key modules — Go (primary) - `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`. - `go/cmd/parity` — diffs the JSON API of the Go and Python servers. - `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`, `SYNC_SCHEDULE`, `SYNC_TZ`). - `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs). - `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests. - `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference; `Runner` serialises manual and scheduled runs). - `go/internal/scheduler` — runs a job at fixed times of day; used by `fuj server` for the scheduled bank sync. - `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded. - `go/tests` — parity tests and the scrubbed fixture corpus captured from Python. ### Key modules — Python (legacy) - `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*` - `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts. - `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab. - `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`. - `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet. - `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics. - `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API. - `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`). - `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go. ### Member tiers Tiers are set in column B of the attendance sheet: - `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 - `X` — Excluded from junior fee calculation (coaches, etc.) ### 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; 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 `AdultMergedMonths` / `JuniorMergedMonths` in `go/internal/services/membership/sources.go` (`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `legacy/scripts/attendance.py`) map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months. ### Caching Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST). ### Bank sync - Manual: open `/sync-bank` (both apps) or run `fuj sync` + `fuj infer` / `make sync-year` + `make infer`. - Scheduled (Go server only): at each time in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`; `off` disables) read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup. Manual and scheduled runs share one lock and never overlap. The page footer shows the last run since start and the next one. - Both use the same window: 1 November of the previous year to 31 December of the current year; rows are de-duplicated by Sync ID. - The schedule runs per process: keep the deployment at one replica, and note that a local `make web-go` with real credentials also syncs at the scheduled time unless `SYNC_SCHEDULE=off`. ### Payments sheet columns `Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID` `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). ## Branching & merge requests The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/fuj-management`). For **features**, do not commit to `main` directly. Use a branch + merge request flow: 1. **Create a branch off `main`** before starting work: - `feat/` for features (e.g. `feat/qr-code-overlay`) - `fix/` for bug-fix branches the user explicitly asks for - `` is short kebab-case 2. **Commit on the branch** following the existing commit conventions (Co-Authored-By trailer, etc.). 3. **Push the branch** to `origin` with `-u` so it tracks. 4. **Open the MR with `tea`** rather than printing a compare URL: ```bash tea pr create \ --title "" \ --description "" \ --base main \ --head ``` `tea` is already authenticated against the Gitea instance; just run it. Print the resulting PR URL for the user. If `tea` is unavailable for some reason, fall back to printing the compare URL (`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...`) and let the user open the MR manually. 5. **Do not merge or delete the branch** from the CLI — neither via `tea`, `gh`, nor `git push --delete`. The user does that in Gitea. **Exceptions — when committing straight to `main` is fine:** - Small bug fixes / hotfixes the user describes as such. - Typo / comment / formatting tweaks. - Edits the user explicitly says to push to `main`. When uncertain whether something is a feature or a small fix, ask before committing. ## Git Commits When making git commits, always append yourself as co-author trailer to the end of the commit message to indicate AI assistance ## Changelog Maintain a running changelog in `CHANGELOG.md` at the repo root. After every significant change, fix, or update — once the user confirms it works — append a new entry **at the top** of the file in this format: ```markdown ## YYYY-MM-DD HH:MM TZ — short title - One-line summary of what changed and why. - Key files touched (optional, only if useful for traceability). ``` Get the timestamp with `date "+%Y-%m-%d %H:%M %Z"`. Skip trivial edits (typos, formatting, comment tweaks); only log changes a future reader would care about. ## Plans When Claude Code's plan mode is used, save the plan file inside the repo at `docs/plans/YYYY-MM-DD-HHMM-.md` instead of the default `~/.claude/plans/` location. Get the timestamp with `date "+%Y-%m-%d-%H%M"` (matches the changelog convention). The `` should be a short kebab-case summary of the plan's topic. Create the `docs/plans/` directory on first use. Plan files are committed to the repo so other contributors can review historical decisions.