Compare commits

...
Author SHA1 Message Date
kacerrandClaude Opus 5.5 82406935ed docs: add pre-feature cleanup task list and API parity plan
Deploy to K8s / deploy (push) Successful in 14s
Task list from the 2026-10-08 repo audit, plus the plan for the two
parity fixes found while validating the legacy/ move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:34:20 +02:00
kacerrandClaude Opus 5.5 e7c8101c3b fix(python): return months_to_show from the JSON API
Go returns months_to_show in /api/adults and /api/juniors; the Python
API did not, so make parity failed on both routes. The Python HTML
pages already had the feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:34:20 +02:00
kacerrandClaude Opus 5.5 97f94afa02 fix: treat VS as a string on read and write
Go stringified sheet cells with fmt.Sprint, so a numeric VS read with
UNFORMATTED_VALUE was rendered as 2.609767878e+09. Text columns of the
payments sheet now go through sheets.CellString, which never uses
exponent form; bank inference uses the same helper.

Both sync implementations now append VS as a text cell so Sheets keeps
it a string and leading zeros survive. Existing rows are not rewritten.

Python already read VS as a digit string; a regression test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:34:20 +02:00
kacerrandClaude Opus 5.5 022facc957 refactor: move Python implementation to legacy/, make Go primary
Deploy to K8s / deploy (push) Successful in 20s
Go (go/) becomes the primary implementation. The original Python
version moves to legacy/ and is kept runnable; new features are still
built in both (see the Dual implementation rule in CLAUDE.md).

- git mv app.py, scripts/, templates/, tests/, pyproject.toml, uv.lock
  and build/ into legacy/
- Makefile: Python targets point at legacy/, venv in legacy/.venv,
  CREDENTIALS_PATH exported for the root .secret/ file
- CI and make image build the Python image with legacy/ as context;
  the Makefile is no longer copied into the image
- legacy/.dockerignore moved to the context root so it takes effect
- CLAUDE.md, docs/README.md, Go comments, fixture README, capture
  script and VS Code launch config updated to the new layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:57 +02:00
kacerr 96c14f0b22 Merge pull request 'fix(ci): resolve image tag via Gitea API instead of artifact' (#42) from fix/gitops-tag-via-api into main
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 8s
Build and Push / build-go (push) Successful in 40s
Reviewed-on: #42
2026-06-12 19:59:40 +02:00
kacerrandClaude Opus 4.8 6d7dbfa624 fix(ci): resolve image tag via Gitea API instead of artifact
Deploy to K8s / deploy (push) Successful in 9s
upload/download-artifact@v4 is not supported on Gitea (GHES). Replace
with a direct Gitea API call in gitops-update: look up the tag name
whose commit SHA matches workflow_run.head_sha. Reverts the artifact
upload from build.yaml; no changes to build.yaml logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:59:24 +02:00
kacerr c00111cff1 Merge pull request 'fix(ci): pass Go image tag from build to gitops via artifact' (#41) from fix/gitops-pass-tag-via-artifact into main
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 7s
Build and Push / build-go (push) Failing after 42s
Reviewed-on: #41
2026-06-12 19:53:19 +02:00
kacerrandClaude Opus 4.8 d263d8a534 fix(ci): pass Go image tag from build to gitops via artifact
Deploy to K8s / deploy (push) Successful in 14s
github.event.workflow_run.head_branch is not populated for tag pushes
in Gitea Actions, causing the image tag to resolve to empty (-go suffix
with no version). Fix: build-go uploads the full image reference as a
one-line artifact; gitops-update downloads it via download-artifact@v4
with run-id from the workflow_run event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:52:02 +02:00
kacerr af030c8255 Merge pull request 'fix(ci): separate git credentials from --git-repo URL to fix tea pr create' (#40) from fix/gitops-tea-url into main
Build and Push / build (push) Successful in 42s
Build and Push / build-go (push) Successful in 1m23s
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #40
2026-06-12 19:39:31 +02:00
kacerrandClaude Opus 4.8 ad127d36ea fix(ci): separate git credentials from --git-repo URL to fix tea pr create
Deploy to K8s / deploy (push) Successful in 12s
tea pr create matches the remote URL against the configured login URL to
auto-detect owner/repo. Embedding credentials in the URL (user:token@host)
breaks that match and produces "path segment [0] is empty". Store creds
via git credential helper instead and pass a clean URL to uh-cli.

Also adds set -x to the PR step for shell-level tracing in CI logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:37:29 +02:00
kacerrandClaude Opus 4.8 29938d7a0c chore(changelog): log gitops-update workflow addition
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:32:20 +02:00
kacerr 1df1863725 Merge pull request 'feat(ci): gitops image-update PR workflow for home-kubernetes' (#39) from feat/gitops-pr-action into main
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #39
2026-06-12 19:31:51 +02:00
90 changed files with 692 additions and 164 deletions
+2 -2
View File
@@ -31,11 +31,11 @@ jobs:
TAG=${{ inputs.tag }} TAG=${{ inputs.tag }}
fi fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -f build/Dockerfile \ docker build -f legacy/build/Dockerfile \
--build-arg GIT_TAG=$TAG \ --build-arg GIT_TAG=$TAG \
--build-arg GIT_COMMIT=${{ github.sha }} \ --build-arg GIT_COMMIT=${{ github.sha }} \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-t $IMAGE . -t $IMAGE legacy/
docker push $IMAGE docker push $IMAGE
build-go: build-go:
+19 -8
View File
@@ -39,10 +39,10 @@ jobs:
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }} GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
steps: steps:
- name: Install git, curl, ca-certificates - name: Install git, curl, ca-certificates, jq
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y --no-install-recommends git curl ca-certificates apt-get install -y --no-install-recommends git curl ca-certificates jq
- name: Install tea - name: Install tea
run: | run: |
@@ -62,19 +62,29 @@ jobs:
id: resolve id: resolve
run: | run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GIT_TAG="${{ inputs.tag }}" IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
else else
# workflow_run: use the ref name of the triggering workflow (the pushed git tag). # workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
GIT_TAG="${{ github.event.workflow_run.head_branch }}" # Look up the tag name that points to the triggering commit SHA via the API.
fi 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" IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "Resolved image: ${IMAGE}" echo "Resolved image: ${IMAGE}"
- name: Configure git identity - name: Configure git identity and credentials
run: | run: |
git config --global user.name "uh-cli bot" git config --global user.name "uh-cli bot"
git config --global user.email "bot@hrajfrisbee.cz" 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 - name: Authenticate tea
run: | run: |
@@ -85,10 +95,11 @@ jobs:
- name: Open image-update PR (or dry run) - name: Open image-update PR (or dry run)
run: | run: |
set -x
uh-cli -v gitops deployment update \ uh-cli -v gitops deployment update \
--deployment-name fuj-management \ --deployment-name fuj-management \
--deployment-namespace fuj \ --deployment-namespace fuj \
--set-image "${{ steps.resolve.outputs.image }}" \ --set-image "${{ steps.resolve.outputs.image }}" \
--git-repo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \ --git-repo "https://gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes \ --git-path gitops/home-kubernetes \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }} ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}
+5 -3
View File
@@ -2,14 +2,16 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python Debugger: Flask", "name": "Python Debugger: Flask (legacy)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "flask", "module": "flask",
"python": "${workspaceFolder}/.venv/bin/python", "python": "${workspaceFolder}/legacy/.venv/bin/python",
"cwd": "${workspaceFolder}/legacy",
"env": { "env": {
"FLASK_APP": "app.py", "FLASK_APP": "app.py",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1",
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
}, },
"args": [ "args": [
"run", "run",
+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## 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-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). - 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).
+86 -24
View File
@@ -4,28 +4,62 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## Project Status
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard. Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
- **Go is the primary implementation** (`go/`). It is the version that is built, deployed and developed first.
- **Python is legacy** (`legacy/`). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on.
## Dual implementation rule
Until the Python version is retired, **every new feature and every behaviour change must be implemented in both Go and Python**:
1. Implement and test in Go first (`go/`).
2. Mirror the change in the legacy Python code (`legacy/`), including its tests.
3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (`/api/adults`, `/api/juniors`, `/api/payments`) must stay identical. The HTML frontends are allowed to diverge.
4. If a pure function's behaviour changes, add or update seeds in `legacy/scripts/_fixture_seeds.py`, run `make capture-fixtures`, and make sure `make go-test-all` passes. With both servers running, `make parity` diffs the live JSON APIs.
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
## Key Constraints ## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one. - **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules. - **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `go/internal/config/config.go` (mirrored in `legacy/scripts/config.py`). Domain-specific constants (fees, merged months) stay in their respective modules.
## Development Setup ## Development Setup
This project uses `uv` for dependency management. Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
```bash ```bash
uv venv && uv sync make go-build # builds bin/fuj
source .venv/bin/activate
``` ```
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically). Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`.
```bash
make venv # uv sync --project legacy → legacy/.venv
```
The Makefile sets `PYTHONPATH` (`legacy/scripts:legacy`) and `CREDENTIALS_PATH` (`.secret/fuj-management-bot-credentials.json` at the repo root) for the Python targets. Set both yourself when running Python scripts directly.
## Commands ## Commands
Go (primary):
```bash ```bash
make web # Start dashboard at http://localhost:5001 make web-go # Build and start dashboard at http://localhost:8080
make go-build # Build bin/fuj
make go-test # Unit tests (go test -race ./...)
make go-parity # Parity tests against the captured Python fixtures
make go-test-all # Both of the above
make go-lint # golangci-lint
make go-run ARGS="fees" # Run a subcommand: server, fees, reconcile, sync, infer, version
make go-sync-debug [DAYS=N] # Dry-run bank sync with debug logs
```
Python (legacy):
```bash
make web-py # Start dashboard at http://localhost:5001 (make web is an alias)
make web-debug # Same with FLASK_DEBUG=1 make web-debug # Same with FLASK_DEBUG=1
make test # Run all tests (unittest discover) make test # Run all tests (unittest discover)
make test-v # Tests with verbose output make test-v # Tests with verbose output
@@ -33,12 +67,20 @@ make fees # Print fee report from attendance sheet
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
make reconcile # Print balance report from Google Sheets data make reconcile # Print balance report from Google Sheets data
make image # Build Docker image make image # Build the legacy Python Docker image
```
Cross-implementation:
```bash
make capture-fixtures # Regenerate go/tests/fixtures from the Python implementation
make parity # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running
``` ```
Run a single test: Run a single test:
```bash ```bash
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route cd go && go test ./internal/domain/reconcile -run TestName
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route
``` ```
## Architecture ## Architecture
@@ -46,25 +88,45 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
### Data flow ### Data flow
``` ```
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/ Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
Google Sheets (payments) ──► match_payments.py ──┘ Google Sheets (payments) ──► payments loader ──┘
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments) Fio Bank API ──► bank sync ──► Google Sheets (payments)
``` ```
### Key modules ### Repository layout
- `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache` - `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
- `scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts. - `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
- `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab. - `docs/` — documentation; `docs/plans/` holds plans and task lists.
- `scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `tmp/`. - `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
- `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics. ### Key modules — Go (primary)
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference).
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
### Key modules — Python (legacy)
- `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*`
- `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
- `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
- `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`.
- `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
- `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
- `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API.
- `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`).
- `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go.
### Member tiers ### Member tiers
Tiers are set in column B of the attendance sheet: Tiers are set in column B of the attendance sheet:
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1) - `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet - `J` — Junior attending adult practices; their attendance is merged with the junior sheet
- `X` — Excluded from junior fee calculation (coaches, etc.) - `X` — Excluded from junior fee calculation (coaches, etc.)
@@ -76,17 +138,17 @@ Tiers are set in column B of the attendance sheet:
### Merged months ### Merged months
`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `attendance.py` map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months. `AdultMergedMonths` / `JuniorMergedMonths` in `go/internal/services/membership/sources.go` (`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `legacy/scripts/attendance.py`) map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
### Caching ### Caching
`get_cached_data()` in `app.py` checks the Drive API `modifiedTime` before each request and serves a JSON file from `tmp/` when the sheet hasn't changed. Cache is warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST) or `flush_cache()`. Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
### Payments sheet columns ### Payments sheet columns
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID` `Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
`Person` and `Purpose` are written by `infer_payments.py` and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose. `Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
### QR codes ### QR codes
+32 -28
View File
@@ -1,26 +1,30 @@
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs .PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
export PYTHONPATH := scripts:$(PYTHONPATH) # Legacy Python implementation lives in legacy/ (Go in go/ is primary).
VENV := .venv LEGACY := legacy
PYTHON := $(VENV)/bin/python3 export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
CREDENTIALS := .secret/fuj-management-bot-credentials.json VENV := $(LEGACY)/.venv
PYTHON := $(CURDIR)/$(VENV)/bin/python3
CREDENTIALS := $(CURDIR)/.secret/fuj-management-bot-credentials.json
# Both implementations read CREDENTIALS_PATH; the Python default would resolve under legacy/.
export CREDENTIALS_PATH ?= $(CREDENTIALS)
GO_SRC := go GO_SRC := go
GO_BIN := bin/fuj GO_BIN := bin/fuj
$(PYTHON): .venv/.last_sync $(PYTHON): $(VENV)/.last_sync
.venv/.last_sync: pyproject.toml $(VENV)/.last_sync: $(LEGACY)/pyproject.toml
uv sync uv sync --project $(LEGACY)
touch .venv/.last_sync touch $(VENV)/.last_sync
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " make fees - Calculate monthly fees from the attendance sheet" @echo " make fees - Calculate monthly fees from the attendance sheet"
@echo " make match - Match Fio bank payments against expected attendance fees" @echo " make match - Match Fio bank payments against expected attendance fees"
@echo " make web - Start Python dashboard (alias for web-py, until M8)" @echo " make web - Start legacy Python dashboard (alias for web-py)"
@echo " make web-py - Start Python dashboard on :5001" @echo " make web-py - Start legacy Python dashboard on :5001"
@echo " make web-go - Build and start Go dashboard on :8080" @echo " make web-go - Build and start Go dashboard on :8080 (primary)"
@echo " make web-debug - Start Python dashboard in debug mode" @echo " make web-debug - Start Python dashboard in debug mode"
@echo " make go-build - Build Go binary to bin/fuj" @echo " make go-build - Build Go binary to bin/fuj"
@echo " make go-test - Run Go unit tests" @echo " make go-test - Run Go unit tests"
@@ -44,21 +48,21 @@ help:
@echo " make docs - Serve documentation in a browser" @echo " make docs - Serve documentation in a browser"
venv: venv:
uv sync uv sync --project $(LEGACY)
fees: $(PYTHON) fees: $(PYTHON)
$(PYTHON) scripts/calculate_fees.py $(PYTHON) $(LEGACY)/scripts/calculate_fees.py
match: $(PYTHON) match: $(PYTHON)
$(PYTHON) scripts/match_payments.py $(PYTHON) $(LEGACY)/scripts/match_payments.py
web: web-py web: web-py
web-py: $(PYTHON) web-py: $(PYTHON)
$(PYTHON) app.py $(PYTHON) $(LEGACY)/app.py
web-debug: $(PYTHON) web-debug: $(PYTHON)
FLASK_DEBUG=1 $(PYTHON) app.py FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
go-build: go-build:
cd $(GO_SRC) && go build -trimpath \ cd $(GO_SRC) && go build -trimpath \
@@ -82,10 +86,10 @@ capture-fixtures: $(PYTHON)
infer_transaction_details format_date reconcile; do \ infer_transaction_details format_date reconcile; do \
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \ dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
mkdir -p "$$dir"; \ mkdir -p "$$dir"; \
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \ $(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \
| while IFS= read -r line; do \ | while IFS= read -r line; do \
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \ case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \ echo "$$line" | $(PYTHON) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
done; \ done; \
echo " $$func done"; \ echo " $$func done"; \
done done
@@ -112,34 +116,34 @@ image:
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \ --build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \ --build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \ --build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-f build/Dockerfile . -f $(LEGACY)/build/Dockerfile $(LEGACY)
run: run:
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
sync: $(PYTHON) sync: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS)
sync-2025: $(PYTHON) sync-2025: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2025-10-01 --to 2025-12-31 --sort-by-date
sync-2026: $(PYTHON) sync-2026: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30) sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table $(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
infer: $(PYTHON) infer: $(PYTHON)
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS) $(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
reconcile: ## Match payments against attendance reconcile: $(PYTHON) ## Match payments against attendance
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS) $(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
test: $(PYTHON) ## Run web application tests test: $(PYTHON) ## Run web application tests
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests cd $(LEGACY) && $(PYTHON) -m unittest discover tests
test-v: $(PYTHON) ## Run tests with verbose output test-v: $(PYTHON) ## Run tests with verbose output
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests
docs: ## Serve documentation locally docs: ## Serve documentation locally
@echo "Starting documentation server at http://localhost:8000" @echo "Starting documentation server at http://localhost:8000"
+2
View File
@@ -4,6 +4,8 @@ Welcome to the documentation for the FUJ Management application.
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club. This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
> **Go is the primary implementation** (`go/`). The project was originally developed in Python; that version now lives in `legacy/` and is considered legacy. New features are still built in both. Most guides below predate this change and describe the Python version with its old paths (`app.py`, `scripts/` are now `legacy/app.py`, `legacy/scripts/`).
## Navigation ## Navigation
Use the sidebar to explore the documentation: Use the sidebar to explore the documentation:
@@ -0,0 +1,157 @@
# 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
- [ ] **T1** Remove the secret leak in `.gitea/workflows/kubernetes-deploy.yaml`.
- 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.
- [ ] **T2** Confirm the fee rates for 2026-09 onward.
- `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.
- [ ] **T3** 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.
- 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.
## Notes & decisions
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
@@ -0,0 +1,112 @@
# 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 -1
View File
@@ -21,7 +21,7 @@ var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
// defaultAllowlist holds dotted key paths to strip before diffing. // defaultAllowlist holds dotted key paths to strip before diffing.
// render_time.total is forward-compatible insurance: today it lives in the Jinja // render_time.total is forward-compatible insurance: today it lives in the Jinja
// template context only (app.py inject_render_time) and is logged via // template context only (legacy/app.py inject_render_time) and is logged via
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either // middleware/timer.go on the Go side, so it isn't in any JSON response. If either
// side ever surfaces it under a render_time envelope, the scrubber handles it. // side ever surfaces it under a render_time envelope, the scrubber handles it.
var defaultAllowlist = []string{"render_time.total"} var defaultAllowlist = []string{"render_time.total"}
+2 -2
View File
@@ -42,7 +42,7 @@ const (
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd) JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
) )
// CacheSheetMap mirrors scripts/config.py CACHE_SHEET_MAP. // CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP.
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it. // Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file. // Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
var CacheSheetMap = map[string]string{ var CacheSheetMap = map[string]string{
@@ -53,7 +53,7 @@ var CacheSheetMap = map[string]string{
} }
// Config holds all runtime configuration loaded from environment variables. // Config holds all runtime configuration loaded from environment variables.
// Mirrors scripts/config.py. // Mirrors legacy/scripts/config.py.
type Config struct { type Config struct {
CredentialsPath string CredentialsPath string
QRAccount string // IBAN of the primary account used for QR codes QRAccount string // IBAN of the primary account used for QR codes
@@ -9,7 +9,7 @@ func TestParseMonthReferences(t *testing.T) {
t.Parallel() t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-05: // All expected outputs verified against live Python implementation on 2026-05-05:
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))' // PYTHONPATH=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
tests := []struct { tests := []struct {
name string name string
input string input string
+2 -2
View File
@@ -1,4 +1,4 @@
// Package fees ports fee calculation from scripts/attendance.py. // Package fees ports fee calculation from legacy/scripts/attendance.py.
package fees package fees
const ( const (
@@ -6,7 +6,7 @@ const (
AdultFeeSingle = 200 // CZK for exactly 1 practice AdultFeeSingle = 200 // CZK for exactly 1 practice
) )
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py. // AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to AdultFeeDefault. // Months absent from this map fall back to AdultFeeDefault.
var AdultFeeMonthlyRate = map[string]int{ var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750, "2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
+1 -1
View File
@@ -2,7 +2,7 @@ package fees
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py. // JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to JuniorFeeDefault. // Months absent from this map fall back to JuniorFeeDefault.
var JuniorFeeMonthlyRate = map[string]int{ var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250, "2025-09": 250,
+1 -1
View File
@@ -1,2 +1,2 @@
// Package matching ports name/member matching from scripts/match_payments.py. // Package matching ports name/member matching from legacy/scripts/match_payments.py.
package matching package matching
+1 -1
View File
@@ -14,7 +14,7 @@ var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value // a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error. // (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
// //
// Ports scripts/match_payments.py format_date. // Ports legacy/scripts/match_payments.py format_date.
func FormatDate(val any) string { func FormatDate(val any) string {
if val == nil { if val == nil {
return "" return ""
@@ -1,8 +1,8 @@
package matching package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -c ' // PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import format_date // from match_payments import format_date
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]: // for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
// print(repr(format_date(v))) // print(repr(format_date(v)))
+1 -1
View File
@@ -33,7 +33,7 @@ type InferredDetails struct {
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year; // defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests). // callers should pass time.Now().Year() or a fixed year for deterministic tests).
// //
// Ports scripts/match_payments.py infer_transaction_details. // Ports legacy/scripts/match_payments.py infer_transaction_details.
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails { func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID) searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
+2 -2
View File
@@ -1,8 +1,8 @@
package matching package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 << 'EOF' // PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
// from match_payments import infer_transaction_details // from match_payments import infer_transaction_details
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"] // MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
// cases = [ // cases = [
+1 -1
View File
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
// MatchMembers finds members mentioned in text and returns them with a // MatchMembers finds members mentioned in text and returns them with a
// confidence level of "auto" (reliable) or "review" (needs human verification). // confidence level of "auto" (reliable) or "review" (needs human verification).
// //
// Algorithm (ported verbatim from scripts/match_payments.py match_members): // Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
// 1. Exact short-circuit: if any member's full normalized name appears as whole // 1. Exact short-circuit: if any member's full normalized name appears as whole
// words in normalize(text), return ONLY those matches as auto. This prevents // words in normalize(text), return ONLY those matches as auto. This prevents
// nickname "tov" from matching inside surname "ottova". // nickname "tov" from matching inside surname "ottova".
@@ -1,9 +1,9 @@
package matching package matching
// Expected values verified against scripts/match_payments.py and // Expected values verified against legacy/scripts/match_payments.py and
// tests/test_match_members.py on 2026-05-06: // tests/test_match_members.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -c ' // PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import match_members // from match_payments import match_members
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"] // MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
// cases = [ // cases = [
+1 -1
View File
@@ -19,7 +19,7 @@ var (
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are // this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
// dropped. // dropped.
// //
// Ports scripts/match_payments.py _build_name_variants. // Ports legacy/scripts/match_payments.py _build_name_variants.
func BuildNameVariants(name string) []string { func BuildNameVariants(name string) []string {
var nickname string var nickname string
if m := nicknameRe.FindStringSubmatch(name); m != nil { if m := nicknameRe.FindStringSubmatch(name); m != nil {
@@ -1,8 +1,8 @@
package matching package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -c ' // PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import _build_name_variants // from match_payments import _build_name_variants
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]: // for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
// print(repr(n), "->", _build_name_variants(n)) // print(repr(n), "->", _build_name_variants(n))
+2 -2
View File
@@ -1,4 +1,4 @@
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py. // Package money ports Czech-locale currency parsing from legacy/scripts/infer_payments.py.
package money package money
import ( import (
@@ -11,7 +11,7 @@ import (
var ErrInvalidAmount = errors.New("money: invalid CZK amount") var ErrInvalidAmount = errors.New("money: invalid CZK amount")
// ParseCZK parses a Czech-locale amount string and returns the value in CZK // ParseCZK parses a Czech-locale amount string and returns the value in CZK
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount: // as a float64. Mirrors legacy/scripts/infer_payments.py parse_czk_amount:
// //
// - empty input → (0, nil) // - empty input → (0, nil)
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python) // - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
+1 -1
View File
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
t.Parallel() t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06: // All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from infer_payments import parse_czk_amount // from infer_payments import parse_czk_amount
// for v in [None, "", "0", "500", "500 Kč", "500 CZK", // for v in [None, "", "0", "500", "500 Kč", "500 CZK",
// "1 500", "1500.00", "1 500.00", // "1 500", "1500.00", "1 500.00",
+3 -3
View File
@@ -1,4 +1,4 @@
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py. // Package reconcile ports the three-phase payment reconciliation from legacy/scripts/match_payments.py.
package reconcile package reconcile
import ( import (
@@ -105,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key // canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
// used to resolve Person-column values that drift from canonical attendance-sheet names. // used to resolve Person-column values that drift from canonical attendance-sheet names.
// Ports scripts/match_payments.py canonical_member_key. // Ports legacy/scripts/match_payments.py canonical_member_key.
func canonicalMemberKey(name string) string { func canonicalMemberKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ") return strings.Join(strings.Fields(czech.Normalize(name)), " ")
} }
@@ -124,7 +124,7 @@ type monthExpected struct {
// defaultYear seeds czech.ParseMonthReferences in the inference fallback. // defaultYear seeds czech.ParseMonthReferences in the inference fallback.
// Pass time.Now().Year() in production; pass a fixed year in tests. // Pass time.Now().Year() in production; pass a fixed year in tests.
// //
// Ports scripts/match_payments.py reconcile. // Ports legacy/scripts/match_payments.py reconcile.
func Reconcile( func Reconcile(
members []Member, members []Member,
sortedMonths []string, sortedMonths []string,
@@ -1,8 +1,8 @@
package reconcile package reconcile
// Expected values verified against scripts/match_payments.py on 2026-05-06: // Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v // PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
// //
// All Python test cases are ported below. Additional Go-only cases are marked with [Go]. // All Python test cases are ported below. Additional Go-only cases are marked with [Go].
+2 -2
View File
@@ -1,5 +1,5 @@
// Package synch ports the bank-sync deduplication helper from // Package synch ports the bank-sync deduplication helper from
// scripts/sync_fio_to_sheets.py. // legacy/scripts/sync_fio_to_sheets.py.
package synch package synch
import ( import (
@@ -30,7 +30,7 @@ type Transaction struct {
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used // "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
// as the dedup key in column K of the payments sheet. // as the dedup key in column K of the payments sheet.
// //
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id. // Byte-stable with legacy/scripts/sync_fio_to_sheets.py generate_sync_id.
func GenerateSyncID(tx Transaction) string { func GenerateSyncID(tx Transaction) string {
currency := tx.Currency currency := tx.Currency
if currency == "" { if currency == "" {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
// All expected digests verified against the live Python implementation on 2026-05-06: // All expected digests verified against the live Python implementation on 2026-05-06:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from sync_fio_to_sheets import generate_sync_id // from sync_fio_to_sheets import generate_sync_id
// cases = [ // cases = [
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"}, // {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
@@ -88,7 +88,7 @@ func TestGenerateSyncID(t *testing.T) {
// All expected strings verified against the live Python implementation on 2026-05-06: // All expected strings verified against the live Python implementation on 2026-05-06:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]: // for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
// print(repr(v), "->", repr(str(v))) // print(repr(v), "->", repr(str(v)))
// ' // '
+2 -2
View File
@@ -1,5 +1,5 @@
// Package cache implements a Drive-modifiedTime-gated JSON file cache, // Package cache implements a Drive-modifiedTime-gated JSON file cache,
// mirroring scripts/cache_utils.py. // mirroring legacy/scripts/cache_utils.py.
package cache package cache
import ( import (
@@ -26,7 +26,7 @@ type cacheFile struct {
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime. // FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
// //
// Two TTL knobs mirror scripts/cache_utils.py: // Two TTL knobs mirror legacy/scripts/cache_utils.py:
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely. // - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID. // - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
// //
+1 -1
View File
@@ -17,7 +17,7 @@ type httpDoer interface {
} }
// apiClient fetches transactions from the Fio REST API (JSON). // apiClient fetches transactions from the Fio REST API (JSON).
// Ports scripts/fio_utils.py fetch_transactions_api. // Ports legacy/scripts/fio_utils.py fetch_transactions_api.
type apiClient struct { type apiClient struct {
token string token string
hc httpDoer hc httpDoer
+1 -1
View File
@@ -15,7 +15,7 @@ import (
) )
// transparentClient fetches transactions from the Fio transparent account page (HTML). // transparentClient fetches transactions from the Fio transparent account page (HTML).
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent. // Ports legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
type transparentClient struct { type transparentClient struct {
accountNum string accountNum string
hc httpDoer hc httpDoer
+33
View File
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,36 @@
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)
}
})
}
}
+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 scripts/infer_payments.py infer_payments. // Ports legacy/scripts/infer_payments.py infer_payments.
func InferPayments( func InferPayments(
ctx context.Context, ctx context.Context,
spreadsheetID string, spreadsheetID string,
@@ -94,7 +94,7 @@ func InferPayments(
if idx < 0 || idx >= len(row) { if idx < 0 || idx >= len(row) {
return "" return ""
} }
return strings.TrimSpace(fmt.Sprint(row[idx])) return strings.TrimSpace(sheets.CellString(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
+5 -4
View File
@@ -6,13 +6,14 @@ 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 scripts/sync_fio_to_sheets.py. // Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py.
var columnLabels = []string{ var columnLabels = []string{
"Date", "Amount", "manual fix", "Person", "Purpose", "Date", "Amount", "manual fix", "Person", "Purpose",
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
@@ -37,7 +38,7 @@ type SyncOpts struct {
// SyncToSheets fetches Fio transactions from all provided clients and appends // SyncToSheets fetches Fio transactions from all provided clients and appends
// new ones to the payments sheet. Returns the number of rows appended. // new ones to the payments sheet. Returns the number of rows appended.
// Ports scripts/sync_fio_to_sheets.py sync_to_sheets. // Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
func SyncToSheets( func SyncToSheets(
ctx context.Context, ctx context.Context,
spreadsheetID string, spreadsheetID string,
@@ -130,7 +131,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, tx.VS, tx.Message, tx.BankID, syncIDs[i], tx.Sender, sheets.TextCell(tx.VS), tx.Message, tx.BankID, syncIDs[i],
}) })
} }
@@ -141,7 +142,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], row[7], row[8]) row[0], row[1], row[6], strings.TrimPrefix(fmt.Sprint(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")
@@ -38,6 +38,10 @@ 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) {
@@ -9,11 +9,11 @@ import (
) )
// printFeesTable writes a fixed-width adult-fees table to w. // printFeesTable writes a fixed-width adult-fees table to w.
// Mirrors scripts/calculate_fees.py main(). // Mirrors legacy/scripts/calculate_fees.py main().
// //
// Verify with: // Verify with:
// //
// PYTHONPATH=scripts:. python scripts/calculate_fees.py // PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) { func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
type row struct { type row struct {
name string name string
@@ -1,8 +1,8 @@
package membership package membership
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06: // Golden strings verified against legacy/scripts/calculate_fees.py on 2026-05-06:
// //
// PYTHONPATH=scripts:. python scripts/calculate_fees.py // PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
// //
// (feed equivalent fixture data via attendance sheet or local CSV) // (feed equivalent fixture data via attendance sheet or local CSV)
@@ -10,11 +10,11 @@ import (
) )
// printReconcileReport writes the full balance report to w. // printReconcileReport writes the full balance report to w.
// Mirrors scripts/match_payments.py print_report(). // Mirrors legacy/scripts/match_payments.py print_report().
// //
// Verify with: // Verify with:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions // from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
// ...' // ...'
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) { func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
@@ -1,8 +1,8 @@
package membership package membership
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06: // Golden strings verified against legacy/scripts/match_payments.py print_report() on 2026-05-06:
// //
// PYTHONPATH=scripts:. python -c ' // PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report // from match_payments import print_report
// result = { // result = {
// "members": { // "members": {
+11 -11
View File
@@ -18,20 +18,20 @@ import (
"time" "time"
) )
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py) // Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
const ( const (
colName = 0 colName = 0
colTier = 1 colTier = 1
firstDateCol = 3 firstDateCol = 3
) )
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py. // AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py.
// Source month → target month (source attendance accumulated into target). // Source month → target month (source attendance accumulated into target).
var AdultMergedMonths = map[string]string{ var AdultMergedMonths = map[string]string{
"2025-09": "2025-10", "2025-09": "2025-10",
} }
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py. // JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in legacy/scripts/attendance.py.
var JuniorMergedMonths = map[string]string{ var JuniorMergedMonths = map[string]string{
"2025-12": "2026-01", "2025-12": "2026-01",
"2025-09": "2025-10", "2025-09": "2025-10",
@@ -127,11 +127,11 @@ func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.Excepti
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Attendance CSV parsing (ports scripts/attendance.py) // Attendance CSV parsing (ports legacy/scripts/attendance.py)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns. // parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m"). // Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m").
func parseDates(header []string) []struct { func parseDates(header []string) []struct {
col int col int
month string month string
@@ -200,7 +200,7 @@ func countTrue(row []string, cols []int) int {
// parseAdultRows converts raw CSV rows to []reconcile.Member. // parseAdultRows converts raw CSV rows to []reconcile.Member.
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream). // Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
// Ports scripts/attendance.py get_members_with_fees. // Ports legacy/scripts/attendance.py get_members_with_fees.
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) { func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
if len(rows) < 2 { if len(rows) < 2 {
return nil, nil, nil return nil, nil, nil
@@ -246,7 +246,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab // parseJuniorRows builds junior members by merging tier-J rows from the adult tab
// with the junior sheet, then calling CalculateJuniorFee. // with the junior sheet, then calling CalculateJuniorFee.
// Ports scripts/attendance.py get_junior_members_with_fees. // Ports legacy/scripts/attendance.py get_junior_members_with_fees.
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) { func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
if len(adultRows) < 2 || len(juniorRows) < 2 { if len(adultRows) < 2 || len(juniorRows) < 2 {
return nil, nil, nil return nil, nil, nil
@@ -362,7 +362,7 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data) // Payments sheet row parsing (ports legacy/scripts/match_payments.py fetch_sheet_data)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) { func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
@@ -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 fmt.Sprint(row[i]) return sheets.CellString(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 != "" && iv != "<nil>" { if iv := getVal(row, idxInferred); iv != "" {
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 scripts/match_payments.py fetch_exceptions) // Exceptions tab parsing (ports legacy/scripts/match_payments.py fetch_exceptions)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception { func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
@@ -123,6 +123,8 @@ 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)
@@ -131,8 +133,8 @@ func TestLoadTransactions(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(txns) != 2 { if len(txns) != 3 {
t.Fatalf("want 2 transactions, got %d", len(txns)) t.Fatalf("want 3 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)
@@ -146,6 +148,12 @@ 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 -1
View File
@@ -13,7 +13,7 @@ import (
type monthSums struct{ expected, paid int } type monthSums struct{ expected, paid int }
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output. // buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_adults_view_model. // Mirrors legacy/scripts/views.py:build_adults_view_model.
func buildAdultsResponse( func buildAdultsResponse(
members []domreconcile.Member, members []domreconcile.Member,
sortedMonths []string, sortedMonths []string,
+3 -3
View File
@@ -12,7 +12,7 @@ import (
// getMonthLabels builds display labels for sortedMonths, merging month names // getMonthLabels builds display labels for sortedMonths, merging month names
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target. // (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
// Mirrors scripts/views.py:get_month_labels. // Mirrors legacy/scripts/views.py:get_month_labels.
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string { func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
labels := make(map[string]string, len(sortedMonths)) labels := make(map[string]string, len(sortedMonths))
for _, m := range sortedMonths { for _, m := range sortedMonths {
@@ -72,14 +72,14 @@ func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`) var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalKey returns a normalized form of a person name used for deduplication. // canonicalKey returns a normalized form of a person name used for deduplication.
// Mirrors scripts/match_payments.py:canonical_member_key. // Mirrors legacy/scripts/match_payments.py:canonical_member_key.
func canonicalKey(name string) string { func canonicalKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ") return strings.Join(strings.Fields(czech.Normalize(name)), " ")
} }
// groupRawPaymentsByPerson groups transactions by the "person" column, // groupRawPaymentsByPerson groups transactions by the "person" column,
// canonicalizing names against memberNames where possible. // canonicalizing names against memberNames where possible.
// Mirrors scripts/views.py:group_payments_by_person (without the // Mirrors legacy/scripts/views.py:group_payments_by_person (without the
// "Unmatched / Unknown" bucket that is payments-view-specific). // "Unmatched / Unknown" bucket that is payments-view-specific).
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction { func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
canonicalByKey := make(map[string]string, len(memberNames)) canonicalByKey := make(map[string]string, len(memberNames))
+1 -1
View File
@@ -11,7 +11,7 @@ import (
) )
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output. // buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_juniors_view_model. // Mirrors legacy/scripts/views.py:build_juniors_view_model.
func buildJuniorsResponse( func buildJuniorsResponse(
members []domreconcile.Member, members []domreconcile.Member,
sortedMonths []string, sortedMonths []string,
+1 -1
View File
@@ -9,7 +9,7 @@ import (
) )
// buildPaymentsResponse constructs the PaymentsResponse wire type. // buildPaymentsResponse constructs the PaymentsResponse wire type.
// Mirrors scripts/views.py:build_payments_view_model. // Mirrors legacy/scripts/views.py:build_payments_view_model.
func buildPaymentsResponse( func buildPaymentsResponse(
txns []domreconcile.Transaction, txns []domreconcile.Transaction,
memberNames []string, memberNames []string,
+1 -1
View File
@@ -1,7 +1,7 @@
package api package api
// VersionResponse is the JSON contract for GET /api/version. // VersionResponse is the JSON contract for GET /api/version.
// Keys match Python's BUILD_META dict (see app.py). // Keys match Python's BUILD_META dict (see legacy/app.py).
type VersionResponse struct { type VersionResponse struct {
Tag string `json:"tag"` Tag string `json:"tag"`
Commit string `json:"commit"` Commit string `json:"commit"`
+3 -3
View File
@@ -85,10 +85,10 @@ git diff go/tests/fixtures/ # review changes before committing
To refresh a single function: To refresh a single function:
```bash ```bash
PYTHONPATH=scripts:. python3 scripts/capture_fixtures.py --func normalize --all \ PYTHONPATH=legacy/scripts:legacy python3 legacy/scripts/capture_fixtures.py --func normalize --all \
| while IFS= read -r line; do | while IFS= read -r line; do
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])") id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
echo "$line" | python3 scripts/scrub_fixtures.py \ echo "$line" | python3 legacy/scripts/scrub_fixtures.py \
> go/tests/fixtures/pure/normalize/${id}.json > go/tests/fixtures/pure/normalize/${id}.json
done done
``` ```
@@ -120,7 +120,7 @@ verify that no real roster names appear in their `member_names` arrays.
## Adding a new fixture ## Adding a new fixture
1. Add a seed to `scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`. 1. Add a seed to `legacy/scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function 2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
is new. is new.
3. Run the single-file capture recipe above and review the diff. 3. Run the single-file capture recipe above and review the diff.
@@ -13,7 +13,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
func TestBuildNameVariantsParity(t *testing.T) { func TestBuildNameVariantsParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
func TestCalculateFeeParity(t *testing.T) { func TestCalculateFeeParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
func TestCalculateJuniorFeeParity(t *testing.T) { func TestCalculateJuniorFeeParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from match_payments import format_date; print(format_date(46027))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import format_date; print(format_date(46027))"
func TestFormatDateParity(t *testing.T) { func TestFormatDateParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c " // PYTHONPATH=legacy/scripts:legacy python3 -c "
// from sync_fio_to_sheets import generate_sync_id // from sync_fio_to_sheets import generate_sync_id
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))" // print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
// //
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c " // PYTHONPATH=legacy/scripts:legacy python3 -c "
// from match_payments import infer_transaction_details // from match_payments import infer_transaction_details
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))" // print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
func TestMatchMembersParity(t *testing.T) { func TestMatchMembersParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
func TestNormalizeParity(t *testing.T) { func TestNormalizeParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
// //
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0. // Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s) // Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
@@ -13,7 +13,7 @@ import (
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))" // PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
func TestParseMonthReferencesParity(t *testing.T) { func TestParseMonthReferencesParity(t *testing.T) {
t.Parallel() t.Parallel()
@@ -6,7 +6,7 @@
// //
// Verify expected values against live Python: // Verify expected values against live Python:
// //
// PYTHONPATH=scripts:. python3 -c " // PYTHONPATH=legacy/scripts:legacy python3 -c "
// from match_payments import reconcile // from match_payments import reconcile
// # ... build members/months/txs/exceptions... // # ... build members/months/txs/exceptions...
// import json; print(json.dumps(reconcile(...)))" // import json; print(json.dumps(reconcile(...)))"
+2 -2
View File
@@ -1,6 +1,6 @@
.git .git
.venv .venv
__pycache__ **/__pycache__
*.pyc **/*.pyc
.claude .claude
.gemini .gemini
+6 -2
View File
@@ -179,7 +179,9 @@ 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,
) )
return jsonify(_unwrap_view_model_for_api(vm)) out = _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():
@@ -203,7 +205,9 @@ 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,
) )
return jsonify(_unwrap_view_model_for_api(vm)) out = _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():
+1 -1
View File
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir \
pillow \ pillow \
gunicorn gunicorn
COPY app.py Makefile ./ COPY app.py ./
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/
COPY templates/ ./templates/ COPY templates/ ./templates/
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "fuj-management" name = "fuj-management"
version = "0.10" version = "0.10"
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)" description = "Legacy Python implementation of the FUJ (Frisbee Ultimate Jablonec) management tools; the Go implementation in ../go is primary"
dependencies = [ dependencies = [
"flask>=3.1.3", "flask>=3.1.3",
"google-api-python-client>=2.162.0", "google-api-python-client>=2.162.0",
@@ -1,14 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Regenerate the full fixture corpus. # Regenerate the full fixture corpus.
# Safe to re-run — always overwrites. # Safe to re-run — always overwrites.
# Requires: tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members). # Requires: legacy/tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
set -euo pipefail set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LEGACY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(cd "$LEGACY/.." && pwd)"
FIXTURES="$REPO/go/tests/fixtures" FIXTURES="$REPO/go/tests/fixtures"
CAPTURE_CMD="PYTHONPATH=$REPO/scripts:. python3 $REPO/scripts/capture_fixtures.py" CAPTURE_CMD="PYTHONPATH=$LEGACY/scripts:$LEGACY python3 $LEGACY/scripts/capture_fixtures.py"
SCRUB_CMD="python3 $REPO/scripts/scrub_fixtures.py" SCRUB_CMD="python3 $LEGACY/scripts/scrub_fixtures.py"
run_func() { run_func() {
local func="$1" local func="$1"
@@ -18,7 +19,7 @@ run_func() {
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \ case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")" echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$dir/${case_id}.json" echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
done done
} }
@@ -39,7 +40,7 @@ echo "==> Capturing reconcile fixtures..."
mkdir -p "$FIXTURES/reconcile" mkdir -p "$FIXTURES/reconcile"
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")" case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json" echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
done done
echo "==> Done. Review with: git diff go/tests/fixtures/" echo "==> Done. Review with: git diff go/tests/fixtures/"
@@ -59,6 +59,16 @@ def get_sheets_service(credentials_path: str):
return _SHEETS_SERVICE return _SHEETS_SERVICE
def as_text_cell(value) -> str:
"""Mark a value 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); it is not part of the stored value. Empty stays empty.
"""
s = "" if value is None else str(value)
return f"'{s}" if s else ""
def generate_sync_id(tx: dict) -> str: def generate_sync_id(tx: dict) -> str:
"""Generate a unique SHA-256 hash for a transaction. """Generate a unique SHA-256 hash for a transaction.
@@ -209,7 +219,7 @@ def sync_to_sheets(
"", # Purpose "", # Purpose
"", # Inferred Amount "", # Inferred Amount
tx.get("sender", ""), tx.get("sender", ""),
tx.get("vs", ""), as_text_cell(tx.get("vs", "")),
tx.get("message", ""), tx.get("message", ""),
tx.get("bank_id", ""), tx.get("bank_id", ""),
sync_id, sync_id,
@@ -6,7 +6,7 @@ from app import app
EXPECTED_ADULTS_KEYS = { EXPECTED_ADULTS_KEYS = {
"months", "raw_months", "results", "totals", "member_data", "month_labels", "months", "raw_months", "results", "totals", "member_data", "month_labels",
"raw_payments", "credits", "debts", "unmatched", "attendance_url", "raw_payments", "credits", "debts", "unmatched", "attendance_url",
"payments_url", "bank_account", "current_month", "payments_url", "bank_account", "current_month", "months_to_show",
} }
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"} EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
@@ -139,6 +139,7 @@ class TestWebApp(unittest.TestCase):
self.assertIsInstance(data['member_data'], dict) self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict) self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict) self.assertIsInstance(data['raw_payments'], dict)
self.assertIsInstance(data['months_to_show'], int)
@patch('app.get_cached_data', side_effect=_bypass_cache) @patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data') @patch('app.fetch_sheet_data')
@@ -166,6 +167,7 @@ class TestWebApp(unittest.TestCase):
self.assertIsInstance(data['member_data'], dict) self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict) self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict) self.assertIsInstance(data['raw_payments'], dict)
self.assertIsInstance(data['months_to_show'], int)
@patch('app.get_cached_data', side_effect=_bypass_cache) @patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data') @patch('app.fetch_sheet_data')
@@ -1,6 +1,7 @@
import unittest import unittest
from unittest.mock import MagicMock, patch
from scripts.match_payments import canonical_member_key, reconcile from scripts.match_payments import canonical_member_key, fetch_sheet_data, reconcile
class TestCanonicalMemberKey(unittest.TestCase): class TestCanonicalMemberKey(unittest.TestCase):
@@ -67,3 +68,23 @@ class TestReconcileTolerantPersonMatching(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class TestFetchSheetDataVS(unittest.TestCase):
"""Numeric VS cells arrive as floats (UNFORMATTED_VALUE) and must stay plain digit strings."""
def _fetch(self, vs_cell):
service = MagicMock()
service.spreadsheets().values().get().execute.return_value = {"values": [
["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount",
"Sender", "VS", "Message", "Bank ID", "Sync ID"],
["2026-04-15", 750, "", "", "", "", "Test Sender", vs_cell, "fee", "", "abc"],
]}
with patch("scripts.match_payments.get_sheets_service", return_value=service):
return fetch_sheet_data("sheet-id", "creds.json")
def test_numeric_vs_is_plain_digit_string(self):
self.assertEqual(self._fetch(2609767878.0)[0]["vs"], "2609767878")
def test_string_vs_is_kept(self):
self.assertEqual(self._fetch("0308")[0]["vs"], "0308")
+33
View File
@@ -0,0 +1,33 @@
import contextlib
import io
import unittest
from unittest.mock import MagicMock, patch
from scripts.sync_fio_to_sheets import COLUMN_LABELS, as_text_cell, sync_to_sheets
class TestAsTextCell(unittest.TestCase):
def test_digits_get_text_marker(self):
self.assertEqual(as_text_cell("0308"), "'0308")
self.assertEqual(as_text_cell(123), "'123")
def test_empty_stays_empty(self):
self.assertEqual(as_text_cell(""), "")
self.assertEqual(as_text_cell(None), "")
class TestSyncWritesVSAsText(unittest.TestCase):
def test_appended_row_has_text_vs(self):
service = MagicMock()
sheet = service.spreadsheets.return_value
sheet.values.return_value.get.return_value.execute.return_value = {"values": [COLUMN_LABELS]}
tx = {"date": "2026-04-10", "amount": 750.0, "sender": "Test Sender",
"vs": "0308", "message": "fee", "bank_id": "111"}
with patch("scripts.sync_fio_to_sheets.get_sheets_service", return_value=service), \
patch("scripts.sync_fio_to_sheets.fetch_transactions_all", return_value=[tx]), \
contextlib.redirect_stdout(io.StringIO()):
sync_to_sheets("sheet-id", "creds.json", days=30)
body = sheet.values.return_value.append.call_args.kwargs["body"]
self.assertEqual(len(body["values"]), 1)
self.assertEqual(body["values"][0][7], "'0308")
View File