refactor: move Python implementation to legacy/, make Go primary #43

Merged
kacerr merged 1 commits from feat/python-to-legacy into main 2026-10-08 11:45:12 +02:00
82 changed files with 223 additions and 144 deletions
Showing only changes of commit 022facc957 - Show all commits
+2 -2
View File
@@ -31,11 +31,11 @@ jobs:
TAG=${{ inputs.tag }} TAG=${{ inputs.tag }}
fi fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -f build/Dockerfile \ docker build -f legacy/build/Dockerfile \
--build-arg GIT_TAG=$TAG \ --build-arg GIT_TAG=$TAG \
--build-arg GIT_COMMIT=${{ github.sha }} \ --build-arg GIT_COMMIT=${{ github.sha }} \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-t $IMAGE . -t $IMAGE legacy/
docker push $IMAGE docker push $IMAGE
build-go: build-go:
+5 -3
View File
@@ -2,14 +2,16 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python Debugger: Flask", "name": "Python Debugger: Flask (legacy)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "flask", "module": "flask",
"python": "${workspaceFolder}/.venv/bin/python", "python": "${workspaceFolder}/legacy/.venv/bin/python",
"cwd": "${workspaceFolder}/legacy",
"env": { "env": {
"FLASK_APP": "app.py", "FLASK_APP": "app.py",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1",
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
}, },
"args": [ "args": [
"run", "run",
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-10-08 11:33 CEST — refactor: move Python implementation to legacy/, Go is primary
- Go (`go/`) is now the primary implementation. The original Python version moved to `legacy/` (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `uv.lock`, `build/`) and is considered legacy; new features are still built in both.
- `CLAUDE.md` rewritten for the Go-first layout, with a "Dual implementation rule" section.
- Makefile Python targets keep their names and point at `legacy/`; virtualenv is `legacy/.venv`, Python cache is `legacy/tmp/`; `CREDENTIALS_PATH` is exported pointing at the root `.secret/` file.
- CI builds the Python image with `legacy/` as context; the image no longer contains the Makefile. `legacy/.dockerignore` now takes effect and excludes nested `__pycache__`.
- Go comments, fixture README, capture script and VS Code launch config updated to the new paths.
## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow ## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow
- Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag. - Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag.
+86 -24
View File
@@ -4,28 +4,62 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## Project Status
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard. 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).
## Key Constraints ## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one. - **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 `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules. - **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 ## Development Setup
This project uses `uv` for dependency management. Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
```bash ```bash
uv venv && uv sync make go-build # builds bin/fuj
source .venv/bin/activate
``` ```
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically). 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 ## Commands
Go (primary):
```bash ```bash
make web # Start dashboard at http://localhost:5001 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 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
@@ -33,12 +67,20 @@ make fees # Print fee report from attendance sheet
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
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 Docker image 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: Run a single test:
```bash ```bash
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route 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 ## Architecture
@@ -46,25 +88,45 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
### Data flow ### Data flow
``` ```
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/ Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
Google Sheets (payments) ──► match_payments.py ──┘ Google Sheets (payments) ──► payments loader ──┘
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments) Fio Bank API ──► bank sync ──► Google Sheets (payments)
``` ```
### Key modules ### Repository layout
- `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache` - `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
- `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/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
- `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab. - `docs/` — documentation; `docs/plans/` holds plans and task lists.
- `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 `tmp/`. - `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
- `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics. ### Key modules — Go (primary)
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
- `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`).
- `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).
- `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 ### 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 `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 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `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.)
@@ -76,17 +138,17 @@ Tiers are set in column B of the attendance sheet:
### Merged months ### Merged months
`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `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. `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 ### Caching
`get_cached_data()` in `app.py` checks the Drive API `modifiedTime` before each request and serves a JSON file from `tmp/` when the sheet hasn't changed. Cache is warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST) or `flush_cache()`. 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).
### Payments sheet columns ### Payments sheet columns
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID` `Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
`Person` and `Purpose` are written by `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. `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.
### QR codes ### QR codes
+32 -28
View File
@@ -1,26 +1,30 @@
.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 test test-v docs
export PYTHONPATH := scripts:$(PYTHONPATH) # Legacy Python implementation lives in legacy/ (Go in go/ is primary).
VENV := .venv LEGACY := legacy
PYTHON := $(VENV)/bin/python3 export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
CREDENTIALS := .secret/fuj-management-bot-credentials.json VENV := $(LEGACY)/.venv
PYTHON := $(CURDIR)/$(VENV)/bin/python3
CREDENTIALS := $(CURDIR)/.secret/fuj-management-bot-credentials.json
# Both implementations read CREDENTIALS_PATH; the Python default would resolve under legacy/.
export CREDENTIALS_PATH ?= $(CREDENTIALS)
GO_SRC := go GO_SRC := go
GO_BIN := bin/fuj GO_BIN := bin/fuj
$(PYTHON): .venv/.last_sync $(PYTHON): $(VENV)/.last_sync
.venv/.last_sync: pyproject.toml $(VENV)/.last_sync: $(LEGACY)/pyproject.toml
uv sync uv sync --project $(LEGACY)
touch .venv/.last_sync touch $(VENV)/.last_sync
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " make fees - Calculate monthly fees from the attendance sheet" @echo " make fees - Calculate monthly fees from the attendance sheet"
@echo " make match - Match Fio bank payments against expected attendance fees" @echo " make match - Match Fio bank payments against expected attendance fees"
@echo " make web - Start Python dashboard (alias for web-py, until M8)" @echo " make web - Start legacy Python dashboard (alias for web-py)"
@echo " make web-py - Start Python dashboard on :5001" @echo " make web-py - Start legacy Python dashboard on :5001"
@echo " make web-go - Build and start Go dashboard on :8080" @echo " make web-go - Build and start Go dashboard on :8080 (primary)"
@echo " make web-debug - Start Python dashboard in debug mode" @echo " make web-debug - Start Python dashboard in debug mode"
@echo " make go-build - Build Go binary to bin/fuj" @echo " make go-build - Build Go binary to bin/fuj"
@echo " make go-test - Run Go unit tests" @echo " make go-test - Run Go unit tests"
@@ -44,21 +48,21 @@ help:
@echo " make docs - Serve documentation in a browser" @echo " make docs - Serve documentation in a browser"
venv: venv:
uv sync uv sync --project $(LEGACY)
fees: $(PYTHON) fees: $(PYTHON)
$(PYTHON) scripts/calculate_fees.py $(PYTHON) $(LEGACY)/scripts/calculate_fees.py
match: $(PYTHON) match: $(PYTHON)
$(PYTHON) scripts/match_payments.py $(PYTHON) $(LEGACY)/scripts/match_payments.py
web: web-py web: web-py
web-py: $(PYTHON) web-py: $(PYTHON)
$(PYTHON) app.py $(PYTHON) $(LEGACY)/app.py
web-debug: $(PYTHON) web-debug: $(PYTHON)
FLASK_DEBUG=1 $(PYTHON) app.py FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
go-build: go-build:
cd $(GO_SRC) && go build -trimpath \ cd $(GO_SRC) && go build -trimpath \
@@ -82,10 +86,10 @@ capture-fixtures: $(PYTHON)
infer_transaction_details format_date reconcile; do \ infer_transaction_details format_date reconcile; do \
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \ dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
mkdir -p "$$dir"; \ mkdir -p "$$dir"; \
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \ $(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \
| while IFS= read -r line; do \ | while IFS= read -r line; do \
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \ case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \ echo "$$line" | $(PYTHON) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
done; \ done; \
echo " $$func done"; \ echo " $$func done"; \
done done
@@ -112,34 +116,34 @@ image:
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \ --build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \ --build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \ --build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-f build/Dockerfile . -f $(LEGACY)/build/Dockerfile $(LEGACY)
run: run:
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
sync: $(PYTHON) sync: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS)
sync-2025: $(PYTHON) sync-2025: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2025-10-01 --to 2025-12-31 --sort-by-date
sync-2026: $(PYTHON) sync-2026: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --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
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) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
infer: $(PYTHON) infer: $(PYTHON)
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS) $(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
reconcile: ## Match payments against attendance reconcile: $(PYTHON) ## Match payments against attendance
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS) $(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
test: $(PYTHON) ## Run web application tests test: $(PYTHON) ## Run web application tests
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests cd $(LEGACY) && $(PYTHON) -m unittest discover tests
test-v: $(PYTHON) ## Run tests with verbose output test-v: $(PYTHON) ## Run tests with verbose output
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests
docs: ## Serve documentation locally docs: ## Serve documentation locally
@echo "Starting documentation server at http://localhost:8000" @echo "Starting documentation server at http://localhost:8000"
+2
View File
@@ -4,6 +4,8 @@ Welcome to the documentation for the FUJ Management application.
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club. This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
> **Go is the primary implementation** (`go/`). The project was originally developed in Python; that version now lives in `legacy/` and is considered legacy. New features are still built in both. Most guides below predate this change and describe the Python version with its old paths (`app.py`, `scripts/` are now `legacy/app.py`, `legacy/scripts/`).
## Navigation ## Navigation
Use the sidebar to explore the documentation: Use the sidebar to explore the documentation:
+1 -1
View File
@@ -21,7 +21,7 @@ var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
// defaultAllowlist holds dotted key paths to strip before diffing. // defaultAllowlist holds dotted key paths to strip before diffing.
// render_time.total is forward-compatible insurance: today it lives in the Jinja // render_time.total is forward-compatible insurance: today it lives in the Jinja
// template context only (app.py inject_render_time) and is logged via // template context only (legacy/app.py inject_render_time) and is logged via
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either // middleware/timer.go on the Go side, so it isn't in any JSON response. If either
// side ever surfaces it under a render_time envelope, the scrubber handles it. // side ever surfaces it under a render_time envelope, the scrubber handles it.
var defaultAllowlist = []string{"render_time.total"} var defaultAllowlist = []string{"render_time.total"}
+2 -2
View File
@@ -42,7 +42,7 @@ const (
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd) JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
) )
// CacheSheetMap mirrors scripts/config.py CACHE_SHEET_MAP. // CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP.
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it. // Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file. // Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
var CacheSheetMap = map[string]string{ var CacheSheetMap = map[string]string{
@@ -53,7 +53,7 @@ var CacheSheetMap = map[string]string{
} }
// Config holds all runtime configuration loaded from environment variables. // Config holds all runtime configuration loaded from environment variables.
// Mirrors scripts/config.py. // Mirrors legacy/scripts/config.py.
type Config struct { type Config struct {
CredentialsPath string CredentialsPath string
QRAccount string // IBAN of the primary account used for QR codes QRAccount string // IBAN of the primary account used for QR codes
@@ -9,7 +9,7 @@ func TestParseMonthReferences(t *testing.T) {
t.Parallel() t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-05: // All expected outputs verified against live Python implementation on 2026-05-05:
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))' // PYTHONPATH=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
tests := []struct { tests := []struct {
name string name string
input string input string
+2 -2
View File
@@ -1,4 +1,4 @@
// Package fees ports fee calculation from scripts/attendance.py. // Package fees ports fee calculation from legacy/scripts/attendance.py.
package fees package fees
const ( const (
@@ -6,7 +6,7 @@ const (
AdultFeeSingle = 200 // CZK for exactly 1 practice AdultFeeSingle = 200 // CZK for exactly 1 practice
) )
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py. // AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to AdultFeeDefault. // Months absent from this map fall back to AdultFeeDefault.
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,
+1 -1
View File
@@ -2,7 +2,7 @@ package fees
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py. // JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to JuniorFeeDefault. // Months absent from this map fall back to JuniorFeeDefault.
var JuniorFeeMonthlyRate = map[string]int{ var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250, "2025-09": 250,
+1 -1
View File
@@ -1,2 +1,2 @@
// Package matching ports name/member matching from scripts/match_payments.py. // Package matching ports name/member matching from legacy/scripts/match_payments.py.
package matching package matching
+1 -1
View File
@@ -14,7 +14,7 @@ var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value // a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error. // (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
// //
// Ports scripts/match_payments.py format_date. // Ports legacy/scripts/match_payments.py format_date.
func FormatDate(val any) string { func FormatDate(val any) string {
if val == nil { if val == nil {
return "" return ""
@@ -1,8 +1,8 @@
package matching package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -c ' // PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import format_date // from match_payments import format_date
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]: // for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
// print(repr(format_date(v))) // print(repr(format_date(v)))
+1 -1
View File
@@ -33,7 +33,7 @@ type InferredDetails struct {
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year; // defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests). // callers should pass time.Now().Year() or a fixed year for deterministic tests).
// //
// Ports scripts/match_payments.py infer_transaction_details. // Ports legacy/scripts/match_payments.py infer_transaction_details.
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails { func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID) searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
+2 -2
View File
@@ -1,8 +1,8 @@
package matching package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 << 'EOF' // PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
// from match_payments import infer_transaction_details // from match_payments import infer_transaction_details
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"] // MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
// cases = [ // cases = [
+1 -1
View File
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
// MatchMembers finds members mentioned in text and returns them with a // MatchMembers finds members mentioned in text and returns them with a
// confidence level of "auto" (reliable) or "review" (needs human verification). // confidence level of "auto" (reliable) or "review" (needs human verification).
// //
// Algorithm (ported verbatim from scripts/match_payments.py match_members): // Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
// 1. Exact short-circuit: if any member's full normalized name appears as whole // 1. Exact short-circuit: if any member's full normalized name appears as whole
// words in normalize(text), return ONLY those matches as auto. This prevents // words in normalize(text), return ONLY those matches as auto. This prevents
// nickname "tov" from matching inside surname "ottova". // nickname "tov" from matching inside surname "ottova".
@@ -1,9 +1,9 @@
package matching package matching
// Expected values verified against scripts/match_payments.py and // Expected values verified against legacy/scripts/match_payments.py and
// tests/test_match_members.py on 2026-05-06: // tests/test_match_members.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -c ' // PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import match_members // from match_payments import match_members
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"] // MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
// cases = [ // cases = [
+1 -1
View File
@@ -19,7 +19,7 @@ var (
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are // this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
// dropped. // dropped.
// //
// Ports scripts/match_payments.py _build_name_variants. // Ports legacy/scripts/match_payments.py _build_name_variants.
func BuildNameVariants(name string) []string { func BuildNameVariants(name string) []string {
var nickname string var nickname string
if m := nicknameRe.FindStringSubmatch(name); m != nil { if m := nicknameRe.FindStringSubmatch(name); m != nil {
@@ -1,8 +1,8 @@
package matching package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -c ' // PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import _build_name_variants // from match_payments import _build_name_variants
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]: // for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
// print(repr(n), "->", _build_name_variants(n)) // print(repr(n), "->", _build_name_variants(n))
+2 -2
View File
@@ -1,4 +1,4 @@
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py. // Package money ports Czech-locale currency parsing from legacy/scripts/infer_payments.py.
package money package money
import ( import (
@@ -11,7 +11,7 @@ import (
var ErrInvalidAmount = errors.New("money: invalid CZK amount") var ErrInvalidAmount = errors.New("money: invalid CZK amount")
// ParseCZK parses a Czech-locale amount string and returns the value in CZK // ParseCZK parses a Czech-locale amount string and returns the value in CZK
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount: // as a float64. Mirrors legacy/scripts/infer_payments.py parse_czk_amount:
// //
// - empty input → (0, nil) // - empty input → (0, nil)
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python) // - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
+1 -1
View File
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
t.Parallel() t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06: // All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from infer_payments import parse_czk_amount // from infer_payments import parse_czk_amount
// for v in [None, "", "0", "500", "500 Kč", "500 CZK", // for v in [None, "", "0", "500", "500 Kč", "500 CZK",
// "1 500", "1500.00", "1 500.00", // "1 500", "1500.00", "1 500.00",
+3 -3
View File
@@ -1,4 +1,4 @@
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py. // Package reconcile ports the three-phase payment reconciliation from legacy/scripts/match_payments.py.
package reconcile package reconcile
import ( import (
@@ -105,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key // canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
// used to resolve Person-column values that drift from canonical attendance-sheet names. // used to resolve Person-column values that drift from canonical attendance-sheet names.
// Ports scripts/match_payments.py canonical_member_key. // Ports legacy/scripts/match_payments.py canonical_member_key.
func canonicalMemberKey(name string) string { func canonicalMemberKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ") return strings.Join(strings.Fields(czech.Normalize(name)), " ")
} }
@@ -124,7 +124,7 @@ type monthExpected struct {
// defaultYear seeds czech.ParseMonthReferences in the inference fallback. // defaultYear seeds czech.ParseMonthReferences in the inference fallback.
// Pass time.Now().Year() in production; pass a fixed year in tests. // Pass time.Now().Year() in production; pass a fixed year in tests.
// //
// Ports scripts/match_payments.py reconcile. // Ports legacy/scripts/match_payments.py reconcile.
func Reconcile( func Reconcile(
members []Member, members []Member,
sortedMonths []string, sortedMonths []string,
@@ -1,8 +1,8 @@
package reconcile package reconcile
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v // PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
// //
// All Python test cases are ported below. Additional Go-only cases are marked with [Go]. // All Python test cases are ported below. Additional Go-only cases are marked with [Go].
+2 -2
View File
@@ -1,5 +1,5 @@
// Package synch ports the bank-sync deduplication helper from // Package synch ports the bank-sync deduplication helper from
// scripts/sync_fio_to_sheets.py. // legacy/scripts/sync_fio_to_sheets.py.
package synch package synch
import ( import (
@@ -30,7 +30,7 @@ type Transaction struct {
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used // "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
// as the dedup key in column K of the payments sheet. // as the dedup key in column K of the payments sheet.
// //
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id. // Byte-stable with legacy/scripts/sync_fio_to_sheets.py generate_sync_id.
func GenerateSyncID(tx Transaction) string { func GenerateSyncID(tx Transaction) string {
currency := tx.Currency currency := tx.Currency
if currency == "" { if currency == "" {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
// All expected digests verified against the live Python implementation on 2026-05-06: // All expected digests verified against the live Python implementation on 2026-05-06:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from sync_fio_to_sheets import generate_sync_id // from sync_fio_to_sheets import generate_sync_id
// cases = [ // cases = [
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"}, // {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
@@ -88,7 +88,7 @@ func TestGenerateSyncID(t *testing.T) {
// All expected strings verified against the live Python implementation on 2026-05-06: // All expected strings verified against the live Python implementation on 2026-05-06:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]: // for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
// print(repr(v), "->", repr(str(v))) // print(repr(v), "->", repr(str(v)))
// ' // '
+2 -2
View File
@@ -1,5 +1,5 @@
// Package cache implements a Drive-modifiedTime-gated JSON file cache, // Package cache implements a Drive-modifiedTime-gated JSON file cache,
// mirroring scripts/cache_utils.py. // mirroring legacy/scripts/cache_utils.py.
package cache package cache
import ( import (
@@ -26,7 +26,7 @@ type cacheFile struct {
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime. // FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
// //
// Two TTL knobs mirror scripts/cache_utils.py: // Two TTL knobs mirror legacy/scripts/cache_utils.py:
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely. // - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID. // - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
// //
+1 -1
View File
@@ -17,7 +17,7 @@ type httpDoer interface {
} }
// apiClient fetches transactions from the Fio REST API (JSON). // apiClient fetches transactions from the Fio REST API (JSON).
// Ports scripts/fio_utils.py fetch_transactions_api. // Ports legacy/scripts/fio_utils.py fetch_transactions_api.
type apiClient struct { type apiClient struct {
token string token string
hc httpDoer hc httpDoer
+1 -1
View File
@@ -15,7 +15,7 @@ import (
) )
// transparentClient fetches transactions from the Fio transparent account page (HTML). // transparentClient fetches transactions from the Fio transparent account page (HTML).
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent. // Ports legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
type transparentClient struct { type transparentClient struct {
accountNum string accountNum string
hc httpDoer hc httpDoer
+1 -1
View File
@@ -30,7 +30,7 @@ type sheetReadWriter interface {
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments // InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
// sheet using name and month matching against the member list. // sheet using name and month matching against the member list.
// Returns the number of rows updated (or that would be updated on dry-run). // Returns the number of rows updated (or that would be updated on dry-run).
// Ports scripts/infer_payments.py infer_payments. // Ports legacy/scripts/infer_payments.py infer_payments.
func InferPayments( func InferPayments(
ctx context.Context, ctx context.Context,
spreadsheetID string, spreadsheetID string,
+2 -2
View File
@@ -12,7 +12,7 @@ import (
) )
// columnLabels is the canonical header for the payments sheet. // columnLabels is the canonical header for the payments sheet.
// Mirrors COLUMN_LABELS in scripts/sync_fio_to_sheets.py. // Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py.
var columnLabels = []string{ var columnLabels = []string{
"Date", "Amount", "manual fix", "Person", "Purpose", "Date", "Amount", "manual fix", "Person", "Purpose",
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
@@ -37,7 +37,7 @@ type SyncOpts struct {
// 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 scripts/sync_fio_to_sheets.py sync_to_sheets. // Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
func SyncToSheets( func SyncToSheets(
ctx context.Context, ctx context.Context,
spreadsheetID string, spreadsheetID string,
@@ -9,11 +9,11 @@ import (
) )
// printFeesTable writes a fixed-width adult-fees table to w. // printFeesTable writes a fixed-width adult-fees table to w.
// Mirrors scripts/calculate_fees.py main(). // Mirrors legacy/scripts/calculate_fees.py main().
// //
// Verify with: // Verify with:
// //
// PYTHONPATH=scripts:. python scripts/calculate_fees.py // PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) { func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
type row struct { type row struct {
name string name string
@@ -1,8 +1,8 @@
package membership package membership
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06: // Golden strings verified against legacy/scripts/calculate_fees.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python scripts/calculate_fees.py // PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
// //
// (feed equivalent fixture data via attendance sheet or local CSV) // (feed equivalent fixture data via attendance sheet or local CSV)
@@ -10,11 +10,11 @@ import (
) )
// printReconcileReport writes the full balance report to w. // printReconcileReport writes the full balance report to w.
// Mirrors scripts/match_payments.py print_report(). // Mirrors legacy/scripts/match_payments.py print_report().
// //
// Verify with: // Verify with:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions // from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
// ...' // ...'
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) { func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
@@ -1,8 +1,8 @@
package membership package membership
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06: // Golden strings verified against legacy/scripts/match_payments.py print_report() on 2026-05-06:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report // from match_payments import print_report
// result = { // result = {
// "members": { // "members": {
+9 -9
View File
@@ -18,20 +18,20 @@ import (
"time" "time"
) )
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py) // Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
const ( const (
colName = 0 colName = 0
colTier = 1 colTier = 1
firstDateCol = 3 firstDateCol = 3
) )
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py. // AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py.
// Source month → target month (source attendance accumulated into target). // Source month → target month (source attendance accumulated into target).
var AdultMergedMonths = map[string]string{ var AdultMergedMonths = map[string]string{
"2025-09": "2025-10", "2025-09": "2025-10",
} }
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py. // JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in legacy/scripts/attendance.py.
var JuniorMergedMonths = map[string]string{ var JuniorMergedMonths = map[string]string{
"2025-12": "2026-01", "2025-12": "2026-01",
"2025-09": "2025-10", "2025-09": "2025-10",
@@ -127,11 +127,11 @@ func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.Excepti
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Attendance CSV parsing (ports scripts/attendance.py) // Attendance CSV parsing (ports legacy/scripts/attendance.py)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns. // parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m"). // Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m").
func parseDates(header []string) []struct { func parseDates(header []string) []struct {
col int col int
month string month string
@@ -200,7 +200,7 @@ func countTrue(row []string, cols []int) int {
// parseAdultRows converts raw CSV rows to []reconcile.Member. // parseAdultRows converts raw CSV rows to []reconcile.Member.
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream). // Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
// Ports scripts/attendance.py get_members_with_fees. // Ports legacy/scripts/attendance.py get_members_with_fees.
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) { func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
if len(rows) < 2 { if len(rows) < 2 {
return nil, nil, nil return nil, nil, nil
@@ -246,7 +246,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab // parseJuniorRows builds junior members by merging tier-J rows from the adult tab
// with the junior sheet, then calling CalculateJuniorFee. // with the junior sheet, then calling CalculateJuniorFee.
// Ports scripts/attendance.py get_junior_members_with_fees. // Ports legacy/scripts/attendance.py get_junior_members_with_fees.
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) { func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
if len(adultRows) < 2 || len(juniorRows) < 2 { if len(adultRows) < 2 || len(juniorRows) < 2 {
return nil, nil, nil return nil, nil, nil
@@ -362,7 +362,7 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data) // Payments sheet row parsing (ports legacy/scripts/match_payments.py fetch_sheet_data)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) { func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
@@ -466,7 +466,7 @@ func parseFloat(v any) float64 {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Exceptions tab parsing (ports scripts/match_payments.py fetch_exceptions) // Exceptions tab parsing (ports legacy/scripts/match_payments.py fetch_exceptions)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception { func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
type monthSums struct{ expected, paid int } type monthSums struct{ expected, paid int }
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output. // buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_adults_view_model. // Mirrors legacy/scripts/views.py:build_adults_view_model.
func buildAdultsResponse( func buildAdultsResponse(
members []domreconcile.Member, members []domreconcile.Member,
sortedMonths []string, sortedMonths []string,
+3 -3
View File
@@ -12,7 +12,7 @@ import (
// getMonthLabels builds display labels for sortedMonths, merging month names // getMonthLabels builds display labels for sortedMonths, merging month names
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target. // (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
// Mirrors scripts/views.py:get_month_labels. // Mirrors legacy/scripts/views.py:get_month_labels.
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string { func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
labels := make(map[string]string, len(sortedMonths)) labels := make(map[string]string, len(sortedMonths))
for _, m := range sortedMonths { for _, m := range sortedMonths {
@@ -72,14 +72,14 @@ func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`) var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalKey returns a normalized form of a person name used for deduplication. // canonicalKey returns a normalized form of a person name used for deduplication.
// Mirrors scripts/match_payments.py:canonical_member_key. // Mirrors legacy/scripts/match_payments.py:canonical_member_key.
func canonicalKey(name string) string { func canonicalKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ") return strings.Join(strings.Fields(czech.Normalize(name)), " ")
} }
// groupRawPaymentsByPerson groups transactions by the "person" column, // groupRawPaymentsByPerson groups transactions by the "person" column,
// canonicalizing names against memberNames where possible. // canonicalizing names against memberNames where possible.
// Mirrors scripts/views.py:group_payments_by_person (without the // Mirrors legacy/scripts/views.py:group_payments_by_person (without the
// "Unmatched / Unknown" bucket that is payments-view-specific). // "Unmatched / Unknown" bucket that is payments-view-specific).
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction { func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
canonicalByKey := make(map[string]string, len(memberNames)) canonicalByKey := make(map[string]string, len(memberNames))
+1 -1
View File
@@ -11,7 +11,7 @@ import (
) )
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output. // buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_juniors_view_model. // Mirrors legacy/scripts/views.py:build_juniors_view_model.
func buildJuniorsResponse( func buildJuniorsResponse(
members []domreconcile.Member, members []domreconcile.Member,
sortedMonths []string, sortedMonths []string,
+1 -1
View File
@@ -9,7 +9,7 @@ import (
) )
// buildPaymentsResponse constructs the PaymentsResponse wire type. // buildPaymentsResponse constructs the PaymentsResponse wire type.
// Mirrors scripts/views.py:build_payments_view_model. // Mirrors legacy/scripts/views.py:build_payments_view_model.
func buildPaymentsResponse( func buildPaymentsResponse(
txns []domreconcile.Transaction, txns []domreconcile.Transaction,
memberNames []string, memberNames []string,
+1 -1
View File
@@ -1,7 +1,7 @@
package api package api
// VersionResponse is the JSON contract for GET /api/version. // VersionResponse is the JSON contract for GET /api/version.
// Keys match Python's BUILD_META dict (see app.py). // Keys match Python's BUILD_META dict (see legacy/app.py).
type VersionResponse struct { type VersionResponse struct {
Tag string `json:"tag"` Tag string `json:"tag"`
Commit string `json:"commit"` Commit string `json:"commit"`
+3 -3
View File
@@ -85,10 +85,10 @@ git diff go/tests/fixtures/ # review changes before committing
To refresh a single function: To refresh a single function:
```bash ```bash
PYTHONPATH=scripts:. python3 scripts/capture_fixtures.py --func normalize --all \ PYTHONPATH=legacy/scripts:legacy python3 legacy/scripts/capture_fixtures.py --func normalize --all \
| while IFS= read -r line; do | while IFS= read -r line; do
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])") id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
echo "$line" | python3 scripts/scrub_fixtures.py \ echo "$line" | python3 legacy/scripts/scrub_fixtures.py \
> go/tests/fixtures/pure/normalize/${id}.json > go/tests/fixtures/pure/normalize/${id}.json
done done
``` ```
@@ -120,7 +120,7 @@ verify that no real roster names appear in their `member_names` arrays.
## Adding a new fixture ## Adding a new fixture
1. Add a seed to `scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`. 1. Add a seed to `legacy/scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function 2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
is new. is new.
3. Run the single-file capture recipe above and review the diff. 3. Run the single-file capture recipe above and review the diff.
@@ -13,7 +13,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
func TestBuildNameVariantsParity(t *testing.T) { func TestBuildNameVariantsParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
func TestCalculateFeeParity(t *testing.T) { func TestCalculateFeeParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
func TestCalculateJuniorFeeParity(t *testing.T) { func TestCalculateJuniorFeeParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from match_payments import format_date; print(format_date(46027))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import format_date; print(format_date(46027))"
func TestFormatDateParity(t *testing.T) { func TestFormatDateParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c " // PYTHONPATH=legacy/scripts:legacy python3 -c "
// from sync_fio_to_sheets import generate_sync_id // from sync_fio_to_sheets import generate_sync_id
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))" // print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
// //
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c " // PYTHONPATH=legacy/scripts:legacy python3 -c "
// from match_payments import infer_transaction_details // from match_payments import infer_transaction_details
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))" // print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
func TestMatchMembersParity(t *testing.T) { func TestMatchMembersParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
func TestNormalizeParity(t *testing.T) { func TestNormalizeParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
// //
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0. // Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s) // Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
@@ -13,7 +13,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
func TestParseMonthReferencesParity(t *testing.T) { func TestParseMonthReferencesParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -6,7 +6,7 @@
// //
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c " // PYTHONPATH=legacy/scripts:legacy python3 -c "
// from match_payments import reconcile // from match_payments import reconcile
// # ... build members/months/txs/exceptions... // # ... build members/months/txs/exceptions...
// import json; print(json.dumps(reconcile(...)))" // import json; print(json.dumps(reconcile(...)))"
+2 -2
View File
@@ -1,6 +1,6 @@
.git .git
.venv .venv
__pycache__ **/__pycache__
*.pyc **/*.pyc
.claude .claude
.gemini .gemini
View File
+1 -1
View File
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir \
pillow \ pillow \
gunicorn gunicorn
COPY app.py Makefile ./ COPY app.py ./
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/
COPY templates/ ./templates/ COPY templates/ ./templates/
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "fuj-management" name = "fuj-management"
version = "0.10" version = "0.10"
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)" description = "Legacy Python implementation of the FUJ (Frisbee Ultimate Jablonec) management tools; the Go implementation in ../go is primary"
dependencies = [ dependencies = [
"flask>=3.1.3", "flask>=3.1.3",
"google-api-python-client>=2.162.0", "google-api-python-client>=2.162.0",
@@ -1,14 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Regenerate the full fixture corpus. # Regenerate the full fixture corpus.
# Safe to re-run — always overwrites. # Safe to re-run — always overwrites.
# Requires: tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members). # Requires: legacy/tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
set -euo pipefail set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LEGACY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(cd "$LEGACY/.." && pwd)"
FIXTURES="$REPO/go/tests/fixtures" FIXTURES="$REPO/go/tests/fixtures"
CAPTURE_CMD="PYTHONPATH=$REPO/scripts:. python3 $REPO/scripts/capture_fixtures.py" CAPTURE_CMD="PYTHONPATH=$LEGACY/scripts:$LEGACY python3 $LEGACY/scripts/capture_fixtures.py"
SCRUB_CMD="python3 $REPO/scripts/scrub_fixtures.py" SCRUB_CMD="python3 $LEGACY/scripts/scrub_fixtures.py"
run_func() { run_func() {
local func="$1" local func="$1"
@@ -18,7 +19,7 @@ run_func() {
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \ case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")" echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$dir/${case_id}.json" echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
done done
} }
@@ -39,7 +40,7 @@ echo "==> Capturing reconcile fixtures..."
mkdir -p "$FIXTURES/reconcile" mkdir -p "$FIXTURES/reconcile"
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")" case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json" echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
done done
echo "==> Done. Review with: git diff go/tests/fixtures/" echo "==> Done. Review with: git diff go/tests/fixtures/"
View File