Compare commits

..
Author SHA1 Message Date
kacerr dd629fa2a9 gitignore go/parity
Deploy to K8s / deploy (push) Successful in 10s
2026-05-12 00:08:38 +02:00
128 changed files with 398 additions and 2910 deletions
+1 -28
View File
@@ -10,34 +10,7 @@
"Bash(./bin/fuj help *)", "Bash(./bin/fuj help *)",
"Bash(./bin/fuj version *)", "Bash(./bin/fuj version *)",
"Bash(make go-test *)", "Bash(make go-test *)",
"Bash(make go-lint *)", "Bash(make go-lint *)"
"Bash(tea pr create --title 'fix\\(go\\): pass raw value to FormatDate so numeric dates format' --description ' *)",
"Bash(git checkout *)",
"Bash(go build *)",
"Bash(go test *)",
"Bash(make parity *)",
"Bash(tea pr create --title 'fix\\(go\\): accept single-digit day/month in attendance date headers' --description ' *)",
"Bash(lsof -nP -iTCP:8080 -sTCP:LISTEN)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management checkout -b fix/period-selector-restore)",
"Bash(git pull *)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log --oneline -20)",
"Bash(curl -s -o /tmp/fio-transparent.html \"https://ib.fio.cz/ib/transparent?a=2800359168\")",
"Read(//tmp/**)",
"Bash(grep -oE '[0-9]{1,2}\\\\.[0-9]{1,2}\\\\.[0-9]{2,4}' /tmp/fio-transparent.html | head -20)",
"Read(//private/tmp/**)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management status)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat HEAD)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log -8 --oneline)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management tag --sort=-v:refname)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch --show-current)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management show --stat --format='%H %s%n%nbranch?: %d' 0.33)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management for-each-ref --format='%\\(refname:short\\) %\\(objectname:short\\) %\\(subject\\)' refs/tags)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch -r --contains 0.33)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat main HEAD -- Makefile scripts/fio_utils.py scripts/sync_fio_to_sheets.py CHANGELOG.md)",
"Bash(cp /Users/jan.novak/.claude/plans/in-python-app-i-m-distributed-muffin.md /Users/jan.novak/srv/personal/fuj-management/docs/plans/2026-06-08-1110-junior-expected-fix.md)"
],
"additionalDirectories": [
"/Users/jan.novak/srv/personal/fuj-management/docs/plans"
] ]
} }
} }
+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 legacy/build/Dockerfile \ docker build -f 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 legacy/ -t $IMAGE .
docker push $IMAGE docker push $IMAGE
build-go: build-go:
-105
View File
@@ -1,105 +0,0 @@
name: GitOps image update
on:
# Auto-fires when "Build and Push" completes successfully (tag push).
workflow_run:
workflows: ["Build and Push"]
types: [completed]
# Manual trigger for dry-runs and one-off bumps.
workflow_dispatch:
inputs:
tag:
description: "Git tag to deploy (without the -go suffix, e.g. 0.37)"
required: true
dry_run:
description: "Dry run — print diff, do not open a PR"
type: boolean
default: false
uh_cli_version:
description: "uh-cli version override (e.g. v0.2.0). Defaults to v0.1.0."
required: false
env:
TEA_VERSION: "0.9.2"
# Resolved priority: manual input → repo/org variable → hardcoded default.
UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}
jobs:
gitops-pr:
runs-on: ubuntu-latest
# Skip if triggered by workflow_run that did not succeed.
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
container:
image: ubuntu:latest
env:
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
steps:
- name: Install git, curl, ca-certificates, jq
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git curl ca-certificates jq
- name: Install tea
run: |
curl -fsSL \
"https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
-o /usr/local/bin/tea
chmod +x /usr/local/bin/tea
- name: Install uh-cli
run: |
curl -fsSL \
"https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64" \
-o /usr/local/bin/uh-cli
chmod +x /usr/local/bin/uh-cli
- name: Resolve image tag
id: resolve
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
else
# workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
# Look up the tag name that points to the triggering commit SHA via the API.
SHA="${{ github.event.workflow_run.head_sha }}"
GIT_TAG=$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.home.hrajfrisbee.cz/api/v1/repos/${{ github.repository }}/tags?limit=50" \
| jq -r --arg sha "$SHA" '.[] | select(.commit.sha == $sha) | .name')
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "Resolved image: ${IMAGE}"
- name: Configure git identity and credentials
run: |
git config --global user.name "uh-cli bot"
git config --global user.email "bot@hrajfrisbee.cz"
# Store credentials separately so the --git-repo URL stays clean.
# Tea matches the login URL against the remote URL; embedded credentials
# break that matching and cause "path segment [0] is empty" on pr create.
git config --global credential.helper store
echo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz" >> ~/.git-credentials
- name: Authenticate tea
run: |
tea login add \
--name ci \
--url https://gitea.home.hrajfrisbee.cz \
--token "$GITEA_TOKEN"
- name: Open image-update PR (or dry run)
run: |
set -x
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "${{ steps.resolve.outputs.image }}" \
--git-repo "https://gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}
+111
View File
@@ -0,0 +1,111 @@
name: Deploy to K8s
on:
workflow_dispatch:
push:
branches:
- '**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Debug - print Gitea Actions environment
run: |
echo "=== All environment variables ==="
env | sort
echo ""
echo "=== GITHUB_* / GITEA_* / ACTIONS_* vars ==="
env | grep -E '^(GITHUB|GITEA|ACTIONS)_' | sort
- name: Install kubectl
run: |
curl -sfLO "https://dl.k8s.io/release/$(curl -sfL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
install kubectl /usr/local/bin/
- name: Get Kanidm token from Vault
id: vault
run: |
set -euxo pipefail
VAULT_AUTH_RESPONSE=$(curl -f --request POST \
--data '{"role_id":"${{ secrets.VAULT_ROLE_ID }}","secret_id":"${{ secrets.VAULT_SECRET_ID }}"}' \
https://vault.hrajfrisbee.cz/v1/auth/approle/login)
echo "Vault auth response: $VAULT_AUTH_RESPONSE" >&2
VAULT_TOKEN=$(echo "$VAULT_AUTH_RESPONSE" | jq -r '.auth.client_token')
# Read the kanidm API token
SECRET_RESPONSE=$(curl -f \
-H "X-Vault-Token: ${VAULT_TOKEN}" \
https://vault.hrajfrisbee.cz/v1/secret/data/gitea/gitea-ci)
echo "Secret response: $SECRET_RESPONSE" >&2
API_TOKEN=$(echo "$SECRET_RESPONSE" | jq -r '.data.data.token')
echo "::add-mask::${API_TOKEN}"
echo "api_token=${API_TOKEN}" >> "$GITHUB_OUTPUT"
- name: Exchange for K8s OIDC token via Kanidm
id: k8s
run: |
API_TOKEN="${{ steps.vault.outputs.api_token }}"
echo "api_token length: ${#API_TOKEN}" >&2
echo "api_token prefix (first 8 chars): ${API_TOKEN:0:8}..." >&2
HTTP_BODY=$(mktemp)
HTTP_STATUS=$(curl -sS -X POST "https://idm.home.hrajfrisbee.cz/oauth2/token" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "client_id=k8s" \
-d "subject_token=${API_TOKEN}" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "audience=k8s" \
-d "scope=openid groups" \
-o "$HTTP_BODY" -w "%{http_code}")
echo "HTTP status: $HTTP_STATUS" >&2
echo "Response body:" >&2
cat "$HTTP_BODY" >&2
RESPONSE=$(cat "$HTTP_BODY")
ID_TOKEN=$(echo "$RESPONSE" | jq -r '.id_token // empty')
if [ -z "$ID_TOKEN" ]; then
echo "::error::Kanidm token exchange failed (HTTP $HTTP_STATUS)"
exit 1
fi
echo "::add-mask::${ID_TOKEN}"
echo "id_token=${ID_TOKEN}" >> "$GITHUB_OUTPUT"
# Sanity check
# Warning - this is the part that is failing
# echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, groups, exp}'
- name: Debug - print environment before kubectl
run: env | sort
- name: Configure kubectl & deploy
run: |
echo "${{ secrets.K8S_CA_CERT }}" > /tmp/ca.crt
kubectl config set-cluster mycluster \
--server=https://192.168.0.31:6443 \
--insecure-skip-tls-verify=true
# --certificate-authority=/tmp/ca.crt \
kubectl config set-credentials gitea-ci \
--token="${{ steps.k8s.outputs.id_token }}"
kubectl config set-context gitea-ci \
--cluster=mycluster --user=gitea-ci
kubectl config use-context gitea-ci
kubectl auth whoami
kubectl get ns
# your deploy here
# kubectl apply -f k8s/
+3 -5
View File
@@ -2,16 +2,14 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python Debugger: Flask (legacy)", "name": "Python Debugger: Flask",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "flask", "module": "flask",
"python": "${workspaceFolder}/legacy/.venv/bin/python", "python": "${workspaceFolder}/.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",
-75
View File
@@ -1,74 +1,5 @@
# Changelog # Changelog
## 2026-10-08 12:16 CEST — feat(go): scheduled bank sync (Go only)
- The Go server now runs the bank sync (sync → infer → flush cache) by itself at the times in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`, `off` disables), read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup; an invalid value stops the server at startup with an error.
- Manual `/sync-bank` and scheduled runs share one lock, so two syncs can no longer overlap; a second request gets "a sync is already running".
- The page footer shows the last sync since start (time, trigger, ok/FAILED) and the next scheduled run.
- The binary embeds the time zone database (`time/tzdata`), since the Alpine image has none.
- Deliberately not implemented in the legacy Python app, which is being phased out; recorded in `CLAUDE.md` under "Deliberately Go-only features".
- Key files: `go/internal/scheduler/`, `go/internal/services/banksync/runner.go`, `go/cmd/fuj/main.go`, `go/internal/config/config.go`, `go/internal/web/`.
## 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
- 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.
- Deployment is handled by `gitops-update.yaml`. Repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and `K8S_CA_CERT` are now unused.
- Follow-up outside the repo: rotate the Vault AppRole secret and the Kanidm API token, and clear old job logs.
## 2026-10-08 11:34 CEST — fix(python): return months_to_show from /api/adults and /api/juniors
- Go already returned `months_to_show` in the JSON API; Python only passed it to its HTML templates. Backported the key so the API contract matches and `make parity` reports 3/3 routes.
- Key files: `legacy/app.py`, `legacy/tests/test_app.py`.
## 2026-10-08 11:34 CEST — fix: VS is always a string, on read and on write
- Go rendered numeric sheet cells with `fmt.Sprint`, so a numeric VS came out as `2.609767878e+09`. Text columns of the payments sheet now go through `sheets.CellString` (plain digits); bank inference uses it too.
- Both sync implementations now append VS as a text cell (leading apostrophe under `USER_ENTERED`), so Sheets no longer stores it as a number or drops leading zeros. Existing rows are unchanged.
- Key files: `go/internal/io/sheets/cell.go`, `go/internal/services/membership/sources.go`, `go/internal/services/banksync/{sync,infer}.go`, `legacy/scripts/sync_fio_to_sheets.py`.
## 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
- 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.
- Supports `workflow_run` auto-trigger and `workflow_dispatch` with `dry_run` / `uh_cli_version` inputs.
- Requires `GITOPS_TOKEN` repo secret (Gitea PAT with write+PR access to `home-kubernetes`).
## 2026-05-24 21:58 CEST — feat(fees): update adult monthly rates for 2026-05 through 2026-08
- 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).
- Mirrored in both `scripts/attendance.py` and `go/internal/domain/fees/fees.go`.
## 2026-05-24 21:42 CEST — feat: multi-account Fio sync + switch QR default to 2502035405/2010
- Added second bank account `2502035405/2010` (IBAN `CZ0820100000002502035405`) to sync.
- Both accounts are fetched on every sync; dedup by existing sync_id keeps the payments sheet clean.
- QR codes now default to the new account (`CZ0820100000002502035405`).
- Go: `config.go` gains hardcoded `Accounts`/`LoadedAccount` slice; `Config.BankAccount` renamed to `Config.QRAccount`; `FioAPIToken` removed (tokens are per-account via `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
- Go: `SyncToSheets` now accepts `[]fio.Client`; new `TestSyncToSheets_MultiAccount` test.
- Python: `config.py` gains `ACCOUNTS` / `LOADED_ACCOUNTS`; `fio_utils.py` adds `fetch_transactions_for` and `fetch_transactions_all`; `sync_fio_to_sheets.py` uses `fetch_transactions_all`.
- Key files: `go/internal/config/config.go`, `go/internal/services/banksync/sync.go`, `go/cmd/fuj/main.go`, `scripts/config.py`, `scripts/fio_utils.py`, `scripts/sync_fio_to_sheets.py`.
## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations ## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations
- Multi-month payment allocation now fills the earliest in-window deficit first and spills - Multi-month payment allocation now fills the earliest in-window deficit first and spills
@@ -83,12 +14,6 @@
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below). - Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet. - Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately. - Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths).
- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime.
- Key files: `go/internal/web/assets_test.go` (new).
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal ## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
+26 -102
View File
@@ -4,89 +4,41 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## 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. 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.
- **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 ## 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 `go/internal/config/config.go` (mirrored in `legacy/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 `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
## Development Setup ## Development Setup
Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`. This project uses `uv` for dependency management.
```bash ```bash
make go-build # builds bin/fuj uv venv && uv sync
source .venv/bin/activate
``` ```
Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`. Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically).
```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-go # Build and start dashboard at http://localhost:8080 make web # Start dashboard at http://localhost:5001
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
make fees # Print fee report from attendance sheet 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 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 the legacy Python Docker image make image # Build 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
cd go && go test ./internal/domain/reconcile -run TestName PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route
``` ```
## Architecture ## Architecture
@@ -94,75 +46,47 @@ cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.Te
### Data flow ### Data flow
``` ```
Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/
Google Sheets (payments) ──► payments loader ──┘ Google Sheets (payments) ──► match_payments.py ──┘
Fio Bank API ──► bank sync ──► Google Sheets (payments) Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments)
``` ```
### Repository layout ### Key modules
- `go/` — primary implementation (single binary `fuj`, templates and static assets embedded). - `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`
- `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`). - `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.
- `docs/` — documentation; `docs/plans/` holds plans and task lists. - `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
- `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR. - `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/`.
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
### Key modules — Go (primary) - `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
- `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`, `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 ### 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 600 CZK for 2+ sessions; 200 CZK for exactly 1, capped at the month's rate) - `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet - `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 (never more than the month's rate, so a 0-rate month is free), 2+ sessions → monthly rate (default 600 CZK) - Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK) - 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))`.
### Merged months ### 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. `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.
### Caching ### 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). `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()`.
### 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 ### 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 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. `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.
### QR codes ### QR codes
+29 -38
View File
@@ -1,30 +1,26 @@
.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 .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
# Legacy Python implementation lives in legacy/ (Go in go/ is primary). export PYTHONPATH := scripts:$(PYTHONPATH)
LEGACY := legacy VENV := .venv
export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH) PYTHON := $(VENV)/bin/python3
VENV := $(LEGACY)/.venv CREDENTIALS := .secret/fuj-management-bot-credentials.json
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: $(LEGACY)/pyproject.toml .venv/.last_sync: pyproject.toml
uv sync --project $(LEGACY) uv sync
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 legacy Python dashboard (alias for web-py)" @echo " make web - Start Python dashboard (alias for web-py, until M8)"
@echo " make web-py - Start legacy Python dashboard on :5001" @echo " make web-py - Start Python dashboard on :5001"
@echo " make web-go - Build and start Go dashboard on :8080 (primary)" @echo " make web-go - Build and start Go dashboard on :8080"
@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"
@@ -39,7 +35,6 @@ 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"
@@ -49,21 +44,21 @@ help:
@echo " make docs - Serve documentation in a browser" @echo " make docs - Serve documentation in a browser"
venv: venv:
uv sync --project $(LEGACY) uv sync
fees: $(PYTHON) fees: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/calculate_fees.py $(PYTHON) scripts/calculate_fees.py
match: $(PYTHON) match: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/match_payments.py $(PYTHON) scripts/match_payments.py
web: web-py web: web-py
web-py: $(PYTHON) web-py: $(PYTHON)
$(PYTHON) $(LEGACY)/app.py $(PYTHON) app.py
web-debug: $(PYTHON) web-debug: $(PYTHON)
FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py FLASK_DEBUG=1 $(PYTHON) app.py
go-build: go-build:
cd $(GO_SRC) && go build -trimpath \ cd $(GO_SRC) && go build -trimpath \
@@ -87,10 +82,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"; \
$(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \ PYTHONPATH=scripts:. $(PYTHON) 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) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \ echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
done; \ done; \
echo " $$func done"; \ echo " $$func done"; \
done done
@@ -117,38 +112,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 $(LEGACY)/build/Dockerfile $(LEGACY) -f build/Dockerfile .
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) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) $(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
sync-2025: $(PYTHON) sync-2025: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2025-10-01 --to 2025-12-31 --sort-by-date $(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
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) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --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) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
infer: $(PYTHON) infer: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS) $(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
reconcile: $(PYTHON) ## Match payments against attendance reconcile: ## Match payments against attendance
$(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS) export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
test: $(PYTHON) ## Run web application tests test: $(PYTHON) ## Run web application tests
cd $(LEGACY) && $(PYTHON) -m unittest discover tests export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests
test-v: $(PYTHON) ## Run tests with verbose output test-v: $(PYTHON) ## Run tests with verbose output
cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(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"
+10 -17
View File
@@ -19,7 +19,7 @@ sys.path.append(str(scripts_dir))
from config import ( from config import (
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID, ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW, BANK_ACCOUNT, CREDENTIALS_PATH,
) )
from attendance import get_members_with_fees, get_junior_members_with_fees from attendance import get_members_with_fees, get_junior_members_with_fees
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
@@ -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, default_sync_window from sync_fio_to_sheets import sync_to_sheets
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):
@@ -121,8 +121,6 @@ def flush_cache_endpoint():
deleted = flush_cache() deleted = flush_cache()
return render_template("flush-cache.html", flushed=True, deleted=deleted) return render_template("flush-cache.html", flushed=True, deleted=deleted)
# Scheduled (daily) bank sync exists only in the Go server, on purpose: this
# app is legacy and being phased out. Here the sync runs only on request.
@app.route("/sync-bank") @app.route("/sync-bank")
def sync_bank(): def sync_bank():
import contextlib import contextlib
@@ -130,14 +128,13 @@ 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-year (same default window as Go) # sync_to_sheets: equivalent of make sync-2026
date_from, date_to = default_sync_window() output.write("=== Syncing Fio transactions (2026) ===\n")
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=date_from, date_from_str="2026-01-01",
date_to_str=date_to, date_to_str="2026-12-31",
sort_by_date=True, sort_by_date=True,
) )
output.write("\n=== Inferring payment details ===\n") output.write("\n=== Inferring payment details ===\n")
@@ -182,9 +179,7 @@ def api_adults():
datetime.now().strftime("%Y-%m"), datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
) )
out = _unwrap_view_model_for_api(vm) return jsonify(_unwrap_view_model_for_api(vm))
out["months_to_show"] = MONTHS_TO_SHOW
return jsonify(out)
@app.route("/api/juniors") @app.route("/api/juniors")
def api_juniors(): def api_juniors():
@@ -208,9 +203,7 @@ def api_juniors():
datetime.now().strftime("%Y-%m"), datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
) )
out = _unwrap_view_model_for_api(vm) return jsonify(_unwrap_view_model_for_api(vm))
out["months_to_show"] = MONTHS_TO_SHOW
return jsonify(out)
@app.route("/api/payments") @app.route("/api/payments")
def api_payments(): def api_payments():
@@ -262,7 +255,7 @@ def adults_view():
bank_account=BANK_ACCOUNT, bank_account=BANK_ACCOUNT,
) )
record_step("process_data") record_step("process_data")
return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **vm) return render_template("adults.html", **vm)
@app.route("/juniors") @app.route("/juniors")
def juniors_view(): def juniors_view():
@@ -298,7 +291,7 @@ def juniors_view():
bank_account=BANK_ACCOUNT, bank_account=BANK_ACCOUNT,
) )
record_step("process_data") record_step("process_data")
return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **vm) return render_template("juniors.html", **vm)
@app.route("/payments") @app.route("/payments")
def payments(): def payments():
+2 -2
View File
@@ -1,6 +1,6 @@
.git .git
.venv .venv
**/__pycache__ __pycache__
**/*.pyc *.pyc
.claude .claude
.gemini .gemini
+1 -1
View File
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir \
pillow \ pillow \
gunicorn gunicorn
COPY app.py ./ COPY app.py Makefile ./
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/
COPY templates/ ./templates/ COPY templates/ ./templates/
-2
View File
@@ -4,8 +4,6 @@ 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
@@ -182,7 +182,7 @@ fuj-management/
│ │
├── .gitea/workflows/ ├── .gitea/workflows/
│ ├── build.yaml # CI: build + push Docker image │ ├── build.yaml # CI: build + push Docker image
│ └── gitops-update.yaml # CD: open image-update PR in home-kubernetes │ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
│ │
├── .secret/ # (gitignored) API credentials ├── .secret/ # (gitignored) API credentials
├── docs/ # Project documentation ├── docs/ # Project documentation
+18 -12
View File
@@ -129,27 +129,32 @@ The project uses two Gitea Actions workflows:
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input. **Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
#### 2. GitOps image update (`gitops-update.yaml`) #### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
**Triggers**: **Triggers**:
- Completion of a successful "Build and Push" run - Push to any branch
- Manual dispatch (with `tag`, `dry_run` and `uh_cli_version` inputs) - Manual dispatch
**Steps**: **Steps**:
1. Install `tea` and `uh-cli` 1. Checkout code
2. Resolve the image tag of the Go image that was just built 2. Install kubectl
3. Open a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to that image 3. Retrieve Kanidm token from HashiCorp Vault:
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
The cluster picks the change up once that PR is merged. CI does not talk to the cluster directly. - Fetch API token from `secret/data/gitea/gitea-ci`
4. Exchange API token for K8s OIDC token via Kanidm:
> The earlier `kubernetes-deploy.yaml` workflow (Vault → Kanidm → `kubectl`) was removed on 2026-10-08. It never deployed anything and printed credentials to the job log. - POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
5. Configure kubectl with the OIDC token
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
**Required secrets**: **Required secrets**:
| Secret | Purpose | | Secret | Purpose |
|--------|---------| |--------|---------|
| `REGISTRY_TOKEN` | Docker registry authentication | | `REGISTRY_TOKEN` | Docker registry authentication |
| `GITOPS_TOKEN` | Gitea token with write and PR access to `home-kubernetes` | | `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
### Infrastructure Topology ### Infrastructure Topology
@@ -159,7 +164,8 @@ Gitea (git push / tag)
├── build.yaml → Docker Build → Gitea Container Registry ├── build.yaml → Docker Build → Gitea Container Registry
│ (gitea.home.hrajfrisbee.cz) │ (gitea.home.hrajfrisbee.cz)
│ │
└── gitops-update.yaml → PR in home-kubernetes → K8s Cluster └── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
(192.168.0.31:6443)
``` ```
This is a self-hosted infrastructure stack: This is a self-hosted infrastructure stack:
@@ -117,7 +117,7 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238` - [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b` - [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b` - [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge) - [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end. **Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
@@ -1,184 +0,0 @@
# M6.7 — Single-binary embed verification
## Context
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire
`embed.FS` into handlers; verify single-binary deployment includes all
assets."
The wiring is **already in place** from M6.1 onward:
- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares
`//go:embed templates` → `templateFS` and `//go:embed static` → `staticFS`.
- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66)
parses every page template via `template.New(...).ParseFS(templateFS, ...)`.
- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68)
serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled
binary into the `alpine:3` runtime image — no `templates/` or `static/`
directory ever lands beside it.
What is missing is **proof** the embed is complete and stays complete:
1. Nothing fails the build/test if a contributor adds a new file under
`internal/web/templates/` or `internal/web/static/` that isn't matched
by an `//go:embed` glob (or, more realistically, adds a sibling
directory like `static/img/` and the glob still picks it up — but a
typo'd directive would silently drop it).
2. No automated test exercises the `/static/*` route against the embedded
FS — current tests in
[html_handler_test.go](../../go/internal/web/html_handler_test.go)
render templates (which proves `templateFS` is good) but never hit a
static URL through the mux.
3. The "single binary, no working-dir assets" property is undocumented —
if it ever broke, no one would notice until the Docker image started
500'ing in prod.
The intended outcome: a small test file plus a documented manual
verification step, after which M6.7 can be ticked and M6 closed.
## Plan
### 1. Add `go/internal/web/assets_test.go`
One new file, two tests, no production code changes.
**Test A — embed completeness regression guard.** Walks the on-disk
`templates/` and `static/` directories and asserts every regular file is
also present in the corresponding embedded FS. Catches:
- A new template added without updating the `//go:embed` directive
(current globs are `templates` and `static` — recursive by default for
directories, so this is a low-probability regression, but the test
doubles as living documentation of the contract).
- A typo in the directive (e.g. someone renames `static` → `assets` in
one place but not the other).
Implementation sketch:
```go
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskFS fs.FS // os.DirFS("templates") / os.DirFS("static")
embed fs.FS // exported helper or via internal test in package web
root string
}{...}
for _, tc := range cases {
_ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { return err }
embPath := tc.root + "/" + path
if _, err := fs.Stat(tc.embed, embPath); err != nil {
t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err)
}
return nil
})
}
}
```
Because `templateFS` and `staticFS` are unexported package vars, this
test lives in `package web` (not `web_test`) — sibling to
[assets.go](../../go/internal/web/assets.go). All the existing handler
tests are in `package web_test`; that's fine, this one is internal.
**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux`
with the same wiring as
[server.go:68](../../go/internal/web/server.go#L68), fires httptest
requests, asserts:
- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`,
body contains a known string from app.css (e.g. a CSS selector).
- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with
`text/javascript` or `application/javascript`, body non-empty.
- `GET /static/js/payment-qr.js` → 200, body non-empty.
- `GET /static/css/missing.css` → 404 (sanity: the file server actually
rejects unknown paths instead of returning some default).
Rather than duplicate the mux assembly, factor a tiny helper (or test the
existing mux). The cleanest move: extract `staticHandler()` from
[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small
exported-from-package function or just `staticFS` / `fs.Sub` helper, and
have the test call it. Smallest delta: keep production code unchanged and
replicate the two-line wiring inside the test file (acceptable — it's
two lines and the test exists precisely to lock that contract).
### 2. Manual / one-shot verification (no code; documented in plan only)
Run once locally and tick M6.7. Command transcript:
```bash
make go-build # → ./bin/fuj
cp bin/fuj /tmp/fuj-standalone
cd /tmp # working dir has no templates/ or static/
./fuj-standalone server &
SERVER_PID=$!
sleep 1
curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard"
curl -sf http://localhost:8080/juniors | grep -q "Juniors"
curl -sf http://localhost:8080/payments | grep -q "Payments Ledger"
curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \
&& test -s /tmp/app.css
curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \
&& test -s /tmp/qr.js
kill $SERVER_PID
```
`fuj server` will fail to talk to Sheets without credentials, so the
`/adults` etc. pages will render with the `Error` field set — that's
fine; the assertion is that the **template + static asset pipeline** is
self-contained, not that data loads. Each curl above only checks for
markup present in every render path (header text and stylesheet body).
### 3. Tracker + changelog
- Tick `M6.7` in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120),
append the merge SHA on the line.
- Mark "Last updated" date and bump milestone status: M6 complete, next is
M7.
- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`).
## Files touched
| File | Change |
| --- | --- |
| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) |
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" |
| [CHANGELOG.md](../../CHANGELOG.md) | new top entry |
No production source files change. (If extracting the static handler
reads cleaner, a 4-line refactor in
[server.go](../../go/internal/web/server.go) is acceptable but optional.)
## Branch + MR
Per project convention this is a feature, so:
```bash
git checkout -b feat/go-m6-7-embed-verify
# … commits …
git push -u origin feat/go-m6-7-embed-verify
tea pr create --title "feat(go): M6.7 — single-binary embed verification" \
--description "<short body referencing M6.7>" --base main \
--head feat/go-m6-7-embed-verify
```
## Verification
After implementation:
1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass).
2. `make go-lint` → clean.
3. Run the manual transcript in §2 above — all curls succeed, no
"template not found" or 404 on static assets.
4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/` →
succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults`
with stylesheet attached (visual smoke test in browser).
## Out of scope
- Re-architecting how templates are parsed or served.
- Compressing / fingerprinting static assets (a separate concern).
- Live integration test with real Sheets data — covered later in M7.
@@ -1,269 +0,0 @@
# Multi-account Fio sync + switch QR target to 2502035405/2010
## Context
The club is opening a second Fio transparent account, **`2502035405/2010`** (IBAN
`CZ??...0000002502035405`). Going forward:
- **Sync** must pull transactions from **both** accounts into the existing
payments sheet — the old `2800359168/2010` stays active so historical fees
paid to it still reconcile.
- **QR codes** generated by `/qr` should default to the **new** account
(`2502035405/2010`), so new payers send money there.
The payments sheet schema and `sync_id` hash stay **unchanged** — no
"Source Account" column, no migration, no risk of re-appending historical rows.
Bank-ID uniqueness from Fio plus the existing hash inputs are good enough to
dedupe across two transparent accounts in practice.
Implementation order: Go first, then port to Python. Both apps use the same
`PaymentsSheetID`, so once Go writes from two accounts the Python code only
needs to do the same when invoked.
### Decisions locked in with the user
- **Config style:** hardcoded list of accounts in `config.go` / `config.py`
(sheet IDs are already hardcoded; tokens stay env-driven).
- **Source tagging:** no schema change, `sync_id` formula unchanged.
- **Old account:** kept syncing in parallel; only QR target changes.
---
## Go implementation
### 1. Config — hardcoded `Accounts` list, derived `QRAccount`
[go/internal/config/config.go](../../go/internal/config/config.go)
- Replace scalar `BankAccount` + `FioAPIToken` with a hardcoded slice and a
primary pointer. Tokens stay env-driven via per-account env names.
```go
// Account describes one Fio bank account we sync from.
type Account struct {
IBAN string // e.g. "CZ0820100000002502035405"
AcctNum string // bare account number, e.g. "2502035405"
TokenEnv string // env var name holding the Fio API token (empty token => transparent scraper path)
Primary bool // true for the QR-code default account
}
var Accounts = []Account{
{IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true},
{IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"},
}
```
Compute the **real** IBAN check digit for `2502035405` once and bake it in
(replace the `85` placeholder above). The same `IBANAccountNum` helper at
[config.go:66](../../go/internal/config/config.go#L66) keeps producing
`AcctNum`, but having it on the struct avoids re-parsing at every call site.
- `Config` struct:
- Drop `BankAccount` and `FioAPIToken` fields.
- Add `QRAccount string` (the IBAN of `Accounts[i].Primary == true`).
- Add `LoadedAccounts []LoadedAccount` where each entry pairs an `Account`
with the token resolved from `os.Getenv(account.TokenEnv)`.
- `Load()` reads each `TokenEnv` and stores tokens with the account. Logs a
warning (info-level) if no token is set — that's the transparent-scraper
path, which is fine.
### 2. Sync loop — one `fio.Client` per account, single dedup pass
[go/internal/services/banksync/sync.go](../../go/internal/services/banksync/sync.go)
Change `SyncToSheets` to accept a slice of clients instead of one:
```go
func SyncToSheets(
ctx context.Context,
spreadsheetID string,
fioClients []fio.Client, // was: fioClient fio.Client
sh sheetsWriter,
opts SyncOpts,
) (int, error)
```
Inside:
- Keep steps 1 (read existing IDs) and 2 (date window) as-is.
- Step 3 (fetch) becomes a loop: call `FetchTransactions` on each client; tag
each `fio.Transaction` with the account it came from for logging only
(don't write it to the sheet). Concatenate into one slice `txns`.
- Steps 4a–4c (sync_id calc, debug table, row build, dedup) are unchanged —
the combined `txns` slice flows through the same code path. Existing
`sync_id` collisions across accounts are dropped silently, which is the
desired dedup behaviour.
- `printFioTable` (dry-run debug) should print an extra column showing the
source account so per-account fetches are visible — small change in
[fio_table.go](../../go/internal/services/banksync/fio_table.go).
Logger output: log fetched count per account and the total combined count.
### 3. Call sites — build clients from the config list
Both entry points construct `[]fio.Client`, one per `LoadedAccount`:
- [go/cmd/fuj/main.go:91](../../go/cmd/fuj/main.go#L91) — server `BankSync` action.
- [go/cmd/fuj/main.go:200](../../go/cmd/fuj/main.go#L200) — `fuj sync` CLI.
Helper to add somewhere in `cmd/fuj/main.go` or a new
`internal/services/banksync/clients.go`:
```go
func clientsFromConfig(cfg config.Config) []fio.Client {
out := make([]fio.Client, 0, len(cfg.LoadedAccounts))
for _, a := range cfg.LoadedAccounts {
out = append(out, fio.New(a.Token, a.AcctNum, nil))
}
return out
}
```
The existing factory at
[go/internal/io/fio/client.go:35](../../go/internal/io/fio/client.go#L35)
already routes per-account between the API path and the transparent scraper
based on whether the token is empty — no change there.
### 4. QR endpoint — default to `QRAccount`
[go/internal/web/html_handler.go:129](../../go/internal/web/html_handler.go#L129)
and [html_handler.go:135](../../go/internal/web/html_handler.go#L135):
swap `h.apiHandler.Config.BankAccount` → `h.apiHandler.Config.QRAccount`.
API DTOs that surface the payment account in JSON:
[build_adults.go:141](../../go/internal/web/api/build_adults.go#L141) and
[build_juniors.go:137](../../go/internal/web/api/build_juniors.go#L137) — same
swap.
The fields on the API structs at
[adults.go:40](../../go/internal/web/api/adults.go#L40) and
[juniors.go:39](../../go/internal/web/api/juniors.go#L39) keep their JSON
names so the Python parity binary doesn't see a schema diff.
### 5. Tests
- **New** [go/internal/services/banksync/sync_test.go](../../go/internal/services/banksync/sync_test.go):
add a test `TestSyncToSheets_MultiAccount` that wires up two fake
`fio.Client`s (via the existing
[fake.go](../../go/internal/io/fio/fake.go)), one returning a txn unique to
it and one returning a txn that duplicates an existing sync_id. Assert:
rows from both accounts get appended, the duplicate is dropped.
- **Update** [go/internal/web/html_handler_test.go:207](../../go/internal/web/html_handler_test.go#L207)
`TestServeQR` and [qr_test.go](../../go/internal/web/qr_test.go)
`TestQRBuildSPD` — expected SPD strings now contain the new IBAN by default.
- **Update** any config tests asserting `cfg.BankAccount` directly to use
`cfg.QRAccount` and the new `LoadedAccounts` slice. (Grep for `BankAccount`
under `go/` after the rename.)
- Existing fio parser tests
([fio_test.go](../../go/internal/io/fio/fio_test.go)) and sync_id test
([synch_test.go](../../go/internal/domain/synch/synch_test.go)) need no
changes — they don't know about the account list.
### 6. Verification (Go)
1. `cd go && go build ./... && go test ./...` — all green.
2. `FIO_API_TOKEN_NEW="" FIO_API_TOKEN_OLD="" ./build/fuj sync --dry-run --print-fio-table --days 7`
— see fetched transactions from **both** accounts, with NEW/DUP status,
without writing.
3. `./build/fuj server` then visit
`http://localhost:8080/qr?amount=700&message=test` — QR payload SPD must
contain the new IBAN `CZ??...2502035405`.
4. Real sync once dry-run looks right: `./build/fuj sync --days 30` — confirm
in the Google sheet that rows from the old account still appear and no
duplicates are written.
---
## Python port (after Go is merged)
The Python app uses the same payments sheet and the same column schema, so
the port mirrors the Go change one-for-one. Goal: one round-trip of
`make sync-2026` pulls from both accounts.
### 1. `scripts/config.py`
- Replace the scalar `BANK_ACCOUNT` constant at
[scripts/config.py:25](../../scripts/config.py#L25) with a hardcoded list
plus a derived primary:
```python
ACCOUNTS = [
{"iban": "CZ0820100000002502035405", "acct_num": "2502035405", "token_env": "FIO_API_TOKEN_NEW", "primary": True},
{"iban": "CZ8520100000002800359168", "acct_num": "2800359168", "token_env": "FIO_API_TOKEN_OLD", "primary": False},
]
BANK_ACCOUNT = next(a["iban"] for a in ACCOUNTS if a["primary"]) # QR / template default
```
Use the real check-digit IBAN for `2502035405` (the same one baked into Go).
- Resolve tokens at module load time with `os.environ.get(token_env, "")` and
stash them on a parallel `LOADED_ACCOUNTS` list (or attach to the dicts) so
other modules don't all re-read env.
### 2. `scripts/fio_utils.py`
- [fio_utils.py:106](../../scripts/fio_utils.py#L106) and
[fio_utils.py:219](../../scripts/fio_utils.py#L219): drop the hardcoded
`"2800359168"` default for `account_id`. Make it a required argument.
- `fetch_transactions(date_from, date_to, *, account)` — change signature to
take one account dict, return that account's transactions. Internally still
routes via `FIO_API_TOKEN` if the account dict has a token, otherwise the
transparent scraper at
[fio_utils.py:105](../../scripts/fio_utils.py#L105).
- Add `fetch_transactions_all(date_from, date_to, accounts=None)` that loops
over `accounts` (default `config.LOADED_ACCOUNTS`), calls the per-account
fetch, concatenates results. Logs per-account counts.
### 3. `scripts/sync_fio_to_sheets.py`
- Where it currently calls `fio_utils.fetch_transactions(...)`, switch to
`fio_utils.fetch_transactions_all(...)`. The dedup at
[sync_fio_to_sheets.py:62](../../scripts/sync_fio_to_sheets.py#L62) and
append at
[sync_fio_to_sheets.py:248](../../scripts/sync_fio_to_sheets.py#L248) need
no changes — same as in Go, the combined list flows through unchanged.
### 4. `app.py` (QR + template defaults)
- `/qr` at [app.py:321](../../app.py#L321) already reads `BANK_ACCOUNT` from
config; since the config default now points at the new IBAN, this is a
zero-line change in `app.py`.
- The `BANK_ACCOUNT` passed into templates at
[app.py:180](../../app.py#L180), [app.py:204](../../app.py#L204),
[app.py:255](../../app.py#L255), [app.py:291](../../app.py#L291) likewise
picks up the new default automatically.
### 5. Tests
The Python suite has **no** existing coverage of `fio_utils`,
`sync_fio_to_sheets`, or `/qr`. Don't add new tests — keep the port minimal
and rely on the Go test suite + manual verification for correctness.
### 6. Verification (Python)
1. `make test` — existing suite stays green (the config refactor must not
break [tests/test_app.py](../../tests/test_app.py)).
2. `make sync-2026` against the real sheet, dry-run first if the script
supports it, then real run — confirm rows from both accounts arrive.
3. `make web-debug` → open `/qr?amount=700&message=test` → QR payload SPD
contains the new IBAN.
---
## Branching
Per `CLAUDE.md`: this is a feature, so `feat/multi-account-bank-sync` off
`main`. Two MRs:
1. `feat/multi-account-bank-sync-go` — Go change end-to-end.
2. `feat/multi-account-bank-sync-py` — Python port, opened after (1) is
merged.
Each MR opened via `tea pr create`. CHANGELOG.md entry per MR.
## Out of scope
- No sheet schema migration.
- No change to `sync_id` hash inputs at
[domain/synch/synch.go:14](../../go/internal/domain/synch/synch.go#L14) or
[sync_fio_to_sheets.py:67](../../scripts/sync_fio_to_sheets.py#L67).
- No UI surface for switching the QR account per-request (the existing
`?account=` query param on `/qr` keeps working as an override).
- No backfill / dedup pass over historical rows.
@@ -1,90 +0,0 @@
# Fix `/juniors` 500: `int + str` in reconcile allocation
## Context
The `/juniors` route crashes with:
```
File ".../scripts/match_payments.py", line 469, in reconcile
total_expected = sum(e for _, e in in_window)
TypeError: unsupported operand type(s) for +: 'int' and 'str'
```
**Why it happens:** A junior with exactly **1 session** in a month gets an
expected fee of the string `"?"` (manual-review marker), set in
[attendance.py:107](scripts/attendance.py#L107). That value flows unchanged into
the ledger as `ledger[name][m]["expected"]`
([match_payments.py:370](scripts/match_payments.py#L370)).
When a bank payment is matched to such a junior+month, the new **fill-first
allocation** block builds `in_window` from those `expected` values and then sums
them ([match_payments.py:469](scripts/match_payments.py#L469)) and later does
`float(exp)` ([match_payments.py:480](scripts/match_payments.py#L480)) — both
blow up on the string `"?"`. Adults never hit this because adults never get
`"?"`, which is why only `/juniors` 500s.
Note the final-balance code at
[match_payments.py:511-512](scripts/match_payments.py#L511-L512) **already**
handles this defensively (`mdata["expected"] if isinstance(..., int) else 0`).
The allocation block added in the recent `fill-first` work simply missed the
same guard. The intended convention is clear: a `"?"` (unknown) expected counts
as **0** for arithmetic, so any payment landing on such a month becomes surplus
/ positive balance rather than filling a deficit.
## Approach
Add one small helper and apply the existing "non-numeric expected → 0"
convention consistently inside `reconcile()`.
### 1. Helper (near the top of `scripts/match_payments.py`)
```python
def _expected_amount(value):
"""Numeric value of an 'expected' fee; non-numeric markers like '?' → 0."""
return value if isinstance(value, (int, float)) else 0
```
### 2. Apply it in the allocation block
- [match_payments.py:469](scripts/match_payments.py#L469):
`total_expected = sum(_expected_amount(e) for _, e in in_window)`
- [match_payments.py:480](scripts/match_payments.py#L480):
`deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)`
With expected coerced to 0 for `"?"` months:
- A `"?"`-only payment falls into the existing `total_expected == 0` fallback
([match_payments.py:495-499](scripts/match_payments.py#L495-L499)) and is
recorded as paid (prepayment behaviour) → shows as positive balance.
- Mixed with real months, the `"?"` month gets deficit 0 (skipped), real months
fill first, surplus → credit. Consistent with line 512.
### 3. Reuse the helper at line 512 (optional consistency tidy)
Replace the inline `isinstance(mdata["expected"], int)` check at
[match_payments.py:512](scripts/match_payments.py#L512) with
`_expected_amount(mdata["paid"]/expected)` form, i.e. use `_expected_amount(...)`.
This also closes a latent gap where a **float** exception amount would be treated
as 0 (current check only accepts `int`).
`print_report` (line 552+) iterates adults only, so it's unaffected and needs no
change.
## Verification
1. `make web` and open `http://localhost:5001/juniors` — page renders 200 (was
500). Confirm a junior with a single-session `"?"` month who also has a
matched payment shows a sensible balance.
2. `/adults` still renders unchanged (regression check).
3. `make test` — existing reconcile tests still pass; if there is a
reconcile test fixture, add a case where a junior month has `expected == "?"`
plus a matched transaction and assert no exception + payment counted as
surplus.
## Notes
- Single-file change: [scripts/match_payments.py](scripts/match_payments.py).
- This is a bug fix; per CLAUDE.md a small fix may go straight to `main`, but
confirm with the user whether to branch (`fix/junior-expected-question-mark`).
- Add a CHANGELOG.md entry once confirmed working.
- On execution, copy this plan to `docs/plans/<ts>-junior-expected-fix.md` per
the repo plan convention.
@@ -1,146 +0,0 @@
# Plan: Limit /adults and /juniors to last N months (default 5)
## Context
The `/adults` and `/juniors` dashboard tables render one column per month of
attendance/fee history. As the season accumulates months, the tables grow wider
than the screen. The goal is to show only the **last N months** by default
(N = 5), with N configurable via an env var, so the tables fit on screen. This
must be implemented in **both** the Python/Flask version and the Go version,
keeping their behavior identical.
### Key correctness requirement
Member **balances, credits, and debts** must continue to reflect *all* history,
not just the visible window. Hiding older columns must not hide older debt.
This is naturally satisfied because in both codebases the balance math iterates
over the full per-member month map produced by reconcile, while only the
*column rendering* iterates over the passed-in month list:
- Python `scripts/views.py`: `settled_balance` / `_settled_balance` /
credits / debts loop over `data["months"].items()` (full), whereas columns,
totals, and per-row cells loop over `sorted_months`.
- Go `build_adults.go` / `build_juniors.go`: `settledBalance(mr, ...)` loops
over `mr.Months` (full); columns/totals/cells loop over `sortedMonths`.
Therefore the correct seam is: run `reconcile()` / `Reconcile()` on the **full**
month list, then trim the list to the last N **only for the view-model builder**.
The member-details modal also keeps full history because it reads the untrimmed
`member_data` / `MemberData`.
## Approach
Add a `MONTHS_TO_SHOW` tunable (default 5; `<= 0` means "show all" as an escape
hatch). Trim `sorted_months`/`sortedMonths` to the last N immediately before the
view-model builder, leaving reconcile on the full list.
### Python
1. **`scripts/config.py`** — add, next to the existing `CACHE_TTL_SECONDS`
pattern (`int(os.environ.get(...))`):
```python
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5))
```
2. **`app.py`** — add a small helper (module-level) and apply it in all four
routes that build the adults/juniors view models:
```python
from config import MONTHS_TO_SHOW # add to existing config import
def _last_n_months(months):
return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months
```
In each route, keep `reconcile(members, sorted_months, ...)` on the full
list, then pass the trimmed list to the builder:
```python
result = reconcile(members, sorted_months, transactions, exceptions)
display_months = _last_n_months(sorted_months)
vm = build_adults_view_model(members, display_months, result, ...)
```
Apply to: `adults_view()` (~`app.py:226`), `juniors_view()` (~`app.py:260`),
and the JSON twins `/api/adults` (~`app.py:161`) and `/api/juniors`
(~`app.py:184`) for parity.
No changes to `scripts/views.py` — it already derives `months`,
`raw_months`, `totals`, and per-row `row.months` from whatever month list it
receives, and balances/credits/debts from the full `result`.
### Go
1. **`go/internal/config/config.go`** — add `MonthsToShow int` to the `Config`
struct (~line 57-67), populate it in `Load()` (~line 80-90) with a new
integer helper modeled on `envDuration`:
```go
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
```
```go
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
```
(Note: unlike `envDuration`, accept `<= 0` so it can mean "show all".)
2. **`go/internal/web/api/handler.go`** — in `AssembleAdults`
(lines 50-57) and `AssembleJuniors` (lines 71-78), keep `Reconcile` on the
full `sortedMonths`, then trim before the builder:
```go
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
displayMonths := lastNMonths(sortedMonths, h.Config.MonthsToShow)
return buildAdultsResponse(members, displayMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
```
Add a small helper (e.g. in handler.go):
```go
func lastNMonths(months []string, n int) []string {
if n > 0 && len(months) > n {
return months[len(months)-n:]
}
return months
}
```
No changes to `build_adults.go` / `build_juniors.go` or the templates — they
already derive `Months`, `RawMonths`, `Totals`, and per-row cells from the
passed-in `sortedMonths`, and balances/credits/debts from the full
`result.Members[...]`.
## Critical files
- `scripts/config.py` — new `MONTHS_TO_SHOW` constant.
- `app.py` — trim helper + apply in 4 routes (HTML + JSON, adults + juniors).
- `go/internal/config/config.go` — `MonthsToShow` field + `envInt` helper.
- `go/internal/web/api/handler.go` — `lastNMonths` helper + apply in
`AssembleAdults` / `AssembleJuniors`.
No template or `views.py` / `build_*.go` changes required.
## Verification
Python:
- `make test` (and a targeted run, e.g.
`PYTHONPATH=scripts:. python -m unittest tests.test_app`).
- `make web`, open `/adults` and `/juniors`: confirm exactly 5 month columns by
default, and that the **Balance** column / credits / debts are unchanged from
before (compare against an untrimmed run, e.g. `MONTHS_TO_SHOW=0`).
- `MONTHS_TO_SHOW=3 make web` → 3 columns; `MONTHS_TO_SHOW=0` → all columns.
- Spot-check that the month-range filter dropdowns and a member-details modal
(full history) still work.
Go:
- `cd go && go build ./... && go test ./...`.
- Run the Go server, open `/adults` and `/juniors`: same checks as above
(default 5 columns, balances unchanged, `MONTHS_TO_SHOW` env override works).
- Confirm Python and Go render the same number of columns and identical
balances for the same data.
## Housekeeping
- Per `CLAUDE.md`, copy this plan to
`docs/plans/YYYY-MM-DD-HHMM-months-to-show.md` during implementation (create
`docs/plans/` if missing) and add a `CHANGELOG.md` entry once verified.
- Per `CLAUDE.md`, this is a feature → do it on a `feat/months-to-show` branch
and open a Gitea MR with `tea`; do not commit to `main`.
@@ -1,112 +0,0 @@
# Plan: Gitea Action to open a gitops image-update PR for fuj-management
## Context
The Go image of this app is built and pushed by the `build-go` job in
[.gitea/workflows/build.yaml](.gitea/workflows/build.yaml), tagged
`gitea.home.hrajfrisbee.cz/kacerr/fuj-management:<git-tag>-go` (e.g. `0.37-go`).
Kubernetes manifests live in a **separate** repo,
`gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes`. Today, bumping the image in
the `fuj-management` Deployment (namespace `fuj`) is a manual edit there.
We want CI to automate that bump: when a new Go image is built, open a PR against
`home-kubernetes` that swaps the image to the freshly built tag — using the
`uh-cli gitops deployment update` command. The user reviews/merges that PR in
Gitea (matching the existing branch-per-change, merge-in-browser workflow).
Decisions confirmed with the user:
- **Separate workflow file** (not a job inside build.yaml).
- **New `GITOPS_TOKEN` secret** for home-kubernetes write + PR access.
- **uh-cli version pinned with a default, overridable via env/var/input.**
## How uh-cli works (from `/Users/jan.novak/srv/go/uh-cli/docs/`)
- `uh-cli gitops deployment update` clones `--git-repo`, walks `--git-path`
recursively for a `kind: Deployment` whose `metadata.name`/`namespace` match,
edits the first container image surgically, commits on a new branch
`gitops/update-<name>-<timestamp>`, pushes, and **opens the PR itself** via
`tea pr create`. PR base is always `main`; title/body are hardcoded (no flags).
- Requires on PATH: `git` and `tea` (tea only for the PR flow; `--force` skips it).
- Auth: token embedded in the `--git-repo` URL (`https://user:TOKEN@host/...`);
`tea login add` for PR creation; git identity via `git config`/env vars.
- `--dry-run` prints the unified diff and makes no git changes. Global `-v`
(placed **before** the subcommand) enables debug logging on stderr.
- Release binaries are named `uh-cli-<version>-linux-amd64` (version includes the
`v`), attached to the Gitea release. Latest tag today is **`v0.1.0`**.
## Change: new workflow `.gitea/workflows/gitops-update.yaml`
Triggers:
- `workflow_run` on `workflows: ["Build and Push"]`, `types: [completed]`, gated
to `conclusion == 'success'` — auto-fires after the image build succeeds.
- `workflow_dispatch` with inputs: `tag` (git tag without the `-go` suffix, e.g.
`0.37`), `dry_run` (boolean, default false), `uh_cli_version` (optional override).
Single job `gitops-pr`, `runs-on: ubuntu-latest`, in a `container: ubuntu:latest`
for a hermetic install (matches the uh-cli CI doc pattern). Steps:
1. **Install git, curl, ca-certificates, tea** — apt-get + download tea
`0.9.2` from `gitea.com/gitea/tea/releases/...` to `/usr/local/bin/tea`.
2. **Install uh-cli** — download
`https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64`
to `/usr/local/bin/uh-cli`.
`UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}`.
3. **Resolve image tag** — if `workflow_dispatch`, use `inputs.tag`; else use
`github.event.workflow_run.head_branch` (the pushed tag name). Output
`gitea.home.hrajfrisbee.cz/${{ github.repository }}:<tag>-go`.
4. **Configure git identity** — `git config --global user.name/email` for the bot.
5. **Authenticate tea** — `tea login add --name ci --url https://gitea.home.hrajfrisbee.cz --token "$GITEA_TOKEN"`.
6. **Open image-update PR** — run, with `--dry-run` appended only when the
dispatch `dry_run` input is true:
```
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "<resolved image>" \
--git-repo "https://fuj-gitops-bot:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes
```
`GITEA_TOKEN` is sourced from `secrets.GITOPS_TOKEN` at job level.
Job-level guard: `if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}`.
## Prerequisites (user must set up in Gitea — call out in handoff)
1. **Create `GITOPS_TOKEN` secret** in the `fuj-management` repo: a Gitea token
for a user (`fuj-gitops-bot` or `kacerr`) that has **write + pull-request**
access to `kacerr/home-kubernetes`. The username in the `--git-repo` URL must
match that token's owner (adjust `fuj-gitops-bot` if using `kacerr`).
2. **uh-cli `v0.1.0` release assets must exist** (the `uh-cli-v0.1.0-linux-amd64`
binary attached to the release). If not yet published, cut that release in the
uh-cli repo first, or set `UH_CLI_VERSION` to a published tag.
3. **Confirm the manifest path**: `--git-path gitops/home-kubernetes` must contain
the `fuj-management` Deployment; `--deployment-namespace fuj` disambiguates.
Cannot verify from this repo — verify against home-kubernetes (narrow the path
if uh-cli reports an ambiguity error).
## Files
- **New**: `.gitea/workflows/gitops-update.yaml` (the workflow above).
- After it works: prepend a `CHANGELOG.md` entry; save this plan to
`docs/plans/<ts>-gitops-pr-action.md` per CLAUDE.md convention.
## Branching
Feature work → branch `feat/gitops-pr-action` off `main`, commit with the
`Co-Authored-By` trailer, push with `-u`, open the MR with
`tea pr create --base main --head feat/gitops-pr-action`. Do not merge from CLI.
## Verification
1. **Dry run (manual)**: trigger `gitops-update.yaml` via workflow_dispatch with
`tag=0.37`, `dry_run=true`. Confirm logs show the unified diff (image line
`…:0.37-go`) and `-v` debug milestones; **no PR** is created.
2. **Real run (manual)**: re-trigger with `dry_run=false`. Confirm a PR appears in
`home-kubernetes` against `main` with the image bump, and the PR URL is printed.
3. **Auto-trigger**: push a new git tag to fuj-management → `Build and Push`
completes → `gitops-update` fires via `workflow_run` and opens the PR.
(Note: `workflow_run`/`head_branch` behavior depends on this Gitea/act_runner
version; if it doesn't fire, manual `workflow_dispatch` is the fallback and the
plan still delivers the core capability.)
@@ -1,174 +0,0 @@
# Pre-feature cleanup — task list
Findings from a full repo audit on 2026-10-08, written up as tasks to clear
before new feature work starts. Companion to the Go rewrite tracker,
[2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md).
## State at audit time
- Two full implementations live side by side: Flask (`app.py`, `scripts/`,
`templates/`) and Go (`go/`). Recent changes were made in both.
- Update 2026-10-08: the Python code was moved to `legacy/` after this audit.
Python paths below are relative to `legacy/` (`build/Dockerfile` is now
`legacy/build/Dockerfile`).
- Python tests pass (32 tests). Go unit tests and the parity suite pass.
Go lint could not run (see T7).
- The gitops workflow bumps the cluster deployment to the `-go` image, so Go
appears to be what is deployed. This was not verified against the cluster.
- `CLAUDE.md` and all docs outside `docs/plans/` describe a Python-only
project. The rewrite tracker stops at M6; M7 and M8 are untouched.
## How to use
- Tick a checkbox when the task's PR or commit lands and append the SHA.
- Tasks in "Fix first" are small and independent of each other and of the
cutover decision. Do them in any order.
- T5 is a decision. Several cleanup tasks become unnecessary once it is made;
they are marked "moot if Python is retired".
---
## Fix first
- [x] **T1** Remove the secret leak in `.gitea/workflows/kubernetes-deploy.yaml`.
Workflow deleted on 2026-10-08. Still open for the owner: rotate the
credentials and remove the now-unused repo secrets (see below).
- It runs on every push to every branch and deploys nothing.
- It echoes the raw Vault login response, the Kanidm token response and the
OIDC token response body to the log, and dumps the environment twice.
- It uses `--insecure-skip-tls-verify=true`.
- Delete or disable the workflow. Rotate the Vault AppRole secret and the
Kanidm API token if anyone else can read the CI logs.
- 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
the leaked tokens until they are deleted or expire.
- [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
`go/internal/domain/fees/fees.go`.
- September and October 2026 are billed at the defaults: 700 CZK adults,
500 CZK juniors.
- If those are not the intended rates, add the months in both files.
- [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`).
- It stops picking up transactions on 2027-01-01. Go already uses the
current year.
- Same applies to the `sync-2026` Makefile target.
- Moot if Python is retired before then.
- [ ] **T4** Remove member names from tracked files (PII rule in `CLAUDE.md`).
- `CHANGELOG.md` line 29 names a member in a fix description.
- `tests/test_match_payments.py`, `tests/test_reconcile_exceptions.py` and
`go/internal/domain/reconcile/reconcile_test.go` use names that may be
real. Check them and replace with obviously fictional ones.
- Three files in `docs/plans/` contain the same names:
`2026-05-05-1717-payment-person-name-canonicalization.md`,
`2026-05-06-1626-infer-payments-junior-roster.md`,
`2026-05-11-2353-fill-first-multi-month-allocation.md`.
- The parity fixtures under `go/tests/fixtures/` are scrubbed and need no
change.
- Decide whether the names also need removing from git history.
## Decide before new features
- [ ] **T5** Decide how to finish the Python → Go cutover (M7 and M8 in the
rewrite tracker).
- Recommended: declare Go primary now, freeze Python, run one month-end
comparison of the balance report on both sides, then delete Python.
- Until this is done, every feature has to be written twice.
- Confirm first that the cluster really runs the `-go` image.
- [ ] **T6** Add a test job to CI.
- `.gitea/workflows/build.yaml` only builds images on tag push. Nothing
runs tests.
- Run `go test -race ./...`, the parity suite (`-tags=parity`) and
`golangci-lint` on pull requests.
- Stop building the Python image on every tag once Python is retired.
- [ ] **T7** Fix `make go-lint`.
- `go/.golangci.yml` is in the v1 config format. golangci-lint 2.12 refuses
to load it ("unsupported version of the configuration").
- Migrate the config to v2 and pin the linter version used in CI.
- [ ] **T8** Protect the side-effectful routes.
- Neither app has any authentication.
- `GET /sync-bank` writes to the payments sheet, so a link prefetcher or
crawler can trigger a sync. Change it to POST with a confirmation page,
as `/flush-cache` already does.
- Confirm that the ingress enforces access control, or add it in the app.
- The Go server uses `http.ListenAndServe` with no read, write or idle
timeouts (`go/internal/web/server.go`). Use an `http.Server` with
timeouts.
- [ ] **T9** Move fee rates and merged months out of code.
- Today a rate change needs a code change in two languages and a release.
- Candidate: a tab in the payments sheet, read the same way as
`exceptions`.
- This also removes the cause of T2.
## Cleanup
- [x] **T10** Rewrite `CLAUDE.md` for the current state: Go commands,
architecture and key modules, and which backend is primary. Done with the
move of Python to `legacy/`.
- [ ] **T11** Collapse the documentation into one set.
- `docs/by-claude-opus/`, `docs/by-gemini/` and the top-level docs overlap,
date from February–March 2026 and predate the Go rewrite.
- Update `docs/_sidebar.md` and `docs/README.md` to match.
- [ ] **T12** Update the rewrite tracker.
- M6.7 is unchecked although the changelog records it as done.
- M7.1 and M8.4 refer to a `docker-compose.yml` that does not exist.
- [ ] **T13** Remove leftovers: `.agent/rules.md` (Antigravity config) and
`prompts/`.
- [ ] **T14** Prune merged branches: 42 local and 44 remote branches are
fully merged into `main`.
- [ ] **T15** Clean up `.claude/settings.json`.
- It is tracked and contains absolute paths under one user's home
directory and one-off commands.
- Keep only shared, generic permissions there; move the rest to
`.claude/settings.local.json`, which is already ignored.
- [ ] **T16** Align versions.
- `pyproject.toml` says `0.10`; git tags are at `0.39`.
- `build/Dockerfile` pip-installs unpinned packages and ignores `uv.lock`.
- `go.mod` and `go/build/Dockerfile` are on Go 1.26; the local toolchain is
1.27.
- Dependencies on both sides are a few months behind.
- The Python items are moot if Python is retired.
- [ ] **T17** Stop the Python tests from calling live Google APIs.
- `warmup_cache()` runs at import of `app.py`, so `make test` makes real
Drive and Sheets read calls and needs the credentials file.
- Moot if Python is retired.
- [ ] **T18** Set the time zone in the Go container. Partly addressed
2026-10-08: the binary now embeds the zone database and the scheduled sync
uses `SYNC_TZ`; the "current month" logic below is still open.
- The image has no time zone configured, so `time.Now()` is UTC and the
"current month" flips one to two hours late at month boundaries.
- The Python image sets `Europe/Prague`.
- [ ] **T19** Decide where `fujarna/` lives.
- `fujarna/project.md` is untracked. It is a brief for a separate
tournament website and shares almost nothing with this codebase.
- Options: its own repository, or a clearly separate Go module here.
- [ ] **T20** Add tests for the untested Go packages: `cmd/fuj`,
`internal/io/sheets`, `internal/io/drive`, `internal/config` (about 590
lines in total).
## Found during validation of the `legacy/` move
Plan: [2026-10-08-1103-api-parity-vs-months-to-show.md](2026-10-08-1103-api-parity-vs-months-to-show.md).
- [x] **T21** Go rendered numeric VS cells in exponent form
(`2.609767878e+09`). Sheet text cells now go through `sheets.CellString`.
- [x] **T22** Python `/api/adults` and `/api/juniors` did not return
`months_to_show`. Backported; `make parity` reports 3/3 routes matching.
- [x] **T23** Write VS to the payments sheet as text. Both sync
implementations use `USER_ENTERED`, so Sheets stored VS as a number and
dropped leading zeros. New rows now carry a text marker; existing rows are
left as they are and are read correctly either way.
## Features added since the audit
- 2026-10-08 — Scheduled bank sync in the Go server, deliberately Go-only.
Plan: [2026-10-08-1216-scheduled-bank-sync.md](2026-10-08-1216-scheduled-bank-sync.md).
It also added a lock so two syncs cannot overlap (relevant to T8).
## Notes & decisions
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
@@ -1,112 +0,0 @@
# Fix the two Go/Python parity differences
## Context
Running `make parity` against both containers on 2026-10-08 reported
`0/3 routes match`. Two causes, neither related to the move to `legacy/`:
1. **VS rendered in scientific notation by Go.** The payments sheet is read
with `UNFORMATTED_VALUE`, so a numeric VS cell arrives as `float64`. Go
stringifies cells with `fmt.Sprint`, which prints any float ≥ 1e6 in
exponent form (`2.609767878e+09`). Python already converts integer floats
to plain digits for VS (`get_str` in `fetch_sheet_data`). VS must always be
a plain string.
2. **`months_to_show` missing from the Python JSON API.** Go returns it in
`/api/adults` and `/api/juniors` (and it is `required` in the committed
schemas). Python already has the feature in its HTML pages
(`MONTHS_TO_SHOW` is passed to the templates) but never added the key to
the API responses. Backport the key so the API contract matches.
Outcome: `make parity` reports 3/3 routes matching.
## Branching
Both are bug fixes that touch `legacy/` paths, so they depend on the
uncommitted `feat/python-to-legacy` work. Commit that branch first (still
awaiting confirmation), then branch `fix/api-parity-vs-months-to-show` from
it, with one commit per task. Open the MR with `tea` after the legacy move is
merged, or target it at that branch.
## Task 1 — VS (and other text cells) always a plain string in Go
Root cause is `getVal` in `parseTransactionRows`
(`go/internal/services/membership/sources.go`, the `fmt.Sprint(row[i])`
closure). The same pattern exists in `get` in
`go/internal/services/banksync/infer.go`.
1. Add `CellString(v any) string` to `go/internal/io/sheets` (new small file
`cell.go`; both services already import this package):
- `nil` → `""`
- `float64` → `strconv.FormatFloat(x, 'f', -1, 64)` (never exponent form;
`2609767878.0` → `"2609767878"`, `12.5` → `"12.5"`)
- `string` → unchanged
- anything else → `fmt.Sprint(v)`
2. Use it in `getVal` in `sources.go`. This fixes VS and protects the other
text columns (Message, Bank ID, Sender, …) from the same failure.
- `getVal` currently returns `"<nil>"` for nil cells and the inferred-amount
code checks for that literal; simplify that check to `iv != ""`.
- Leave `getRaw`/`FormatDate` and `parseFloat` for Date and Amount as they
are — those must stay numeric.
3. Use it in `get` in `banksync/infer.go` (keeping the `TrimSpace`), so
inference sees the same text the dashboard shows.
4. Tests:
- `go/internal/io/sheets/cell_test.go`: table test for `CellString`
(large integer float, small integer float, fractional, string with
leading zeros, nil).
- Extend `TestLoadTransactions` in
`go/internal/services/membership/sources_test.go` with a row whose VS is
`2609767878.0` and whose Bank ID is an 11-digit float; assert plain
digit strings.
5. Python: no code change needed — `get_str` already does this for VS. Add one
regression test for `fetch_sheet_data` in
`legacy/tests/test_match_payments.py` (mock the Sheets service, numeric VS
cell → `"2609767878"`) so the two sides are pinned to the same behaviour.
Not included (note for later): VS is *written* to the sheet with
`USER_ENTERED` by both sync implementations, so Sheets stores it as a number
and drops leading zeros. Fixing that means writing VS as text and is a
separate change to the sheet's data.
## Task 2 — backport `months_to_show` to the Python JSON API
`legacy/app.py` only; `MONTHS_TO_SHOW` is already imported there.
1. In `api_adults()` and `api_juniors()`, add the key to the response:
build `out = _unwrap_view_model_for_api(vm)`, set
`out["months_to_show"] = MONTHS_TO_SHOW`, return `jsonify(out)`.
- Do not add it inside `build_*_view_model` or `_unwrap_view_model_for_api`:
the HTML routes already pass `months_to_show=` to `render_template`
alongside `**vm`, and a duplicate key would raise `TypeError`.
- `/api/payments` is unchanged; Go does not return the key there.
2. Tests in `legacy/tests/test_app.py`:
- Add `"months_to_show"` to the expected key list used by
`test_api_adults` / `test_api_juniors` (the tuple near the top of the
file), and assert the value is an `int`.
3. Go: no change. The key and schemas
(`go/tests/fixtures/api-schema/{adults,juniors}.schema.json`) already
contain it.
## Wrap-up
- `CHANGELOG.md` entry at the top (after confirmation it works), per repo
convention.
- Copy this plan to
`docs/plans/2026-10-08-HHMM-api-parity-vs-months-to-show.md`, per the repo's
plan-location rule.
- Add both items to
`docs/plans/2026-10-08-1026-pre-feature-cleanup-tasks.md` as done tasks so
the audit list stays complete.
## Verification
1. `make go-test-all` — unit tests (including the new ones) and parity
fixtures pass.
2. `make test` — Python tests pass, including the new API key and VS tests.
3. `cd go && go test ./internal/web/api/...` — schema golden test still
matches (no schema change expected).
4. End to end: start `make web-py` and `make web-go`, then `make parity`;
expect `3/3 routes match`. Alternatively repeat the two-container run used
to find the bugs (`make image`, Go image from `go/build/Dockerfile`, then
`go run ./cmd/parity -py … -go …`).
5. Open `/payments` on :8080 and check the affected transaction's VS shows as
plain digits.
@@ -1,134 +0,0 @@
# Scheduled bank sync in the Go server (Go only)
## Context
Bank synchronization only runs when someone opens `/sync-bank`; the payments
sheet is as fresh as the last click. The Go server should run the same
sync → infer → flush-cache sequence by itself on a configured schedule,
defaulting to once a day.
Decisions already made:
- **Go only.** Deliberately not implemented in the legacy Python app, which is
being phased out (gunicorn's multi-worker model would also need a
cross-process lock). This is recorded as an explicit exception to the dual
implementation rule.
- **Schedule format:** one or more times of day. On by default, once a day.
## Behaviour
- `SYNC_SCHEDULE` — comma-separated `HH:MM` times, default `04:00`.
`off` disables the scheduler. An invalid value makes `fuj server` exit at
startup with a clear error.
- `SYNC_TZ` — IANA zone the times are read in, default `Europe/Prague`.
The binary embeds the zone database (`import _ "time/tzdata"`), because the
Alpine image has none.
- At each scheduled time the server runs exactly what `/sync-bank` runs
(`banksync.DefaultWindow`, `SyncToSheets`, `InferPayments`, cache flush),
with a 10-minute timeout, and logs start, result and row counts via `slog`.
- Nothing runs at startup; a restart only recomputes the next run time.
- A scheduled run and a manual `/sync-bank` can no longer overlap: both go
through one mutex. If a sync is already running, the manual page shows
"a sync is already running" and a scheduled run is skipped with a log line.
(This also closes today's gap where two overlapping manual runs could append
the same rows.)
- The footer of every page shows the last sync since the server started:
time, trigger (scheduled / manual), ok or failed, and the next scheduled
run. Status is in memory only and resets on restart.
## Implementation
Branch `feat/scheduled-bank-sync` off `main`; MR via `tea`.
1. **Config** — `go/internal/config/config.go`
- Add `SyncSchedule []TimeOfDay` and `SyncLocation *time.Location` to
`Config`.
- Add `ParseSyncSchedule(s string) ([]TimeOfDay, error)` (`"off"` → empty,
sorted, de-duplicated, rejects anything not `HH:MM`) and a
`LoadSyncSchedule() (…, error)` reading `SYNC_SCHEDULE` / `SYNC_TZ`.
`Load()` keeps its signature; `serverCmd` calls the new loader and exits
on error.
- New `config_test.go` for the parser (first tests in this package).
2. **Scheduler** — new package `go/internal/scheduler`
- `Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time` —
pure; builds candidates with `time.Date` in `loc` for today and tomorrow
and returns the earliest one strictly after `now` (handles DST days).
- `Run(ctx, times, loc, logger, job func(context.Context))` — loop:
compute `Next`, wait on a timer or `ctx.Done()`, call `job`.
- Tests: `Next` table test (before/after today's time, multiple times,
midnight rollover, the two DST switch days in Europe/Prague); `Run` with
a cancelled context returns.
3. **Sync runner with lock and status** — new
`go/internal/services/banksync/runner.go`
- `Runner` wraps the existing sync+infer function (moved out of the
closure in `go/cmd/fuj/main.go`) with a `sync.Mutex` (`TryLock`) and
records `Status{Started, Finished, Trigger, Err}`.
- `Run(ctx, trigger string, out io.Writer) error` returns `ErrBusy` when a
run is in progress. `Last() (Status, bool)` for display.
- Tests with a fake job: status recorded, error recorded, second
concurrent call gets `ErrBusy`.
4. **Wiring** — `go/cmd/fuj/main.go`
- Add `import _ "time/tzdata"`.
- Build one `Runner`; `ActionHandlers.BankSync` calls
`runner.Run(ctx, "manual", out)` (signature unchanged, so
`TestServeSync` keeps working).
- If the schedule is non-empty, start `go scheduler.Run(...)` whose job
calls `runner.Run(ctx, "scheduled", &buf)` with a 10-minute timeout, then
`sources.FlushCache()`, and logs the outcome. Log the schedule and next
run at startup, or "scheduled sync disabled".
5. **Display** — `go/internal/web`
- `ActionHandlers` gains optional `SyncInfo func() string` (last + next run
as one line). `PageData` gains `SyncInfo string`; add a small
`h.page(active)` helper in `html_handler.go` and use it where
`PageData{Active: …, Build: h.build}` is built today.
- `templates/partials/footer.tmpl`: append the line when non-empty.
- `templates/sync.tmpl`: fix the description, which still says "current
year" (stale since the window change).
- Extend `html_handler_test.go`: footer shows the info when provided,
nothing extra when not.
6. **Docs and notes**
- `CLAUDE.md`: document `SYNC_SCHEDULE` / `SYNC_TZ` under the Go config
list and the commands/architecture text; under "Dual implementation
rule" add a "Deliberately Go-only" list with this feature and the reason.
- `legacy/app.py`: one comment above `sync_bank()` stating that scheduled
sync exists only in Go, on purpose.
- `CHANGELOG.md` entry; copy this plan to
`docs/plans/2026-10-08-HHMM-scheduled-bank-sync.md`.
- Add a note to the cleanup task list: T18 (time zone) is addressed for
the scheduler only; the "current month" logic still uses the process
clock.
No new dependencies. No Dockerfile change. No change to parity fixtures or
the JSON API.
## Deployment notes (for the MR description)
- On by default at 04:00 Europe/Prague. Set `SYNC_SCHEDULE=off` to disable.
- A locally running `make web-go` with real credentials will also sync at
that time.
- With more than one replica each pod would run its own schedule; the
deployment must stay at one replica (or disable the schedule on extras).
- Without `FIO_API_TOKEN_*` the sync scrapes the transparent-account pages,
as today.
## Verification
1. `make go-test-all` and `make go-lint` equivalent (`go vet`, `gofmt -l`);
`make test` to confirm Python is untouched.
2. Start `fuj server` with `SYNC_SCHEDULE=off` → log says disabled, footer
shows no schedule.
3. Start with an invalid value (`SYNC_SCHEDULE=25:00`) → exits with an error.
4. Live check: start with `SYNC_SCHEDULE` set two minutes ahead and watch the
log for the scheduled run and the footer for the status line. **This
performs one real sync** against the payments sheet (currently one pending
transaction would be appended) — the same thing a click on `/sync-bank`
does.
5. While that run is in progress, or with a test job, confirm a second
trigger reports "already running".
6. Build the Go image and confirm the schedule log line shows the Prague
zone inside the container (proves the embedded zone data works).
+9 -95
View File
@@ -1,25 +1,20 @@
package main package main
import ( import (
"bytes"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"fuj-management/go/internal/config" "fuj-management/go/internal/config"
"fuj-management/go/internal/io/fio" "fuj-management/go/internal/io/fio"
"fuj-management/go/internal/io/sheets" "fuj-management/go/internal/io/sheets"
"fuj-management/go/internal/logging" "fuj-management/go/internal/logging"
"fuj-management/go/internal/scheduler"
"fuj-management/go/internal/services/banksync" "fuj-management/go/internal/services/banksync"
"fuj-management/go/internal/services/membership" "fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web" "fuj-management/go/internal/web"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"strings"
"time" "time"
_ "time/tzdata" // the container image has no zone database; SYNC_TZ needs one
) )
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..." // Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
@@ -93,22 +88,16 @@ func serverCmd(args []string) {
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err) fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fioClients := buildFioClients(cfg) fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
syncTimes, syncLoc, err := config.LoadSyncSchedule() actions := web.ActionHandlers{
if err != nil { BankSync: func(ctx context.Context, out io.Writer) error {
fmt.Fprintf(os.Stderr, "fuj server: %v\n", err) yr := time.Now().Year()
os.Exit(2) from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
} to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
// One runner serves both the /sync-bank page and the scheduler, so the two
// can never run at the same time.
runner := banksync.NewRunner(
func(ctx context.Context, out io.Writer) error {
from, to := banksync.DefaultWindow(time.Now())
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, fioCli, sheetsCli,
banksync.SyncOpts{From: from, To: to, Sort: true}) banksync.SyncOpts{From: from, To: to, Sort: true})
if err != nil { if err != nil {
return fmt.Errorf("sync: %w", err) return fmt.Errorf("sync: %w", err)
@@ -123,22 +112,6 @@ func serverCmd(args []string) {
fmt.Fprintf(out, "Inferred %d row(s).\n", n) fmt.Fprintf(out, "Inferred %d row(s).\n", n)
return nil return nil
}, },
)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
return runner.Run(ctx, "manual", out)
},
SyncInfo: func() string { return syncInfo(runner, syncTimes, syncLoc, time.Now()) },
}
if len(syncTimes) == 0 {
logger.Info("scheduled bank sync disabled (SYNC_SCHEDULE=off)")
} else {
logger.Info("scheduled bank sync enabled", "times", fmt.Sprint(syncTimes), "tz", syncLoc.String())
go scheduler.Run(ctx, syncTimes, syncLoc, logger, func(ctx context.Context) {
scheduledSync(ctx, logger, runner, sources)
})
} }
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate} build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
@@ -149,55 +122,6 @@ func serverCmd(args []string) {
} }
} }
// scheduledSyncTimeout bounds one scheduled run (two bank fetches plus sheet writes).
const scheduledSyncTimeout = 10 * time.Minute
// scheduledSync is the scheduler's job: the same sync → infer → flush-cache
// sequence as the /sync-bank page, with the outcome written to the log.
func scheduledSync(ctx context.Context, logger *slog.Logger, runner *banksync.Runner, sources membership.Sources) {
ctx, cancel := context.WithTimeout(ctx, scheduledSyncTimeout)
defer cancel()
logger.Info("scheduled bank sync starting")
var buf bytes.Buffer
err := runner.Run(ctx, "scheduled", &buf)
if errors.Is(err, banksync.ErrBusy) {
logger.Warn("scheduled bank sync skipped: a sync is already running")
return
}
output := strings.Join(strings.Fields(buf.String()), " ")
if _, ferr := sources.FlushCache(); ferr != nil {
logger.Error("scheduled bank sync: cache flush failed", "err", ferr)
}
if err != nil {
logger.Error("scheduled bank sync failed", "err", err, "output", output)
return
}
logger.Info("scheduled bank sync finished", "output", output)
}
// syncInfo renders the footer line: the last run since the server started
// and the next scheduled one.
func syncInfo(runner *banksync.Runner, times []scheduler.TimeOfDay, loc *time.Location, now time.Time) string {
const layout = "2006-01-02 15:04"
var parts []string
if st, ok := runner.Last(); ok {
result := "ok"
if st.Err != nil {
result = "FAILED"
}
parts = append(parts, fmt.Sprintf("last sync %s (%s) %s", st.Finished.In(loc).Format(layout), st.Trigger, result))
} else {
parts = append(parts, "no sync since start")
}
if next := scheduler.Next(now, times, loc); !next.IsZero() {
parts = append(parts, "next "+next.Format(layout))
} else {
parts = append(parts, "scheduled sync off")
}
return strings.Join(parts, " | ")
}
func feesCmd(args []string) { func feesCmd(args []string) {
fs := flag.NewFlagSet("fees", flag.ExitOnError) fs := flag.NewFlagSet("fees", flag.ExitOnError)
fs.Usage = func() { fs.Usage = func() {
@@ -273,7 +197,7 @@ func syncCmd(args []string) {
fmt.Fprintf(os.Stderr, "fuj sync: sheets client: %v\n", err) fmt.Fprintf(os.Stderr, "fuj sync: sheets client: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fioClients := buildFioClients(cfg) fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable} opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable}
if *fromStr != "" && *toStr != "" { if *fromStr != "" && *toStr != "" {
@@ -289,7 +213,7 @@ func syncCmd(args []string) {
} }
} }
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli, opts) n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli, opts)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err) fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -350,13 +274,3 @@ Commands:
sync Sync Fio transactions to payments sheet sync Sync Fio transactions to payments sheet
infer Infer payment details in payments sheet`) infer Infer payment details in payments sheet`)
} }
// buildFioClients constructs one fio.Client per configured account.
// Each client uses the account's token if available, otherwise the transparent-scraper path.
func buildFioClients(cfg config.Config) []fio.Client {
clients := make([]fio.Client, 0, len(cfg.LoadedAccounts))
for _, a := range cfg.LoadedAccounts {
clients = append(clients, fio.New(a.Token, a.AcctNum, nil))
}
return clients
}
-35
View File
@@ -1,35 +0,0 @@
package main
import (
"context"
"errors"
"fuj-management/go/internal/scheduler"
"fuj-management/go/internal/services/banksync"
"io"
"strings"
"testing"
"time"
)
func TestSyncInfo(t *testing.T) {
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
four := []scheduler.TimeOfDay{{Hour: 4}}
ok := banksync.NewRunner(func(context.Context, io.Writer) error { return nil })
if got := syncInfo(ok, four, time.UTC, now); got != "no sync since start | next 2026-10-09 04:00" {
t.Errorf("before any run: %q", got)
}
if err := ok.Run(context.Background(), "scheduled", io.Discard); err != nil {
t.Fatal(err)
}
got := syncInfo(ok, nil, time.UTC, now)
if !strings.HasPrefix(got, "last sync ") || !strings.HasSuffix(got, "(scheduled) ok | scheduled sync off") {
t.Errorf("after ok run, schedule off: %q", got)
}
bad := banksync.NewRunner(func(context.Context, io.Writer) error { return errors.New("boom") })
_ = bad.Run(context.Background(), "manual", io.Discard)
if got := syncInfo(bad, four, time.UTC, now); !strings.Contains(got, "(manual) FAILED | next 2026-10-09 04:00") {
t.Errorf("after failed run: %q", got)
}
}
+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 (legacy/app.py inject_render_time) and is logged via // template context only (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"}
+6 -71
View File
@@ -1,36 +1,12 @@
package config package config
import ( import (
"fmt"
"fuj-management/go/internal/scheduler"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
// Account describes a Fio bank account.
type Account struct {
IBAN string // e.g. "CZ0820100000002502035405"
AcctNum string // bare account number, e.g. "2502035405"
TokenEnv string // env var name holding the optional Fio API token
Primary bool // true for the account QR codes default to
}
// LoadedAccount is an Account with its token resolved from the environment.
type LoadedAccount struct {
Account
Token string // value of os.Getenv(Account.TokenEnv); empty → transparent-scraper path
}
// Accounts is the hardcoded list of Fio bank accounts to sync from.
// The first entry with Primary=true is used for QR codes.
// Tokens are loaded at runtime from each account's TokenEnv.
var Accounts = []Account{
{IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true},
{IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"},
}
// Google Sheets IDs — change in code if sheets change (not from env). // Google Sheets IDs — change in code if sheets change (not from env).
const ( const (
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA" AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
@@ -44,7 +20,7 @@ const (
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd) JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
) )
// CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP. // CacheSheetMap mirrors 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{
@@ -55,67 +31,35 @@ var CacheSheetMap = map[string]string{
} }
// Config holds all runtime configuration loaded from environment variables. // Config holds all runtime configuration loaded from environment variables.
// Mirrors legacy/scripts/config.py. // Mirrors scripts/config.py.
type Config struct { type Config struct {
CredentialsPath string CredentialsPath string
QRAccount string // IBAN of the primary account used for QR codes BankAccount string
LoadedAccounts []LoadedAccount // all accounts to sync, tokens resolved from env
CacheDir string CacheDir string
CacheTTL time.Duration CacheTTL time.Duration
CacheAPICheckTTL time.Duration CacheAPICheckTTL time.Duration
DriveTimeout time.Duration DriveTimeout time.Duration
LogLevel string LogLevel string
FioAPIToken string
ServerAddr string ServerAddr string
MonthsToShow int // show last N month columns; 0 means show all
} }
// Load reads configuration from the environment, applying defaults that // Load reads configuration from the environment, applying defaults that
// match the Python side. // match the Python side.
func Load() Config { func Load() Config {
loaded := make([]LoadedAccount, len(Accounts))
var qrAccount string
for i, a := range Accounts {
loaded[i] = LoadedAccount{Account: a, Token: os.Getenv(a.TokenEnv)}
if a.Primary {
qrAccount = a.IBAN
}
}
return Config{ return Config{
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"), CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
QRAccount: qrAccount, BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
LoadedAccounts: loaded,
CacheDir: env("CACHE_DIR", "tmp/go"), CacheDir: env("CACHE_DIR", "tmp/go"),
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300), CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300), CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10), DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
LogLevel: env("LOG_LEVEL", "INFO"), LogLevel: env("LOG_LEVEL", "INFO"),
FioAPIToken: env("FIO_API_TOKEN", ""),
ServerAddr: env("SERVER_ADDR", ":8080"), ServerAddr: env("SERVER_ADDR", ":8080"),
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
} }
} }
// Scheduled bank sync defaults (Go server only; the legacy Python app has no scheduler).
const (
DefaultSyncSchedule = "04:00" // SYNC_SCHEDULE: comma-separated HH:MM times, or "off"
DefaultSyncTimezone = "Europe/Prague" // SYNC_TZ: IANA zone the schedule is read in
)
// LoadSyncSchedule reads SYNC_SCHEDULE and SYNC_TZ. An empty schedule means
// the scheduled sync is disabled. Resolving the zone needs a zone database:
// the server binary embeds one via time/tzdata.
func LoadSyncSchedule() ([]scheduler.TimeOfDay, *time.Location, error) {
times, err := scheduler.ParseTimes(env("SYNC_SCHEDULE", DefaultSyncSchedule))
if err != nil {
return nil, nil, fmt.Errorf("SYNC_SCHEDULE: %w", err)
}
tz := env("SYNC_TZ", DefaultSyncTimezone)
loc, err := time.LoadLocation(tz)
if err != nil {
return nil, nil, fmt.Errorf("SYNC_TZ: unknown time zone %q", tz)
}
return times, loc, nil
}
// IBANAccountNum extracts the bare account number from a Czech IBAN. // IBANAccountNum extracts the bare account number from a Czech IBAN.
// "CZ8520100000002800359168" → "2800359168" // "CZ8520100000002800359168" → "2800359168"
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account). // Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
@@ -147,12 +91,3 @@ func envDuration(key string, defaultSeconds int) time.Duration {
} }
return time.Duration(defaultSeconds) * time.Second return time.Duration(defaultSeconds) * time.Second
} }
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
-45
View File
@@ -1,45 +0,0 @@
package config
import (
"testing"
)
func TestLoadSyncSchedule(t *testing.T) {
cases := []struct {
name, schedule, tz string
wantTimes int
wantZone string
wantErr bool
}{
{"defaults", "", "", 1, "Europe/Prague", false},
{"two times", "06:00,18:00", "", 2, "Europe/Prague", false},
{"off", "off", "", 0, "Europe/Prague", false},
{"other zone", "04:00", "UTC", 1, "UTC", false},
{"bad time", "25:00", "", 0, "", true},
{"bad zone", "04:00", "Mars/Olympus", 0, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SYNC_SCHEDULE", tc.schedule)
t.Setenv("SYNC_TZ", tc.tz)
times, loc, err := LoadSyncSchedule()
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if len(times) != tc.wantTimes {
t.Errorf("times = %v, want %d entries", times, tc.wantTimes)
}
if loc.String() != tc.wantZone {
t.Errorf("zone = %s, want %s", loc, tc.wantZone)
}
})
}
t.Setenv("SYNC_SCHEDULE", "")
t.Setenv("SYNC_TZ", "")
if times, _, _ := LoadSyncSchedule(); len(times) != 1 || times[0].String() != DefaultSyncSchedule {
t.Errorf("default schedule = %v, want [%s]", times, DefaultSyncSchedule)
}
}
@@ -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=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))' // PYTHONPATH=scripts:. 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
+10 -13
View File
@@ -1,37 +1,34 @@
// Package fees ports fee calculation from legacy/scripts/attendance.py. // Package fees ports fee calculation from scripts/attendance.py.
package fees package fees
const ( const (
AdultFeeDefault = 600 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
AdultFeeSingle = 200 // CZK for exactly 1 practice AdultFeeSingle = 200 // CZK for exactly 1 practice
) )
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py. // AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in 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,
"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": 0, "2026-08": 0, "2026-04": 700, "2026-05": 700,
"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), capped at the month's rate so a // 1 practice → AdultFeeSingle (200)
// 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 min(AdultFeeSingle, rate) return AdultFeeSingle
} }
return rate if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
return rate
}
return AdultFeeDefault
} }
+10 -28
View File
@@ -5,42 +5,24 @@ import "testing"
func TestCalculateFee(t *testing.T) { func TestCalculateFee(t *testing.T) {
t.Parallel() t.Parallel()
// mustRate returns the configured rate for a month that must be in the map. // All expected outputs verified against live Python implementation on 2026-05-06:
// It panics immediately if the month is absent — so if a rate entry is ever // PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
// removed, the test fails loudly rather than silently comparing against
// Go's zero value.
mustRate := func(month string) int {
r, ok := AdultFeeMonthlyRate[month]
if !ok {
panic("test month not in AdultFeeMonthlyRate: " + month)
}
return r
}
tests := []struct { tests := []struct {
name string name string
count int count int
month string month string
want int want int
}{ }{
// 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, capped at the month's rate. {"single practice", 1, "2026-05", 200},
{"single practice", 1, "2026-05", AdultFeeSingle}, {"single ignores monthKey", 1, "unknown", 200},
{"single unknown month", 1, "unknown", AdultFeeSingle}, {"two practices configured month", 2, "2026-05", 700},
{"single in free month is free", 1, "2026-07", 0}, {"two practices reduced march", 2, "2026-03", 350},
{"two in free month is free", 2, "2026-08", 0}, {"two practices early season", 2, "2025-09", 750},
// Two+ practices for a configured month: must use the map value, not the default. {"high count same as two", 5, "2026-05", 700},
// Expected values are read from AdultFeeMonthlyRate so this test stays correct {"unknown future month falls back", 2, "2027-01", 700},
// when rates are updated — the assertion verifies dispatch logic, not rate values. {"empty month falls back", 2, "", 700},
{"two practices configured month", 2, "2026-05", mustRate("2026-05")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"two practices early season", 2, "2025-09", mustRate("2025-09")},
{"high count same as two", 5, "2026-05", mustRate("2026-05")},
// Two+ practices for an unknown/future month: must fall back to AdultFeeDefault.
{"unknown future month falls back", 2, "2027-01", AdultFeeDefault},
{"empty month falls back", 2, "", AdultFeeDefault},
} }
for _, tc := range tests { for _, tc := range tests {
+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 legacy/scripts/attendance.py. // JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in 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,
+8 -21
View File
@@ -5,37 +5,24 @@ import "testing"
func TestCalculateJuniorFee(t *testing.T) { func TestCalculateJuniorFee(t *testing.T) {
t.Parallel() t.Parallel()
// mustRate returns the configured rate for a month that must be in the map. // All expected outputs verified against live Python implementation on 2026-05-06:
// Panics immediately if the month is absent so a removed entry causes a loud // PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
// failure rather than a silent comparison against Go's zero value.
mustRate := func(month string) Expected {
r, ok := JuniorFeeMonthlyRate[month]
if !ok {
panic("test month not in JuniorFeeMonthlyRate: " + month)
}
return Expected{Value: r}
}
tests := []struct { tests := []struct {
name string name string
count int count int
month string month string
want Expected want Expected
}{ }{
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}}, {"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
{"zero empty month", 0, "", Expected{Value: 0}}, {"zero empty month", 0, "", Expected{Value: 0}},
// Single practice returns the Unknown sentinel regardless of month.
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}}, {"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}}, {"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
// Two+ practices for a configured month: must use the map value, not the default. {"two practices default month", 2, "2026-05", Expected{Value: 500}},
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}}, {"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")}, {"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")}, {"high count same as two", 5, "2025-09", Expected{Value: 250}},
{"high count same as two", 5, "2025-09", mustRate("2025-09")}, {"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault. {"empty month falls back", 2, "", Expected{Value: 500}},
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
} }
for _, tc := range tests { for _, tc := range tests {
+1 -1
View File
@@ -1,2 +1,2 @@
// Package matching ports name/member matching from legacy/scripts/match_payments.py. // Package matching ports name/member matching from 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 legacy/scripts/match_payments.py format_date. // Ports 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 legacy/scripts/match_payments.py on 2026-05-06: // Expected values verified against scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=legacy/scripts:legacy python3 -c ' // PYTHONPATH=scripts:. 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 legacy/scripts/match_payments.py infer_transaction_details. // Ports 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 legacy/scripts/match_payments.py on 2026-05-06: // Expected values verified against scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=legacy/scripts:legacy python3 << 'EOF' // PYTHONPATH=scripts:. 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 legacy/scripts/match_payments.py match_members): // Algorithm (ported verbatim from 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 legacy/scripts/match_payments.py and // Expected values verified against scripts/match_payments.py and
// tests/test_match_members.py on 2026-05-06: // tests/test_match_members.py on 2026-05-06:
// //
// PYTHONPATH=legacy/scripts:legacy python3 -c ' // PYTHONPATH=scripts:. 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 legacy/scripts/match_payments.py _build_name_variants. // Ports 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 legacy/scripts/match_payments.py on 2026-05-06: // Expected values verified against scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=legacy/scripts:legacy python3 -c ' // PYTHONPATH=scripts:. 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 legacy/scripts/infer_payments.py. // Package money ports Czech-locale currency parsing from 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 legacy/scripts/infer_payments.py parse_czk_amount: // as a float64. Mirrors 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=legacy/scripts:legacy python -c ' // PYTHONPATH=scripts:. 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 legacy/scripts/match_payments.py. // Package reconcile ports the three-phase payment reconciliation from 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 legacy/scripts/match_payments.py canonical_member_key. // Ports 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 legacy/scripts/match_payments.py reconcile. // Ports 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 legacy/scripts/match_payments.py on 2026-05-06: // Expected values verified against scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v // PYTHONPATH=scripts:. 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
// legacy/scripts/sync_fio_to_sheets.py. // 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 legacy/scripts/sync_fio_to_sheets.py generate_sync_id. // Byte-stable with 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=legacy/scripts:legacy python -c ' // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python -c ' // PYTHONPATH=scripts:. 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 legacy/scripts/cache_utils.py. // mirroring 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 legacy/scripts/cache_utils.py: // Two TTL knobs mirror 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 legacy/scripts/fio_utils.py fetch_transactions_api. // Ports 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 legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent. // Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
type transparentClient struct { type transparentClient struct {
accountNum string accountNum string
hc httpDoer hc httpDoer
-33
View File
@@ -1,33 +0,0 @@
package sheets
import (
"fmt"
"strconv"
)
// CellString renders a cell read with UNFORMATTED_VALUE as text.
// Numeric cells arrive as float64; fmt.Sprint would print large ones in
// exponent form ("2.609767878e+09"), which corrupts identifiers such as VS
// or Bank ID. Mirrors get_str in legacy/scripts/match_payments.py.
func CellString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
}
return fmt.Sprint(v)
}
// TextCell marks s to be stored as text when written with USER_ENTERED:
// a leading apostrophe stops Sheets from parsing digits as a number (which
// would drop leading zeros). The apostrophe is not part of the stored value.
// Empty stays empty. Mirrors as_text_cell in legacy/scripts/sync_fio_to_sheets.py.
func TextCell(s string) string {
if s == "" {
return ""
}
return "'" + s
}
-36
View File
@@ -1,36 +0,0 @@
package sheets
import "testing"
func TestTextCell(t *testing.T) {
if got := TextCell("0308"); got != "'0308" {
t.Errorf(`TextCell("0308"): got %q`, got)
}
if got := TextCell(""); got != "" {
t.Errorf(`TextCell(""): got %q`, got)
}
}
func TestCellString(t *testing.T) {
cases := []struct {
name string
in any
want string
}{
{"nil", nil, ""},
{"string", "fee", "fee"},
{"string with leading zeros", "0308", "0308"},
{"small integer float", 123.0, "123"},
{"large integer float", 2609767878.0, "2609767878"},
{"eleven digit float", 26123456789.0, "26123456789"},
{"fractional float", 12.5, "12.5"},
{"bool", true, "true"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := CellString(tc.in); got != tc.want {
t.Errorf("CellString(%v): want %q, got %q", tc.in, tc.want, got)
}
})
}
}
-95
View File
@@ -1,95 +0,0 @@
// Package scheduler runs a job at fixed times of day.
package scheduler
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
)
// TimeOfDay is a wall-clock time without a date.
type TimeOfDay struct {
Hour, Minute int
}
func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) }
// ParseTimes parses a comma-separated list of HH:MM times, e.g. "04:00" or
// "06:00,18:00". "off" yields an empty schedule. The result is sorted and
// de-duplicated.
func ParseTimes(s string) ([]TimeOfDay, error) {
s = strings.TrimSpace(s)
if strings.EqualFold(s, "off") {
return nil, nil
}
seen := map[TimeOfDay]bool{}
var times []TimeOfDay
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
parsed, err := time.Parse("15:04", part)
if err != nil {
return nil, fmt.Errorf("invalid time %q: want HH:MM, a comma-separated list of them, or \"off\"", part)
}
t := TimeOfDay{Hour: parsed.Hour(), Minute: parsed.Minute()}
if !seen[t] {
seen[t] = true
times = append(times, t)
}
}
sort.Slice(times, func(i, j int) bool {
if times[i].Hour != times[j].Hour {
return times[i].Hour < times[j].Hour
}
return times[i].Minute < times[j].Minute
})
return times, nil
}
// Next returns the earliest scheduled instant strictly after now, with the
// times read in loc. It returns the zero time when times is empty.
func Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time {
local := now.In(loc)
var best time.Time
for dayOffset := 0; dayOffset <= 1; dayOffset++ {
for _, t := range times {
c := time.Date(local.Year(), local.Month(), local.Day()+dayOffset, t.Hour, t.Minute, 0, 0, loc)
if c.After(now) && (best.IsZero() || c.Before(best)) {
best = c
}
}
}
return best
}
// pollInterval bounds each wait so the loop follows the wall clock across
// clock adjustments and host suspend instead of relying on one long timer.
const pollInterval = time.Minute
// Run calls job at every scheduled time until ctx is cancelled. Nothing runs
// at startup. Runs do not overlap: the next time is computed after job returns.
func Run(ctx context.Context, times []TimeOfDay, loc *time.Location, logger *slog.Logger, job func(context.Context)) {
for {
next := Next(time.Now(), times, loc)
if next.IsZero() {
return
}
logger.Info("next scheduled run", "at", next.Format(time.RFC3339))
for {
wait := time.Until(next)
if wait <= 0 {
break
}
timer := time.NewTimer(min(wait, pollInterval))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
job(ctx)
}
}
-115
View File
@@ -1,115 +0,0 @@
package scheduler
import (
"context"
"io"
"log/slog"
"reflect"
"testing"
"time"
)
func TestParseTimes(t *testing.T) {
cases := []struct {
in string
want []TimeOfDay
wantErr bool
}{
{"04:00", []TimeOfDay{{4, 0}}, false},
{" 18:30 , 06:00,06:00 ", []TimeOfDay{{6, 0}, {18, 30}}, false},
{"off", nil, false},
{"OFF", nil, false},
{"", nil, true},
{"25:00", nil, true},
{"04:60", nil, true},
{"daily", nil, true},
{"04:00,", nil, true},
}
for _, tc := range cases {
got, err := ParseTimes(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("ParseTimes(%q): err = %v, wantErr %v", tc.in, err, tc.wantErr)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ParseTimes(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestNext(t *testing.T) {
prague, err := time.LoadLocation("Europe/Prague")
if err != nil {
t.Fatal(err)
}
at := func(s string) time.Time {
v, err := time.ParseInLocation("2006-01-02 15:04:05", s, prague)
if err != nil {
t.Fatal(err)
}
return v
}
four := []TimeOfDay{{4, 0}}
cases := []struct {
name string
now time.Time
times []TimeOfDay
want time.Time
}{
{"before today's time", at("2026-10-08 03:59:59"), four, at("2026-10-08 04:00:00")},
{"exactly at the time moves to tomorrow", at("2026-10-08 04:00:00"), four, at("2026-10-09 04:00:00")},
{"after today's time", at("2026-10-08 12:00:00"), four, at("2026-10-09 04:00:00")},
{"picks the next of several", at("2026-10-08 12:00:00"), []TimeOfDay{{6, 0}, {18, 0}}, at("2026-10-08 18:00:00")},
{"year rollover", at("2026-12-31 23:00:00"), four, at("2027-01-01 04:00:00")},
{"now given in UTC", at("2026-10-08 03:00:00").UTC(), four, at("2026-10-08 04:00:00")},
// Spring forward (2026-03-29 02:00 → 03:00): still 04:00 local, 23h after the previous run.
{"spring forward", at("2026-03-28 04:00:00"), four, at("2026-03-29 04:00:00")},
// Fall back (2026-10-25 03:00 → 02:00): still 04:00 local, 25h after the previous run.
{"fall back", at("2026-10-24 04:00:00"), four, at("2026-10-25 04:00:00")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Next(tc.now, tc.times, prague)
if !got.Equal(tc.want) {
t.Errorf("Next = %s, want %s", got, tc.want)
}
})
}
if got := Next(at("2026-10-08 12:00:00"), nil, prague); !got.IsZero() {
t.Errorf("Next with empty schedule = %s, want zero", got)
}
if d := at("2026-03-29 04:00:00").Sub(at("2026-03-28 04:00:00")); d != 23*time.Hour {
t.Errorf("spring-forward gap = %s, want 23h", d)
}
}
func TestRunStopsOnCancelAndEmptySchedule(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
called := false
job := func(context.Context) { called = true }
done := make(chan struct{})
go func() { Run(context.Background(), nil, time.UTC, logger, job); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run with empty schedule did not return")
}
ctx, cancel := context.WithCancel(context.Background())
done = make(chan struct{})
far := time.Now().UTC().Add(12 * time.Hour)
go func() {
Run(ctx, []TimeOfDay{{far.Hour(), far.Minute()}}, time.UTC, logger, job)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
if called {
t.Error("job must not run before its scheduled time")
}
}
+2 -2
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 legacy/scripts/infer_payments.py infer_payments. // Ports scripts/infer_payments.py infer_payments.
func InferPayments( func InferPayments(
ctx context.Context, ctx context.Context,
spreadsheetID string, spreadsheetID string,
@@ -94,7 +94,7 @@ func InferPayments(
if idx < 0 || idx >= len(row) { if idx < 0 || idx >= len(row) {
return "" return ""
} }
return strings.TrimSpace(sheets.CellString(row[idx])) return strings.TrimSpace(fmt.Sprint(row[idx]))
} }
// Skip rule: any of manual fix / Person / Purpose non-empty → leave alone // Skip rule: any of manual fix / Person / Purpose non-empty → leave alone
-63
View File
@@ -1,63 +0,0 @@
package banksync
import (
"context"
"errors"
"io"
"sync"
"time"
)
// ErrBusy is returned by Runner.Run when another run is still in progress.
var ErrBusy = errors.New("a sync is already running")
// RunStatus describes one finished run of a Runner.
type RunStatus struct {
Started, Finished time.Time
Trigger string // "manual" or "scheduled"
Err error
}
// Runner serialises bank sync runs so a scheduled run and a manual one never
// overlap (overlapping runs could append the same rows twice), and remembers
// the outcome of the last run for display.
type Runner struct {
job func(ctx context.Context, out io.Writer) error
running sync.Mutex
mu sync.Mutex
last RunStatus
hasLast bool
}
// NewRunner wraps job, which performs one full sync and writes a
// human-readable log to out.
func NewRunner(job func(ctx context.Context, out io.Writer) error) *Runner {
return &Runner{job: job}
}
// Run executes the job unless one is already running, in which case it
// returns ErrBusy without waiting.
func (r *Runner) Run(ctx context.Context, trigger string, out io.Writer) error {
if !r.running.TryLock() {
return ErrBusy
}
defer r.running.Unlock()
st := RunStatus{Started: time.Now(), Trigger: trigger}
st.Err = r.job(ctx, out)
st.Finished = time.Now()
r.mu.Lock()
r.last, r.hasLast = st, true
r.mu.Unlock()
return st.Err
}
// Last returns the outcome of the most recent finished run, if any.
func (r *Runner) Last() (RunStatus, bool) {
r.mu.Lock()
defer r.mu.Unlock()
return r.last, r.hasLast
}
@@ -1,65 +0,0 @@
package banksync
import (
"context"
"errors"
"io"
"testing"
)
func TestRunner_RecordsStatus(t *testing.T) {
r := NewRunner(func(context.Context, io.Writer) error { return nil })
if _, ok := r.Last(); ok {
t.Fatal("Last before any run: want ok=false")
}
if err := r.Run(context.Background(), "scheduled", io.Discard); err != nil {
t.Fatal(err)
}
st, ok := r.Last()
if !ok || st.Trigger != "scheduled" || st.Err != nil || st.Finished.Before(st.Started) {
t.Errorf("unexpected status %+v ok=%v", st, ok)
}
}
func TestRunner_RecordsError(t *testing.T) {
boom := errors.New("boom")
r := NewRunner(func(context.Context, io.Writer) error { return boom })
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, boom) {
t.Fatalf("Run err = %v, want boom", err)
}
if st, _ := r.Last(); !errors.Is(st.Err, boom) || st.Trigger != "manual" {
t.Errorf("unexpected status %+v", st)
}
}
func TestRunner_BusyWhileRunning(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
first := true
r := NewRunner(func(context.Context, io.Writer) error {
if first { // runs are serialised by Runner, so no extra locking is needed
first = false
close(started)
<-release
}
return nil
})
done := make(chan error, 1)
go func() { done <- r.Run(context.Background(), "scheduled", io.Discard) }()
<-started
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, ErrBusy) {
t.Errorf("second Run err = %v, want ErrBusy", err)
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
// The skipped run must not overwrite the status of the real one.
if st, _ := r.Last(); st.Trigger != "scheduled" {
t.Errorf("Last trigger = %q, want scheduled", st.Trigger)
}
if err := r.Run(context.Background(), "manual", io.Discard); err != nil {
t.Errorf("Run after release: %v", err)
}
}
+11 -28
View File
@@ -6,14 +6,13 @@ import (
"fmt" "fmt"
"fuj-management/go/internal/domain/synch" "fuj-management/go/internal/domain/synch"
"fuj-management/go/internal/io/fio" "fuj-management/go/internal/io/fio"
"fuj-management/go/internal/io/sheets"
"os" "os"
"strings" "strings"
"time" "time"
) )
// columnLabels is the canonical header for the payments sheet. // columnLabels is the canonical header for the payments sheet.
// Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py. // Mirrors COLUMN_LABELS in 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",
@@ -36,25 +35,13 @@ 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 // SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
// calendar year plus the last two months of the previous one, so payments // Returns the number of rows appended.
// dated around New Year are still picked up in January. Re-fetching is safe // Ports scripts/sync_fio_to_sheets.py sync_to_sheets.
// 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
// new ones to the payments sheet. Returns the number of rows appended.
// 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,
fioClients []fio.Client, fioClient fio.Client,
sh sheetsWriter, sh sheetsWriter,
opts SyncOpts, opts SyncOpts,
) (int, error) { ) (int, error) {
@@ -97,14 +84,10 @@ func SyncToSheets(
from = to.AddDate(0, 0, -days) from = to.AddDate(0, 0, -days)
} }
// 3. Fetch Fio transactions from each account and combine. // 3. Fetch Fio transactions.
var txns []fio.Transaction txns, err := fioClient.FetchTransactions(ctx, from, to)
for _, client := range fioClients { if err != nil {
got, err := client.FetchTransactions(ctx, from, to) return 0, fmt.Errorf("sync: fetch fio: %w", err)
if err != nil {
return 0, fmt.Errorf("sync: fetch fio: %w", err)
}
txns = append(txns, got...)
} }
if opts.DryRun { if opts.DryRun {
fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n", fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n",
@@ -143,7 +126,7 @@ func SyncToSheets(
newRows = append(newRows, []any{ newRows = append(newRows, []any{
tx.Date, tx.Amount, tx.Date, tx.Amount,
"", "", "", "", // manual fix, Person, Purpose, Inferred Amount "", "", "", "", // manual fix, Person, Purpose, Inferred Amount
tx.Sender, sheets.TextCell(tx.VS), tx.Message, tx.BankID, syncIDs[i], tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i],
}) })
} }
@@ -154,7 +137,7 @@ func SyncToSheets(
if opts.DryRun { if opts.DryRun {
for _, row := range newRows { for _, row := range newRows {
fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n", fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n",
row[0], row[1], row[6], strings.TrimPrefix(fmt.Sprint(row[7]), "'"), row[8]) row[0], row[1], row[6], row[7], row[8])
} }
if opts.Sort { if opts.Sort {
fmt.Println("Dry run: would sort by date") fmt.Println("Dry run: would sort by date")
+7 -65
View File
@@ -20,7 +20,7 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
}} }}
fioFake := &fio.Fake{Transactions: testFioTxns} fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30}) n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -38,10 +38,6 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 { if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 {
t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10]) t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10])
} }
// VS (index 7) is written as text so Sheets keeps it a string.
if vs, _ := rows[0][7].(string); vs != "'123" {
t.Errorf("expected VS written as text cell '123, got %v", rows[0][7])
}
} }
func TestSyncToSheets_Dedup(t *testing.T) { func TestSyncToSheets_Dedup(t *testing.T) {
@@ -55,7 +51,7 @@ func TestSyncToSheets_Dedup(t *testing.T) {
}} }}
fioFake := &fio.Fake{Transactions: testFioTxns} fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30}) n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -76,7 +72,7 @@ func TestSyncToSheets_NoNewTxns(t *testing.T) {
}} }}
fioFake := &fio.Fake{Transactions: testFioTxns} fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30}) n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -96,7 +92,7 @@ func TestSyncToSheets_MissingHeader(t *testing.T) {
}} }}
fioFake := &fio.Fake{Transactions: testFioTxns[:1]} fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30}) n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -109,7 +105,7 @@ func TestSyncToSheets_Sort(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}} sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
fioFake := &fio.Fake{Transactions: testFioTxns[:1]} fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
_, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30, Sort: true}) _, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30, Sort: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -122,7 +118,7 @@ func TestSyncToSheets_ExplicitDateWindow(t *testing.T) {
from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{From: from, To: to}) n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{From: from, To: to})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -135,7 +131,7 @@ func TestSyncToSheets_DryRun(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}} sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
fioFake := &fio.Fake{Transactions: testFioTxns} fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh,
SyncOpts{Days: 30, Sort: true, DryRun: true}) SyncOpts{Days: 30, Sort: true, DryRun: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -148,40 +144,6 @@ func TestSyncToSheets_DryRun(t *testing.T) {
} }
} }
func TestSyncToSheets_MultiAccount(t *testing.T) {
txnsA := []fio.Transaction{
{Date: "2026-04-10", Amount: 700, Sender: "Alice", Message: "april", VS: "1", BankID: "A1"},
}
txnsB := []fio.Transaction{
{Date: "2026-04-11", Amount: 500, Sender: "Bob", Message: "duben", VS: "2", BankID: "B1"},
}
// One transaction that duplicates the first one from account A (same sync_id).
dupID := syncIDFor(txnsA[0])
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:K": {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-10", 700.0, "", "", "", "", "Alice", "1", "april", "A1", dupID},
},
}}
fakeA := &fio.Fake{Transactions: txnsA}
fakeB := &fio.Fake{Transactions: txnsB}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fakeA, fakeB}, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("want 1 new row (B1 from account B; A1 is duplicate), got %d", n)
}
if len(sh.Appended) != 1 || len(sh.Appended[0].Rows) != 1 {
t.Fatalf("want exactly 1 row appended, got %v", sh.Appended)
}
row := sh.Appended[0].Rows[0]
if row[6] != "Bob" {
t.Errorf("expected Bob's row, got sender=%v", row[6])
}
}
// syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction. // syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction.
func syncIDFor(tx fio.Transaction) string { func syncIDFor(tx fio.Transaction) string {
currency := tx.Currency currency := tx.Currency
@@ -193,23 +155,3 @@ 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)
}
}
}
@@ -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 legacy/scripts/calculate_fees.py main(). // Mirrors scripts/calculate_fees.py main().
// //
// Verify with: // Verify with:
// //
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py // PYTHONPATH=scripts:. python 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 legacy/scripts/calculate_fees.py on 2026-05-06: // Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
// //
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py // PYTHONPATH=scripts:. python 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 legacy/scripts/match_payments.py print_report(). // Mirrors scripts/match_payments.py print_report().
// //
// Verify with: // Verify with:
// //
// PYTHONPATH=legacy/scripts:legacy python -c ' // PYTHONPATH=scripts:. 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 legacy/scripts/match_payments.py print_report() on 2026-05-06: // Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
// //
// PYTHONPATH=legacy/scripts:legacy python -c ' // PYTHONPATH=scripts:. python -c '
// from match_payments import print_report // from match_payments import print_report
// result = { // result = {
// "members": { // "members": {
+11 -11
View File
@@ -18,20 +18,20 @@ import (
"time" "time"
) )
// Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py) // Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
const ( const (
colName = 0 colName = 0
colTier = 1 colTier = 1
firstDateCol = 3 firstDateCol = 3
) )
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py. // AdultMergedMonths mirrors ADULT_MERGED_MONTHS in 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 legacy/scripts/attendance.py. // JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in 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 legacy/scripts/attendance.py) // Attendance CSV parsing (ports scripts/attendance.py)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns. // parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
// Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m"). // Ports 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 legacy/scripts/attendance.py get_members_with_fees. // Ports 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 legacy/scripts/attendance.py get_junior_members_with_fees. // Ports 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 legacy/scripts/match_payments.py fetch_sheet_data) // Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) { func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
@@ -402,7 +402,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
if i < 0 || i >= len(row) { if i < 0 || i >= len(row) {
return "" return ""
} }
return sheets.CellString(row[i]) return fmt.Sprint(row[i])
} }
// getRaw returns row[i] without stringifying — needed for FormatDate to // getRaw returns row[i] without stringifying — needed for FormatDate to
@@ -425,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
amount := parseFloat(amountRaw) amount := parseFloat(amountRaw)
var inferredAmount *float64 var inferredAmount *float64
if iv := getVal(row, idxInferred); iv != "" { if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
if f := parseFloat(iv); f != 0 { if f := parseFloat(iv); f != 0 {
inferredAmount = &f inferredAmount = &f
} }
@@ -466,7 +466,7 @@ func parseFloat(v any) float64 {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Exceptions tab parsing (ports legacy/scripts/match_payments.py fetch_exceptions) // Exceptions tab parsing (ports scripts/match_payments.py fetch_exceptions)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception { func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
@@ -123,8 +123,6 @@ func TestLoadTransactions(t *testing.T) {
{"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"},
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"}, {"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05 {46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
// Numeric VS / Bank ID cells arrive as float64 and must stay plain digits.
{"2026-05-06", 500.0, "", "", "", "", "Carol Bank", 2609767878.0, "platba", 26123456789.0, "ghi"},
}, },
}} }}
s := buildSources(t, &attendance.Fake{}, sh) s := buildSources(t, &attendance.Fake{}, sh)
@@ -133,8 +131,8 @@ func TestLoadTransactions(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(txns) != 3 { if len(txns) != 2 {
t.Fatalf("want 3 transactions, got %d", len(txns)) t.Fatalf("want 2 transactions, got %d", len(txns))
} }
if txns[0].Person != "Alice" { if txns[0].Person != "Alice" {
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person) t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
@@ -148,12 +146,6 @@ func TestLoadTransactions(t *testing.T) {
if txns[1].Date != "2026-05-05" { if txns[1].Date != "2026-05-05" {
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date) t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
} }
if txns[2].VS != "2609767878" {
t.Errorf("txn[2].VS (numeric cell): want 2609767878, got %q", txns[2].VS)
}
if txns[2].BankID != "26123456789" {
t.Errorf("txn[2].BankID (numeric cell): want 26123456789, got %q", txns[2].BankID)
}
} }
func TestLoadExceptions(t *testing.T) { func TestLoadExceptions(t *testing.T) {
-1
View File
@@ -39,5 +39,4 @@ type AdultsResponse struct {
PaymentsURL string `json:"payments_url"` PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"` BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"` CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
} }
+2 -3
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 legacy/scripts/views.py:build_adults_view_model. // Mirrors scripts/views.py:build_adults_view_model.
func buildAdultsResponse( func buildAdultsResponse(
members []domreconcile.Member, members []domreconcile.Member,
sortedMonths []string, sortedMonths []string,
@@ -138,9 +138,8 @@ func buildAdultsResponse(
Unmatched: unmatched, Unmatched: unmatched,
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit", AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit", PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount, BankAccount: cfg.BankAccount,
CurrentMonth: currentMonth, CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
} }
} }
+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 legacy/scripts/views.py:get_month_labels. // Mirrors 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 legacy/scripts/match_payments.py:canonical_member_key. // Mirrors 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 legacy/scripts/views.py:group_payments_by_person (without the // Mirrors 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))
+2 -3
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 legacy/scripts/views.py:build_juniors_view_model. // Mirrors scripts/views.py:build_juniors_view_model.
func buildJuniorsResponse( func buildJuniorsResponse(
members []domreconcile.Member, members []domreconcile.Member,
sortedMonths []string, sortedMonths []string,
@@ -134,9 +134,8 @@ func buildJuniorsResponse(
Unmatched: unmatched, Unmatched: unmatched,
AttendanceURL: juniorURL, AttendanceURL: juniorURL,
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit", PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount, BankAccount: cfg.BankAccount,
CurrentMonth: currentMonth, CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import (
) )
// buildPaymentsResponse constructs the PaymentsResponse wire type. // buildPaymentsResponse constructs the PaymentsResponse wire type.
// Mirrors legacy/scripts/views.py:build_payments_view_model. // Mirrors scripts/views.py:build_payments_view_model.
func buildPaymentsResponse( func buildPaymentsResponse(
txns []domreconcile.Transaction, txns []domreconcile.Transaction,
memberNames []string, memberNames []string,
-1
View File
@@ -38,5 +38,4 @@ type JuniorsResponse struct {
PaymentsURL string `json:"payments_url"` PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"` BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"` CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
} }
+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 legacy/app.py). // Keys match Python's BUILD_META dict (see app.py).
type VersionResponse struct { type VersionResponse struct {
Tag string `json:"tag"` Tag string `json:"tag"`
Commit string `json:"commit"` Commit string `json:"commit"`
-93
View File
@@ -1,93 +0,0 @@
package web
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// TestEmbedCompleteness guards against a new template or static file being
// added to disk but missing from the embedded FS (e.g. a new directory that
// the //go:embed glob does not match).
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskDir string
embedFS fs.FS
embedRoot string
}{
{"templates", "templates", templateFS, "templates"},
{"static", "static", staticFS, "static"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diskFS := os.DirFS(tc.diskDir)
_ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
embPath := tc.embedRoot + "/" + path
if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil {
t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr)
}
return nil
})
})
}
}
// TestStaticAssetsServed verifies that /static/* is served from the embedded
// FS through the same mux wiring used in server.go, so a standalone binary
// with no adjacent static/ directory still delivers assets.
func TestStaticAssetsServed(t *testing.T) {
subFS, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatalf("fs.Sub static: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS)))
cases := []struct {
path string
wantCT string
wantSnippet string
}{
{"/static/css/app.css", "text/css", "body {"},
{"/static/js/member-detail.js", "javascript", "Member-detail modal"},
{"/static/js/filters.js", "javascript", ""},
{"/static/js/payment-qr.js", "javascript", ""},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, tc.wantCT) {
t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT)
}
if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) {
t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet)
}
})
}
// Sanity: unknown path → 404 (file server doesn't fall through silently)
t.Run("missing-file", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("unknown static path: status %d, want 404", w.Code)
}
})
}
+12 -21
View File
@@ -21,26 +21,17 @@ func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHan
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions} return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
} }
// page builds the data shared by every page (nav highlight, build info, sync summary).
func (h *HTMLHandler) page(active string) PageData {
pd := PageData{Active: active, Build: h.build}
if h.actions.SyncInfo != nil {
pd.SyncInfo = h.actions.SyncInfo()
}
return pd
}
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) { func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleAdults(r.Context()) data, err := h.apiHandler.AssembleAdults(r.Context())
if err != nil { if err != nil {
h.renderer.Render(w, "adults", AdultsPageData{ h.renderer.Render(w, "adults", AdultsPageData{
PageData: h.page("adults"), PageData: PageData{Active: "adults", Build: h.build},
Error: err.Error(), Error: err.Error(),
}) })
return return
} }
h.renderer.Render(w, "adults", AdultsPageData{ h.renderer.Render(w, "adults", AdultsPageData{
PageData: h.page("adults"), PageData: PageData{Active: "adults", Build: h.build},
Data: data, Data: data,
}) })
} }
@@ -49,13 +40,13 @@ func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleJuniors(r.Context()) data, err := h.apiHandler.AssembleJuniors(r.Context())
if err != nil { if err != nil {
h.renderer.Render(w, "juniors", JuniorsPageData{ h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: h.page("juniors"), PageData: PageData{Active: "juniors", Build: h.build},
Error: err.Error(), Error: err.Error(),
}) })
return return
} }
h.renderer.Render(w, "juniors", JuniorsPageData{ h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: h.page("juniors"), PageData: PageData{Active: "juniors", Build: h.build},
Data: data, Data: data,
}) })
} }
@@ -64,20 +55,20 @@ func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssemblePayments(r.Context()) data, err := h.apiHandler.AssemblePayments(r.Context())
if err != nil { if err != nil {
h.renderer.Render(w, "payments", PaymentsPageData{ h.renderer.Render(w, "payments", PaymentsPageData{
PageData: h.page("payments"), PageData: PageData{Active: "payments", Build: h.build},
Error: err.Error(), Error: err.Error(),
}) })
return return
} }
h.renderer.Render(w, "payments", PaymentsPageData{ h.renderer.Render(w, "payments", PaymentsPageData{
PageData: h.page("payments"), PageData: PageData{Active: "payments", Build: h.build},
Data: data, Data: data,
}) })
} }
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result. // ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) { func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
pd := h.page("sync") pd := PageData{Active: "sync", Build: h.build}
if h.actions.BankSync == nil { if h.actions.BankSync == nil {
h.renderer.Render(w, "sync", SyncPageData{ h.renderer.Render(w, "sync", SyncPageData{
@@ -105,7 +96,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
} }
h.renderer.Render(w, "sync", SyncPageData{ h.renderer.Render(w, "sync", SyncPageData{
PageData: h.page("sync"), // rebuilt so the footer reflects this run PageData: pd,
Output: buf.String(), Output: buf.String(),
Success: success, Success: success,
}) })
@@ -114,7 +105,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form. // ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) { func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", FlushPageData{ h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: h.page("flush"), PageData: PageData{Active: "flush", Build: h.build},
}) })
} }
@@ -122,7 +113,7 @@ func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request)
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) { func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
n, _ := h.apiHandler.FlushCache() n, _ := h.apiHandler.FlushCache()
h.renderer.Render(w, "flush_cache", FlushPageData{ h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: h.page("flush"), PageData: PageData{Active: "flush", Build: h.build},
Flushed: true, Flushed: true,
Deleted: n, Deleted: n,
}) })
@@ -135,13 +126,13 @@ func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
amount := q.Get("amount") amount := q.Get("amount")
message := q.Get("message") message := q.Get("message")
if account == "" { if account == "" {
account = h.apiHandler.Config.QRAccount account = h.apiHandler.Config.BankAccount
} }
if amount == "" { if amount == "" {
amount = "0" amount = "0"
} }
payload := BuildSPD(account, amount, message, h.apiHandler.Config.QRAccount) payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
png, err := RenderQRCode(payload) png, err := RenderQRCode(payload)
if err != nil { if err != nil {
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError) http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
+1 -23
View File
@@ -50,7 +50,7 @@ func fixtureHandler(t *testing.T) *api.Handler {
t.Helper() t.Helper()
return &api.Handler{ return &api.Handler{
Sources: fixtureSources{}, Sources: fixtureSources{},
Config: config.Config{QRAccount: "CZ0000000000000000000000"}, Config: config.Config{BankAccount: "CZ0000000000000000000000"},
} }
} }
@@ -314,28 +314,6 @@ func TestServeSync(t *testing.T) {
} }
} }
func TestFooterSyncInfo(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
get := func(actions web.ActionHandlers) string {
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
w := httptest.NewRecorder()
h.ServeFlushCacheGET(w, httptest.NewRequest(http.MethodGet, "/flush-cache", nil))
return w.Body.String()
}
const info = "last sync 2026-10-08 04:00 (scheduled) ok"
if body := get(web.ActionHandlers{SyncInfo: func() string { return info }}); !strings.Contains(body, "| "+info) {
t.Errorf("footer missing sync info %q", info)
}
if body := get(web.ActionHandlers{}); strings.Contains(body, "last sync") {
t.Errorf("footer shows sync info although none was provided")
}
}
func TestPaymentQRMarkup(t *testing.T) { func TestPaymentQRMarkup(t *testing.T) {
renderer, err := web.NewRenderer() renderer, err := web.NewRenderer()
if err != nil { if err != nil {
+2 -3
View File
@@ -10,9 +10,8 @@ import (
// PageData is the view model passed to every HTML template. // PageData is the view model passed to every HTML template.
type PageData struct { type PageData struct {
Active string Active string
Build BuildInfo Build BuildInfo
SyncInfo string // last/next bank sync summary shown in the footer; empty hides it
} }
// AdultsPageData is the view model for the /adults HTML page. // AdultsPageData is the view model for the /adults HTML page.
-3
View File
@@ -26,9 +26,6 @@ type ActionHandlers struct {
// BankSync runs sync+infer and writes a human-readable log to out. // BankSync runs sync+infer and writes a human-readable log to out.
// nil disables the /sync-bank action (renders an error instead). // nil disables the /sync-bank action (renders an error instead).
BankSync func(ctx context.Context, out io.Writer) error BankSync func(ctx context.Context, out io.Writer) error
// SyncInfo returns a one-line summary of the last and next bank sync for
// the page footer. nil or an empty result shows nothing.
SyncInfo func() string
} }
// Run registers routes and starts the HTTP server on addr. // Run registers routes and starts the HTTP server on addr.
+1 -8
View File
@@ -12,8 +12,7 @@
const container = document.getElementById('filterContainer'); const container = document.getElementById('filterContainer');
if (!container) return; if (!container) return;
const currentMonth = container.dataset.currentMonth || ''; const currentMonth = container.dataset.currentMonth || '';
const monthsToShow = parseInt(container.dataset.monthsToShow || '0', 10);
const nameInput = document.getElementById('nameFilter'); const nameInput = document.getElementById('nameFilter');
const fromSelect = document.getElementById('fromMonth'); const fromSelect = document.getElementById('fromMonth');
@@ -89,10 +88,4 @@
// ── Initialise ──────────────────────────────────────────────────────────── // ── Initialise ────────────────────────────────────────────────────────────
hideFutureMonths(); hideFutureMonths();
// Default the from-select to show only the last N months.
if (monthsToShow > 0 && toSelect.value !== '') {
const defaultFrom = Math.max(0, parseInt(toSelect.value, 10) - monthsToShow + 1);
fromSelect.value = String(defaultFrom);
applyMonthFilter();
}
}()); }());
+1 -1
View File
@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a> <a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div> </div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}"> <div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
<div class="filter-item"> <div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label> <label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…"> <input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
+1 -1
View File
@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a> <a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div> </div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}"> <div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
<div class="filter-item"> <div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label> <label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…"> <input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
@@ -1,3 +1,3 @@
{{define "footer"}} {{define "footer"}}
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}{{if .SyncInfo}} | {{.SyncInfo}}{{end}}</div> <div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
{{end}} {{end}}
+1 -1
View File
@@ -9,7 +9,7 @@
{{end}} {{end}}
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre> <pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
{{else}} {{else}}
<p class="description">Fetches Fio transactions for the current year and the last two months of the previous one, infers payment details, and flushes the cache.</p> <p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
{{end}} {{end}}
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p> <p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
{{end}} {{end}}
+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=legacy/scripts:legacy python3 legacy/scripts/capture_fixtures.py --func normalize --all \ PYTHONPATH=scripts:. python3 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 legacy/scripts/scrub_fixtures.py \ echo "$line" | python3 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 `legacy/scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`. 1. Add a seed to `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.
+1 -5
View File
@@ -143,9 +143,6 @@
}, },
"current_month": { "current_month": {
"type": "string" "type": "string"
},
"months_to_show": {
"type": "integer"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@@ -164,8 +161,7 @@
"attendance_url", "attendance_url",
"payments_url", "payments_url",
"bank_account", "bank_account",
"current_month", "current_month"
"months_to_show"
] ]
}, },
"Credit": { "Credit": {
+1 -5
View File
@@ -187,9 +187,6 @@
}, },
"current_month": { "current_month": {
"type": "string" "type": "string"
},
"months_to_show": {
"type": "integer"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@@ -208,8 +205,7 @@
"attendance_url", "attendance_url",
"payments_url", "payments_url",
"bank_account", "bank_account",
"current_month", "current_month"
"months_to_show"
] ]
}, },
"MemberOtherEntry": { "MemberOtherEntry": {
@@ -1,12 +0,0 @@
{
"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-10-08", "captured_at": "2026-05-06",
"input": { "input": {
"attendance_count": 2, "attendance_count": 2,
"month_key": "2099-01" "month_key": "2099-01"
}, },
"output": { "output": {
"fee": 600 "fee": 700
} }
} }
@@ -1,12 +0,0 @@
{
"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
}
}
@@ -1,12 +0,0 @@
{
"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
}
}
@@ -13,7 +13,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))" // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))" // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))" // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c "from match_payments import format_date; print(format_date(46027))" // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c " // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c " // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))" // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))" // PYTHONPATH=scripts:. 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=legacy/scripts:legacy python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))" // PYTHONPATH=scripts:. 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)

Some files were not shown because too many files have changed in this diff Show More