Compare commits

...
Author SHA1 Message Date
kacerr ac9f974007 Merge pull request 'fix(pii): remove real member names tied to payments' (#51) from fix/scrub-member-names into main
Reviewed-on: #51
2026-10-09 00:23:31 +02:00
kacerrandClaude Opus 5.5 e53b57f976 fix(pii): remove real member names tied to payments
- Scrub three parity fixtures that still held raw bank messages
- Use fictional names in the junior regression test, its changelog
  entry and its plan file
- State the rule in CLAUDE.md: a real name as a bare test input is
  fine; a real name next to payment details, or any junior or parent
  name, is not
- Tick T4 in the cleanup task list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:22:15 +02:00
kacerr bc49a8286d Merge pull request 'feat(fees): junior rate for 2026-08 is 0' (#50) from feat/junior-fee-2026-08 into main
Build and Push / build (push) Successful in 10s
Build and Push / build-go (push) Successful in 1m54s
Reviewed-on: #50
2026-10-08 21:27:57 +02:00
kacerrandClaude Opus 5.5 21db863958 feat(fees): junior rate for 2026-08 is 0
- 2026-08 added to the junior rate table at 0 CZK, same as adults
- A single practice in a 0-rate month is 0 instead of "?", since a
  free month needs no manual review
- Mirrored in Go and legacy Python; new calculate_junior_fee parity
  fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:27:10 +02:00
kacerr 7d55eca189 Merge pull request 'feat(infer): messages starting with "other:" are not monthly fees' (#49) from feat/infer-other-purpose into main
Build and Push / build (push) Successful in 1m10s
Build and Push / build-go (push) Successful in 2m1s
Reviewed-on: #49
2026-10-08 12:40:29 +02:00
kacerrandClaude Opus 5.5 bbad19d822 feat(infer): messages starting with "other:" are not monthly fees
When the bank message starts with "other:" (case-insensitive),
inference does not look for a month, neither in the text nor from the
payment date. The trimmed message is written to Purpose, so reconcile
books the payment as an "other" transaction for the person instead of
a monthly fee. Messages containing "other:" elsewhere are unchanged.

- matching.IsOtherPurpose / is_other_purpose shared by inference and
  reconcile
- reconcile applies the rule to rows not yet processed by inference
- no person found: Purpose is still written, row shows as unmatched
- implemented in Go and legacy Python; four new parity fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00
kacerrandClaude Opus 5.5 294c40fa42 fix(go): show "Expected: ?" in junior tooltip for unknown fees
A junior month with one session has an unknown expected fee. When such
a month had a payment, the Go tooltip printed "Expected: 0" where
Python prints "Expected: ?", which made make parity fail on
/api/juniors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00
kacerr d5da993690 Merge pull request 'feat(go): scheduled bank sync (Go only)' (#48) from feat/scheduled-bank-sync into main
Reviewed-on: #48
2026-10-08 12:27:14 +02:00
kacerrandClaude Opus 5.5 2c2db60fe7 feat(go): scheduled bank sync (Go only)
The Go server now runs sync -> infer -> flush-cache by itself at the
times in SYNC_SCHEDULE (comma-separated HH:MM, default 04:00, "off"
disables), read in SYNC_TZ (default Europe/Prague). Nothing runs at
startup; an invalid value stops the server with an error.

- internal/scheduler: time-of-day parsing, Next, and the run loop
- banksync.Runner: one lock shared by /sync-bank and the scheduler so
  runs never overlap, plus the status of the last run
- footer shows the last sync since start and the next scheduled run
- binary embeds time/tzdata because the Alpine image has no zone data

Deliberately not implemented in the legacy Python app, which is being
phased out; recorded in CLAUDE.md under "Deliberately Go-only features".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:17:15 +02:00
kacerr 1d5a1c95d2 Merge pull request 'fix(sync): dashboard sync covers current year plus Nov-Dec of previous' (#47) from fix/sync-window-prev-year-overlap into main
Reviewed-on: #47
2026-10-08 12:05:16 +02:00
kacerrandClaude Opus 5.5 62c6dc2ca0 fix(sync): dashboard sync covers current year plus Nov-Dec of previous
/sync-bank now uses one shared default window in both implementations:
1 November of the previous year to 31 December of the current year.
Payments dated around New Year are picked up in January; duplicates
are still filtered by Sync ID.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:53:18 +02:00
kacerr 14cdbeac21 Merge pull request 'fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs' (#45) from fix/remove-leaky-k8s-deploy-workflow into main
Reviewed-on: #45
2026-10-08 11:49:02 +02:00
kacerrandClaude Opus 5.5 5b162d0030 fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs
The workflow ran on every push to every branch and deployed nothing.
It echoed the raw Vault login response, the Kanidm API token response
and the OIDC token response to the job log, dumped the environment,
and configured kubectl with --insecure-skip-tls-verify.

Deployment is handled by gitops-update.yaml. Docs updated to describe
that flow; T1 marked done in the cleanup task list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:48:35 +02:00
kacerr cfdc5cbb2a Merge pull request 'fix: VS always a string; months_to_show in Python JSON API' (#44) from fix/api-parity-vs-months-to-show into main
Deploy to K8s / deploy (push) Successful in 13s
Reviewed-on: #44
2026-10-08 11:45:53 +02:00
kacerr e2ae5a7ec4 Merge pull request 'refactor: move Python implementation to legacy/, make Go primary' (#43) from feat/python-to-legacy into main
Deploy to K8s / deploy (push) Successful in 13s
Reviewed-on: #43
2026-10-08 11:45:11 +02:00
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
64 changed files with 2062 additions and 239 deletions
-111
View File
@@ -1,111 +0,0 @@
name: Deploy to K8s
on:
workflow_dispatch:
push:
branches:
- '**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Debug - print Gitea Actions environment
run: |
echo "=== All environment variables ==="
env | sort
echo ""
echo "=== GITHUB_* / GITEA_* / ACTIONS_* vars ==="
env | grep -E '^(GITHUB|GITEA|ACTIONS)_' | sort
- name: Install kubectl
run: |
curl -sfLO "https://dl.k8s.io/release/$(curl -sfL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
install kubectl /usr/local/bin/
- name: Get Kanidm token from Vault
id: vault
run: |
set -euxo pipefail
VAULT_AUTH_RESPONSE=$(curl -f --request POST \
--data '{"role_id":"${{ secrets.VAULT_ROLE_ID }}","secret_id":"${{ secrets.VAULT_SECRET_ID }}"}' \
https://vault.hrajfrisbee.cz/v1/auth/approle/login)
echo "Vault auth response: $VAULT_AUTH_RESPONSE" >&2
VAULT_TOKEN=$(echo "$VAULT_AUTH_RESPONSE" | jq -r '.auth.client_token')
# Read the kanidm API token
SECRET_RESPONSE=$(curl -f \
-H "X-Vault-Token: ${VAULT_TOKEN}" \
https://vault.hrajfrisbee.cz/v1/secret/data/gitea/gitea-ci)
echo "Secret response: $SECRET_RESPONSE" >&2
API_TOKEN=$(echo "$SECRET_RESPONSE" | jq -r '.data.data.token')
echo "::add-mask::${API_TOKEN}"
echo "api_token=${API_TOKEN}" >> "$GITHUB_OUTPUT"
- name: Exchange for K8s OIDC token via Kanidm
id: k8s
run: |
API_TOKEN="${{ steps.vault.outputs.api_token }}"
echo "api_token length: ${#API_TOKEN}" >&2
echo "api_token prefix (first 8 chars): ${API_TOKEN:0:8}..." >&2
HTTP_BODY=$(mktemp)
HTTP_STATUS=$(curl -sS -X POST "https://idm.home.hrajfrisbee.cz/oauth2/token" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "client_id=k8s" \
-d "subject_token=${API_TOKEN}" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "audience=k8s" \
-d "scope=openid groups" \
-o "$HTTP_BODY" -w "%{http_code}")
echo "HTTP status: $HTTP_STATUS" >&2
echo "Response body:" >&2
cat "$HTTP_BODY" >&2
RESPONSE=$(cat "$HTTP_BODY")
ID_TOKEN=$(echo "$RESPONSE" | jq -r '.id_token // empty')
if [ -z "$ID_TOKEN" ]; then
echo "::error::Kanidm token exchange failed (HTTP $HTTP_STATUS)"
exit 1
fi
echo "::add-mask::${ID_TOKEN}"
echo "id_token=${ID_TOKEN}" >> "$GITHUB_OUTPUT"
# Sanity check
# Warning - this is the part that is failing
# echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, groups, exp}'
- name: Debug - print environment before kubectl
run: env | sort
- name: Configure kubectl & deploy
run: |
echo "${{ secrets.K8S_CA_CERT }}" > /tmp/ca.crt
kubectl config set-cluster mycluster \
--server=https://192.168.0.31:6443 \
--insecure-skip-tls-verify=true
# --certificate-authority=/tmp/ca.crt \
kubectl config set-credentials gitea-ci \
--token="${{ steps.k8s.outputs.id_token }}"
kubectl config set-context gitea-ci \
--cluster=mycluster --user=gitea-ci
kubectl config use-context gitea-ci
kubectl auth whoami
kubectl get ns
# your deploy here
# kubectl apply -f k8s/
+63 -1
View File
@@ -1,5 +1,67 @@
# Changelog
## 2026-10-09 00:21 CEST — fix(pii): remove real member names tied to payments
- Three parity fixtures held raw bank messages with real names; the names are now `Member_<hash>` pseudonyms, as the scrubber produces.
- The junior regression test, its changelog entry and its plan file used a real junior's and parent's name with paid months; replaced with fictional names.
- `CLAUDE.md` now states the rule: a real name as a bare test input is fine, a real name next to payment details or any junior/parent name is not.
- Git history still contains the old text.
## 2026-10-08 21:27 CEST — feat(fees): junior rate for 2026-08 is 0
- Junior monthly rate for 2026-08: 500 → 0 CZK, same as adults.
- A single practice in a 0-rate month is now 0 instead of `?`, since there is nothing to review in a free month. Other months are unchanged.
- Mirrored in `go/internal/domain/fees/junior.go` and `legacy/scripts/attendance.py`; two new parity fixtures for `calculate_junior_fee`.
## 2026-10-08 12:37 CEST — feat(infer): messages starting with "other:" are not monthly fees
- When the bank message starts with `other:` (case-insensitive), inference no longer looks for a month. It copies the trimmed message into Purpose, so the payment is linked to the person as an "other" transaction and not counted as a monthly fee. Messages that contain `other:` elsewhere are inferred as before.
- If no person is found, Purpose is still written and the row shows under unmatched for a manual fix.
- Reconcile applies the same rule to rows not yet processed by inference.
- Existing rows are not re-inferred; clear Person and Purpose on a row to reprocess it.
- Go and Python: `matching.IsOtherPurpose` / `is_other_purpose`, `InferTransactionDetails` / `infer_transaction_details`, reconcile fallback; four new parity fixtures.
- Also fixed (Go): junior cell tooltip showed `Expected: 0` instead of `Expected: ?` when a month with unknown expected fee had a payment.
## 2026-10-08 12:16 CEST — feat(go): scheduled bank sync (Go only)
- The Go server now runs the bank sync (sync → infer → flush cache) by itself at the times in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`, `off` disables), read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup; an invalid value stops the server at startup with an error.
- Manual `/sync-bank` and scheduled runs share one lock, so two syncs can no longer overlap; a second request gets "a sync is already running".
- The page footer shows the last sync since start (time, trigger, ok/FAILED) and the next scheduled run.
- The binary embeds the time zone database (`time/tzdata`), since the Alpine image has none.
- Deliberately not implemented in the legacy Python app, which is being phased out; recorded in `CLAUDE.md` under "Deliberately Go-only features".
- Key files: `go/internal/scheduler/`, `go/internal/services/banksync/runner.go`, `go/cmd/fuj/main.go`, `go/internal/config/config.go`, `go/internal/web/`.
## 2026-10-08 12:04 CEST — fix(sync): dashboard sync covers the current year plus Nov–Dec of the previous one
- `/sync-bank` now uses one shared default window in both implementations: 1 November of the previous year to 31 December of the current year. Payments dated around New Year are picked up in January; duplicates are still filtered by Sync ID.
- Python previously used a hardcoded `2026-01-01`..`2026-12-31` and would have stopped syncing on 2027-01-01; Go used the current calendar year only.
- Added `make sync-year [YEAR=N]` with the same window; `make sync-2026` is kept. The CLI default without flags (last 30 days) is unchanged.
- Key files: `go/internal/services/banksync/sync.go` (`DefaultWindow`), `go/cmd/fuj/main.go`, `legacy/scripts/sync_fio_to_sheets.py` (`default_sync_window`), `legacy/app.py`.
## 2026-10-08 11:53 CEST — feat(fees): adult rates for 2026-07 through 2026-12, default 600
- Adult default monthly rate (months not in the table): 700 → 600 CZK.
- 2026-07 and 2026-08: 600 → 0 CZK; 2026-09 through 2026-12 added at 600 CZK (September and October were previously billed at the old 700 default).
- The single-session fee (200 CZK) is now capped at the month's rate, so a 0-rate month is free for one session too.
- Mirrored in `go/internal/domain/fees/fees.go` and `legacy/scripts/attendance.py`; three new parity fixtures for `calculate_fee`.
## 2026-10-08 11:48 CEST — fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs
- Deleted `.gitea/workflows/kubernetes-deploy.yaml`. It ran on every push to every branch, deployed nothing, and echoed the Vault login response, the Kanidm API token response and the OIDC token response to the job log.
- Deployment is handled by `gitops-update.yaml`. Repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and `K8S_CA_CERT` are now unused.
- Follow-up outside the repo: rotate the Vault AppRole secret and the Kanidm API token, and clear old job logs.
## 2026-10-08 11:34 CEST — fix(python): return months_to_show from /api/adults and /api/juniors
- Go already returned `months_to_show` in the JSON API; Python only passed it to its HTML templates. Backported the key so the API contract matches and `make parity` reports 3/3 routes.
- Key files: `legacy/app.py`, `legacy/tests/test_app.py`.
## 2026-10-08 11:34 CEST — fix: VS is always a string, on read and on write
- Go rendered numeric sheet cells with `fmt.Sprint`, so a numeric VS came out as `2.609767878e+09`. Text columns of the payments sheet now go through `sheets.CellString` (plain digits); bank inference uses it too.
- Both sync implementations now append VS as a text cell (leading apostrophe under `USER_ENTERED`), so Sheets no longer stores it as a number or drops leading zeros. Existing rows are unchanged.
- Key files: `go/internal/io/sheets/cell.go`, `go/internal/services/membership/sources.go`, `go/internal/services/banksync/{sync,infer}.go`, `legacy/scripts/sync_fio_to_sheets.py`.
## 2026-10-08 11:33 CEST — refactor: move Python implementation to legacy/, Go is primary
- Go (`go/`) is now the primary implementation. The original Python version moved to `legacy/` (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `uv.lock`, `build/`) and is considered legacy; new features are still built in both.
@@ -239,7 +301,7 @@
## 2026-05-06 16:38 CEST — fix: include juniors in payment-inference roster
- `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher.
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Dvořák were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
- Two regression tests added to `tests/test_match_members.py`.
## 2026-05-06 16:05 CEST — feat(go/M2.10): port domain/reconcile.Reconcile
+25 -6
View File
@@ -20,9 +20,18 @@ Until the Python version is retired, **every new feature and every behaviour cha
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
### Deliberately Go-only features
These exist only in the Go app on purpose. Do not port them to the legacy Python app, which is being phased out:
- **Scheduled bank sync** — the Go server runs sync → infer → flush-cache by itself at the times in `SYNC_SCHEDULE`. Python's multi-worker gunicorn setup would need a cross-process lock for this, which is not worth building in code that is going away. In Python the sync still runs only from `/sync-bank` or the CLI.
## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- Tests may use a real member's name as a bare matching input (diacritics, nicknames, word order).
- Never commit a real name together with payment details or raw bank messages, and never commit names of juniors or their parents. Use fictional names there.
- Parity fixtures under `go/tests/fixtures/` must be scrubbed (`Member_<hash>`); check new or regenerated fixtures before committing.
- **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
@@ -64,7 +73,7 @@ make web-debug # Same with FLASK_DEBUG=1
make test # Run all tests (unittest discover)
make test-v # Tests with verbose output
make fees # Print fee report from attendance sheet
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
make sync-year # Sync Fio transactions for the current year plus Nov-Dec of the previous one (YEAR=N to override)
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
make reconcile # Print balance report from Google Sheets data
make image # Build the legacy Python Docker image
@@ -104,10 +113,11 @@ Fio Bank API ──► bank sync ──► Google Sheets (payments
- `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/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`, `SYNC_SCHEDULE`, `SYNC_TZ`).
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference).
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference; `Runner` serialises manual and scheduled runs).
- `go/internal/scheduler` — runs a job at fixed times of day; used by `fuj server` for the scheduled bank sync.
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
@@ -126,14 +136,14 @@ Fio Bank API ──► bank sync ──► Google Sheets (payments
### Member tiers
Tiers are set in column B of the attendance sheet:
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 600 CZK for 2+ sessions; 200 CZK for exactly 1, capped at the month's rate)
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
- `X` — Excluded from junior fee calculation (coaches, etc.)
### Fee calculation
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
- Adults: 0 sessions → 0, 1 session → 200 CZK (never more than the month's rate, so a 0-rate month is free), 2+ sessions → monthly rate (default 600 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required; 0 in a 0-rate month), 2+ → monthly rate from `JuniorFeeMonthlyRate` / `JUNIOR_MONTHLY_RATE` (default 500 CZK)
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
### Merged months
@@ -144,12 +154,21 @@ Tiers are set in column B of the attendance sheet:
Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
### Bank sync
- Manual: open `/sync-bank` (both apps) or run `fuj sync` + `fuj infer` / `make sync-year` + `make infer`.
- Scheduled (Go server only): at each time in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`; `off` disables) read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup. Manual and scheduled runs share one lock and never overlap. The page footer shows the last run since start and the next one.
- Both use the same window: 1 November of the previous year to 31 December of the current year; rows are de-duplicated by Sync ID.
- The schedule runs per process: keep the deployment at one replica, and note that a local `make web-go` with real credentials also syncs at the scheduled time unless `SYNC_SCHEDULE=off`.
### Payments sheet columns
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
`Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
**Non-fee payments ("other")**: a Purpose that starts with `other:` (case-insensitive) is attached to the person's "other transactions" and is not counted against any month. Inference produces this by itself when the bank message starts with `other:`: it copies the trimmed message into Purpose and does not look for a month (neither in the text nor from the date). A message that only contains `other:` later in the text is inferred the normal way. The shared check is `matching.IsOtherPurpose` / `is_other_purpose`.
### QR codes
`/qr?account=…&amount=…&message=…` generates a Czech QR Platba PNG (SPD format).
+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 sync-year test test-v docs
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
LEGACY := legacy
@@ -39,6 +39,7 @@ help:
@echo " make sync - Sync Fio transactions to Google Sheets"
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
@echo " make sync-year [YEAR=N] - Sync Fio transactions for YEAR plus Nov-Dec of the year before (default: current year)"
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
@echo " make reconcile - Show balance report using Google Sheets data"
@@ -130,6 +131,10 @@ sync-2025: $(PYTHON)
sync-2026: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
YEAR ?= $(shell date +%Y)
sync-year: $(PYTHON)
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from $(shell expr $(YEAR) - 1)-11-01 --to $(YEAR)-12-31 --sort-by-date
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
+1 -1
View File
@@ -182,7 +182,7 @@ fuj-management/
│
├── .gitea/workflows/
│ ├── build.yaml # CI: build + push Docker image
│ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
│ └── gitops-update.yaml # CD: open image-update PR in home-kubernetes
│
├── .secret/ # (gitignored) API credentials
├── docs/ # Project documentation
+12 -18
View File
@@ -129,32 +129,27 @@ The project uses two Gitea Actions workflows:
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
#### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
#### 2. GitOps image update (`gitops-update.yaml`)
**Triggers**:
- Push to any branch
- Manual dispatch
- Completion of a successful "Build and Push" run
- Manual dispatch (with `tag`, `dry_run` and `uh_cli_version` inputs)
**Steps**:
1. Checkout code
2. Install kubectl
3. Retrieve Kanidm token from HashiCorp Vault:
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
- Fetch API token from `secret/data/gitea/gitea-ci`
4. Exchange API token for K8s OIDC token via Kanidm:
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
5. Configure kubectl with the OIDC token
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
1. Install `tea` and `uh-cli`
2. Resolve the image tag of the Go image that was just built
3. Open a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to that image
The cluster picks the change up once that PR is merged. CI does not talk to the cluster directly.
> The earlier `kubernetes-deploy.yaml` workflow (Vault → Kanidm → `kubectl`) was removed on 2026-10-08. It never deployed anything and printed credentials to the job log.
**Required secrets**:
| Secret | Purpose |
|--------|---------|
| `REGISTRY_TOKEN` | Docker registry authentication |
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
| `GITOPS_TOKEN` | Gitea token with write and PR access to `home-kubernetes` |
### Infrastructure Topology
@@ -164,8 +159,7 @@ Gitea (git push / tag)
├── build.yaml → Docker Build → Gitea Container Registry
│ (gitea.home.hrajfrisbee.cz)
│
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
(192.168.0.31:6443)
└── gitops-update.yaml → PR in home-kubernetes → K8s Cluster
```
This is a self-hosted infrastructure stack:
@@ -2,12 +2,12 @@
## Context
A bank payment from sender `JIŘÍ KUBÍK` with the message
`Jáchym Kubík: 01/2026+03/2026+04/2026` is being inferred as
`[?] Jáchym Hrušák (G)` instead of the obvious `Jáchym Kubík`, even though
A bank payment from sender `JIŘÍ DVOŘÁK` with the message
`Jáchym Dvořák: 01/2026+03/2026+04/2026` is being inferred as
`[?] Jáchym Novotný (G)` instead of the obvious `Jáchym Dvořák`, even though
the message contains his exact full name.
**Root cause** (confirmed with the user): `Jáchym Kubík` is in the **junior**
**Root cause** (confirmed with the user): `Jáchym Dvořák` is in the **junior**
attendance sheet only — he does not appear on the main/adults sheet. But
[scripts/infer_payments.py:101-102](scripts/infer_payments.py#L101-L102)
builds `member_names` by calling `get_members_with_fees()`
@@ -15,17 +15,17 @@ builds `member_names` by calling `get_members_with_fees()`
`EXPORT_URL` (the adults sheet). Junior-only members are therefore invisible
to the matcher.
With Kubík absent from `member_names`, the matcher in
With Dvořák absent from `member_names`, the matcher in
[scripts/match_payments.py:65](scripts/match_payments.py#L65) processes the
combined text `jiri kubik jachym kubik: 01/2026+03/2026+04/2026` against an
combined text `jiri dvorak jachym dvorak: 01/2026+03/2026+04/2026` against an
adults-only roster:
- The exact-full-name short-circuit (`match_payments.py:75-84`) finds nothing —
no adult's full name is in the text.
- Hrušák `(G)` is the only adult with first name `Jáchym`. He fails the
- Novotný `(G)` is the only adult with first name `Jáchym`. He fails the
auto-rules (his surname isn't in the text) but hits the partial-first-name
review rule (`match_payments.py:123-125`) → returned as `("Jáchym Hrušák (G)",
"review")`, rendered as `[?] Jáchym Hrušák (G)`.
review rule (`match_payments.py:123-125`) → returned as `("Jáchym Novotný (G)",
"review")`, rendered as `[?] Jáchym Novotný (G)`.
The user's original framing — "exact match in message should win over
everything" — is already implemented for any candidate that **is** in the
@@ -33,7 +33,7 @@ roster (the May-04 short-circuit). The bug is upstream: the right candidate
was never even considered.
**Goal:** make `infer_payments` consider junior members as candidates, so
junior-only names like `Jáchym Kubík` get matched correctly.
junior-only names like `Jáchym Dvořák` get matched correctly.
## Approach
@@ -98,23 +98,23 @@ changes are needed.
the adults version, so `m[0]` works for both.
- [scripts/match_payments.py:65-137](scripts/match_payments.py#L65-L137) —
`match_members` already handles the precedence the user wants (exact full-name
short-circuit), so once Kubík is in `member_names`, the case will be auto-matched
short-circuit), so once Dvořák is in `member_names`, the case will be auto-matched
with no `[?]`.
## Verification
1. **Manual sanity** — re-run inference on the offending row:
- Clear `Person`/`Purpose` for the Kubík row in the payments sheet.
- Clear `Person`/`Purpose` for the Dvořák row in the payments sheet.
- `make infer`.
- Expect `Person = Jáchym Kubík`, `Purpose = 2026-01, 2026-03, 2026-04`,
- Expect `Person = Jáchym Dvořák`, `Purpose = 2026-01, 2026-03, 2026-04`,
no `[?]`.
2. **Unit test** — extend
[tests/test_match_members.py](tests/test_match_members.py) (or add a small
`tests/test_infer_payments.py`) to assert that, given a roster that
includes `Jáchym Hrušák (G)` and `Jáchym Kubík`, the message
`Jáchym Kubík: 01/2026+03/2026+04/2026` resolves to
`[("Jáchym Kubík", "auto")]` only. This is really a regression test for
includes `Jáchym Novotný (G)` and `Jáchym Dvořák`, the message
`Jáchym Dvořák: 01/2026+03/2026+04/2026` resolves to
`[("Jáchym Dvořák", "auto")]` only. This is really a regression test for
the May-04 short-circuit — the new behavior under test is just that
`infer_payments` now feeds in juniors.
@@ -122,7 +122,7 @@ changes are needed.
4. **Dashboard smoke** — `make web`, open `/payments`, confirm the row now
shows the correct member; open `/juniors`, confirm the payment is
credited to Kubík for the three months listed.
credited to Dvořák for the three months listed.
5. **Changelog** — once the user confirms the fix, append an entry to
[CHANGELOG.md](CHANGELOG.md) per [CLAUDE.md](CLAUDE.md):
@@ -0,0 +1,188 @@
# Pre-feature cleanup — task list
Findings from a full repo audit on 2026-10-08, written up as tasks to clear
before new feature work starts. Companion to the Go rewrite tracker,
[2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md).
## State at audit time
- Two full implementations live side by side: Flask (`app.py`, `scripts/`,
`templates/`) and Go (`go/`). Recent changes were made in both.
- Update 2026-10-08: the Python code was moved to `legacy/` after this audit.
Python paths below are relative to `legacy/` (`build/Dockerfile` is now
`legacy/build/Dockerfile`).
- Python tests pass (32 tests). Go unit tests and the parity suite pass.
Go lint could not run (see T7).
- The gitops workflow bumps the cluster deployment to the `-go` image, so Go
appears to be what is deployed. This was not verified against the cluster.
- `CLAUDE.md` and all docs outside `docs/plans/` describe a Python-only
project. The rewrite tracker stops at M6; M7 and M8 are untouched.
## How to use
- Tick a checkbox when the task's PR or commit lands and append the SHA.
- Tasks in "Fix first" are small and independent of each other and of the
cutover decision. Do them in any order.
- T5 is a decision. Several cleanup tasks become unnecessary once it is made;
they are marked "moot if Python is retired".
---
## Fix first
- [x] **T1** Remove the secret leak in `.gitea/workflows/kubernetes-deploy.yaml`.
Workflow deleted on 2026-10-08. Still open for the owner: rotate the
credentials and remove the now-unused repo secrets (see below).
- It runs on every push to every branch and deploys nothing.
- It echoes the raw Vault login response, the Kanidm token response and the
OIDC token response body to the log, and dumps the environment twice.
- It uses `--insecure-skip-tls-verify=true`.
- Delete or disable the workflow. Rotate the Vault AppRole secret and the
Kanidm API token if anyone else can read the CI logs.
- After deletion the repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and
`K8S_CA_CERT` are unused and can be removed. Old job logs still contain
the leaked tokens until they are deleted or expire.
- [x] **T2** Confirm the fee rates for 2026-09 onward. Done 2026-10-08:
adult default 600, 2026-07/08 at 0, 2026-09 to 2026-12 at 600. Junior
rates were not changed.
- `ADULT_FEE_MONTHLY_RATE` ends at 2026-08 in `scripts/attendance.py` and
`go/internal/domain/fees/fees.go`.
- September and October 2026 are billed at the defaults: 700 CZK adults,
500 CZK juniors.
- If those are not the intended rates, add the months in both files.
- [x] **T3** Done 2026-10-08, extended: both dashboards now sync the
current year plus November–December of the previous one. Original task:
remove the hardcoded 2026 window from Python `/sync-bank`
(`app.py`, the `sync_to_sheets` call with `2026-01-01` / `2026-12-31`).
- It stops picking up transactions on 2027-01-01. Go already uses the
current year.
- Same applies to the `sync-2026` Makefile target.
- Moot if Python is retired before then.
- [x] **T4** Remove member names from tracked files (PII rule in `CLAUDE.md`).
Done 2026-10-09 in a narrower form, see Notes & decisions. Original task:
- `CHANGELOG.md` line 29 names a member in a fix description.
- `tests/test_match_payments.py`, `tests/test_reconcile_exceptions.py` and
`go/internal/domain/reconcile/reconcile_test.go` use names that may be
real. Check them and replace with obviously fictional ones.
- Three files in `docs/plans/` contain the same names:
`2026-05-05-1717-payment-person-name-canonicalization.md`,
`2026-05-06-1626-infer-payments-junior-roster.md`,
`2026-05-11-2353-fill-first-multi-month-allocation.md`.
- The parity fixtures under `go/tests/fixtures/` are scrubbed and need no
change.
- Decide whether the names also need removing from git history.
## Decide before new features
- [ ] **T5** Decide how to finish the Python → Go cutover (M7 and M8 in the
rewrite tracker).
- Recommended: declare Go primary now, freeze Python, run one month-end
comparison of the balance report on both sides, then delete Python.
- Until this is done, every feature has to be written twice.
- Confirm first that the cluster really runs the `-go` image.
- [ ] **T6** Add a test job to CI.
- `.gitea/workflows/build.yaml` only builds images on tag push. Nothing
runs tests.
- Run `go test -race ./...`, the parity suite (`-tags=parity`) and
`golangci-lint` on pull requests.
- Stop building the Python image on every tag once Python is retired.
- [ ] **T7** Fix `make go-lint`.
- `go/.golangci.yml` is in the v1 config format. golangci-lint 2.12 refuses
to load it ("unsupported version of the configuration").
- Migrate the config to v2 and pin the linter version used in CI.
- [ ] **T8** Protect the side-effectful routes.
- Neither app has any authentication.
- `GET /sync-bank` writes to the payments sheet, so a link prefetcher or
crawler can trigger a sync. Change it to POST with a confirmation page,
as `/flush-cache` already does.
- Confirm that the ingress enforces access control, or add it in the app.
- The Go server uses `http.ListenAndServe` with no read, write or idle
timeouts (`go/internal/web/server.go`). Use an `http.Server` with
timeouts.
- [ ] **T9** Move fee rates and merged months out of code.
- Today a rate change needs a code change in two languages and a release.
- Candidate: a tab in the payments sheet, read the same way as
`exceptions`.
- This also removes the cause of T2.
## Cleanup
- [x] **T10** Rewrite `CLAUDE.md` for the current state: Go commands,
architecture and key modules, and which backend is primary. Done with the
move of Python to `legacy/`.
- [ ] **T11** Collapse the documentation into one set.
- `docs/by-claude-opus/`, `docs/by-gemini/` and the top-level docs overlap,
date from February–March 2026 and predate the Go rewrite.
- Update `docs/_sidebar.md` and `docs/README.md` to match.
- [ ] **T12** Update the rewrite tracker.
- M6.7 is unchecked although the changelog records it as done.
- M7.1 and M8.4 refer to a `docker-compose.yml` that does not exist.
- [ ] **T13** Remove leftovers: `.agent/rules.md` (Antigravity config) and
`prompts/`.
- [ ] **T14** Prune merged branches: 42 local and 44 remote branches are
fully merged into `main`.
- [ ] **T15** Clean up `.claude/settings.json`.
- It is tracked and contains absolute paths under one user's home
directory and one-off commands.
- Keep only shared, generic permissions there; move the rest to
`.claude/settings.local.json`, which is already ignored.
- [ ] **T16** Align versions.
- `pyproject.toml` says `0.10`; git tags are at `0.39`.
- `build/Dockerfile` pip-installs unpinned packages and ignores `uv.lock`.
- `go.mod` and `go/build/Dockerfile` are on Go 1.26; the local toolchain is
1.27.
- Dependencies on both sides are a few months behind.
- The Python items are moot if Python is retired.
- [ ] **T17** Stop the Python tests from calling live Google APIs.
- `warmup_cache()` runs at import of `app.py`, so `make test` makes real
Drive and Sheets read calls and needs the credentials file.
- Moot if Python is retired.
- [ ] **T18** Set the time zone in the Go container. Partly addressed
2026-10-08: the binary now embeds the zone database and the scheduled sync
uses `SYNC_TZ`; the "current month" logic below is still open.
- The image has no time zone configured, so `time.Now()` is UTC and the
"current month" flips one to two hours late at month boundaries.
- The Python image sets `Europe/Prague`.
- [ ] **T19** Decide where `fujarna/` lives.
- `fujarna/project.md` is untracked. It is a brief for a separate
tournament website and shares almost nothing with this codebase.
- Options: its own repository, or a clearly separate Go module here.
- [ ] **T20** Add tests for the untested Go packages: `cmd/fuj`,
`internal/io/sheets`, `internal/io/drive`, `internal/config` (about 590
lines in total).
## Found during validation of the `legacy/` move
Plan: [2026-10-08-1103-api-parity-vs-months-to-show.md](2026-10-08-1103-api-parity-vs-months-to-show.md).
- [x] **T21** Go rendered numeric VS cells in exponent form
(`2.609767878e+09`). Sheet text cells now go through `sheets.CellString`.
- [x] **T22** Python `/api/adults` and `/api/juniors` did not return
`months_to_show`. Backported; `make parity` reports 3/3 routes matching.
- [x] **T23** Write VS to the payments sheet as text. Both sync
implementations use `USER_ENTERED`, so Sheets stored VS as a number and
dropped leading zeros. New rows now carry a text marker; existing rows are
left as they are and are read correctly either way.
## Features added since the audit
- 2026-10-08 — Scheduled bank sync in the Go server, deliberately Go-only.
Plan: [2026-10-08-1216-scheduled-bank-sync.md](2026-10-08-1216-scheduled-bank-sync.md).
It also added a lock so two syncs cannot overlap (relevant to T8).
## Notes & decisions
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
- 2026-10-09 — T4 scope decided by the owner: a real name as a bare test
input is acceptable; a real name next to payment details, raw bank
messages, or any junior or parent name is not. Replaced the junior
regression-test names (test, changelog, plan) with fictional ones and
scrubbed three parity fixtures that still held raw bank messages. The
other test names stay. Git history was not rewritten. Rule recorded in
`CLAUDE.md`.
- 2026-10-09 — The `real_*` parse_month_references seeds pick rows from the
live payments cache by index (`_REAL_PMR_INDICES`). The sheet has changed
since capture, so `make capture-fixtures` now rewrites those fixtures with
different messages. Commit only the fixtures a change needs, or replace
the index-based seeds with fixed synthetic ones.
@@ -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.
@@ -0,0 +1,134 @@
# Scheduled bank sync in the Go server (Go only)
## Context
Bank synchronization only runs when someone opens `/sync-bank`; the payments
sheet is as fresh as the last click. The Go server should run the same
sync → infer → flush-cache sequence by itself on a configured schedule,
defaulting to once a day.
Decisions already made:
- **Go only.** Deliberately not implemented in the legacy Python app, which is
being phased out (gunicorn's multi-worker model would also need a
cross-process lock). This is recorded as an explicit exception to the dual
implementation rule.
- **Schedule format:** one or more times of day. On by default, once a day.
## Behaviour
- `SYNC_SCHEDULE` — comma-separated `HH:MM` times, default `04:00`.
`off` disables the scheduler. An invalid value makes `fuj server` exit at
startup with a clear error.
- `SYNC_TZ` — IANA zone the times are read in, default `Europe/Prague`.
The binary embeds the zone database (`import _ "time/tzdata"`), because the
Alpine image has none.
- At each scheduled time the server runs exactly what `/sync-bank` runs
(`banksync.DefaultWindow`, `SyncToSheets`, `InferPayments`, cache flush),
with a 10-minute timeout, and logs start, result and row counts via `slog`.
- Nothing runs at startup; a restart only recomputes the next run time.
- A scheduled run and a manual `/sync-bank` can no longer overlap: both go
through one mutex. If a sync is already running, the manual page shows
"a sync is already running" and a scheduled run is skipped with a log line.
(This also closes today's gap where two overlapping manual runs could append
the same rows.)
- The footer of every page shows the last sync since the server started:
time, trigger (scheduled / manual), ok or failed, and the next scheduled
run. Status is in memory only and resets on restart.
## Implementation
Branch `feat/scheduled-bank-sync` off `main`; MR via `tea`.
1. **Config** — `go/internal/config/config.go`
- Add `SyncSchedule []TimeOfDay` and `SyncLocation *time.Location` to
`Config`.
- Add `ParseSyncSchedule(s string) ([]TimeOfDay, error)` (`"off"` → empty,
sorted, de-duplicated, rejects anything not `HH:MM`) and a
`LoadSyncSchedule() (…, error)` reading `SYNC_SCHEDULE` / `SYNC_TZ`.
`Load()` keeps its signature; `serverCmd` calls the new loader and exits
on error.
- New `config_test.go` for the parser (first tests in this package).
2. **Scheduler** — new package `go/internal/scheduler`
- `Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time` —
pure; builds candidates with `time.Date` in `loc` for today and tomorrow
and returns the earliest one strictly after `now` (handles DST days).
- `Run(ctx, times, loc, logger, job func(context.Context))` — loop:
compute `Next`, wait on a timer or `ctx.Done()`, call `job`.
- Tests: `Next` table test (before/after today's time, multiple times,
midnight rollover, the two DST switch days in Europe/Prague); `Run` with
a cancelled context returns.
3. **Sync runner with lock and status** — new
`go/internal/services/banksync/runner.go`
- `Runner` wraps the existing sync+infer function (moved out of the
closure in `go/cmd/fuj/main.go`) with a `sync.Mutex` (`TryLock`) and
records `Status{Started, Finished, Trigger, Err}`.
- `Run(ctx, trigger string, out io.Writer) error` returns `ErrBusy` when a
run is in progress. `Last() (Status, bool)` for display.
- Tests with a fake job: status recorded, error recorded, second
concurrent call gets `ErrBusy`.
4. **Wiring** — `go/cmd/fuj/main.go`
- Add `import _ "time/tzdata"`.
- Build one `Runner`; `ActionHandlers.BankSync` calls
`runner.Run(ctx, "manual", out)` (signature unchanged, so
`TestServeSync` keeps working).
- If the schedule is non-empty, start `go scheduler.Run(...)` whose job
calls `runner.Run(ctx, "scheduled", &buf)` with a 10-minute timeout, then
`sources.FlushCache()`, and logs the outcome. Log the schedule and next
run at startup, or "scheduled sync disabled".
5. **Display** — `go/internal/web`
- `ActionHandlers` gains optional `SyncInfo func() string` (last + next run
as one line). `PageData` gains `SyncInfo string`; add a small
`h.page(active)` helper in `html_handler.go` and use it where
`PageData{Active: …, Build: h.build}` is built today.
- `templates/partials/footer.tmpl`: append the line when non-empty.
- `templates/sync.tmpl`: fix the description, which still says "current
year" (stale since the window change).
- Extend `html_handler_test.go`: footer shows the info when provided,
nothing extra when not.
6. **Docs and notes**
- `CLAUDE.md`: document `SYNC_SCHEDULE` / `SYNC_TZ` under the Go config
list and the commands/architecture text; under "Dual implementation
rule" add a "Deliberately Go-only" list with this feature and the reason.
- `legacy/app.py`: one comment above `sync_bank()` stating that scheduled
sync exists only in Go, on purpose.
- `CHANGELOG.md` entry; copy this plan to
`docs/plans/2026-10-08-HHMM-scheduled-bank-sync.md`.
- Add a note to the cleanup task list: T18 (time zone) is addressed for
the scheduler only; the "current month" logic still uses the process
clock.
No new dependencies. No Dockerfile change. No change to parity fixtures or
the JSON API.
## Deployment notes (for the MR description)
- On by default at 04:00 Europe/Prague. Set `SYNC_SCHEDULE=off` to disable.
- A locally running `make web-go` with real credentials will also sync at
that time.
- With more than one replica each pod would run its own schedule; the
deployment must stay at one replica (or disable the schedule on extras).
- Without `FIO_API_TOKEN_*` the sync scrapes the transparent-account pages,
as today.
## Verification
1. `make go-test-all` and `make go-lint` equivalent (`go vet`, `gofmt -l`);
`make test` to confirm Python is untouched.
2. Start `fuj server` with `SYNC_SCHEDULE=off` → log says disabled, footer
shows no schedule.
3. Start with an invalid value (`SYNC_SCHEDULE=25:00`) → exits with an error.
4. Live check: start with `SYNC_SCHEDULE` set two minutes ahead and watch the
log for the scheduled run and the footer for the status line. **This
performs one real sync** against the payments sheet (currently one pending
transaction would be appended) — the same thing a click on `/sync-bank`
does.
5. While that run is in progress, or with a test job, confirm a second
trigger reports "already running".
6. Build the Go image and confirm the schedule log line shows the Prague
zone inside the container (proves the embedded zone data works).
@@ -0,0 +1,109 @@
# Inference: messages starting with "other:" are not monthly fees
## Context
Some payments are not monthly fees (tournament fees, jerseys, …). Reconcile
already supports them: a row whose **Purpose** starts with `other:` is attached
to the person's "other transactions" and is not counted against any month.
Today that only happens when someone types the purpose by hand, because
inference always tries to find a month — from the message, or failing that
from the payment date.
New rule: when the bank message for the recipient **starts with `other:`**,
inference must not look for a month. It copies the message into Purpose, so
the payment is linked to the person but not counted as a monthly fee. Messages
that merely contain `other:` somewhere else are handled the old way.
Implemented in both Go and legacy Python (this is inference behaviour, so the
dual implementation rule applies).
## Behaviour
- Trigger: message, after trimming whitespace, starts with `other:`
(case-insensitive, same test reconcile already uses for Purpose).
- Purpose written to the sheet: the trimmed message, unchanged
(e.g. `other: turnaj Praha`). No month parsing, no date fallback.
- Person: inferred exactly as today (sender + message + VS, then sender
alone), including the `[?]` review marker.
- Inferred Amount: the full amount, as today.
- No person found: Purpose is still written, Person stays empty. The row then
shows up under "unmatched" for a manual fix instead of being guessed again
on every run.
- Rows not yet processed by inference (Person and Purpose both empty) are
inferred on the fly by reconcile; that path follows the same rule, so such a
payment is never briefly counted as a monthly fee.
- Existing rows are untouched: inference only fills rows where manual fix,
Person and Purpose are all empty. To reprocess an old row, clear its Person
and Purpose cells.
## Implementation
Branch `feat/infer-other-purpose` off `main`; MR via `tea`.
The output shape of the inference function does not change: for an "other"
message the months list holds the single purpose string. That is the same
convention reconcile already uses internally (`matchedMonths = [purpose]`),
and it means the sheet-writing code needs no change in either language.
1. **Shared predicate**
- Go: `matching.IsOtherPurpose(s string) bool` in
`go/internal/domain/matching/infer.go`
(`strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")`).
- Python: `is_other_purpose(s)` in `legacy/scripts/match_payments.py`.
- Replace the two existing inline checks in reconcile with it
(`go/internal/domain/reconcile/reconcile.go`, `reconcile()` in
`match_payments.py`).
2. **Inference**
- Go `matching.InferTransactionDetails` and Python
`infer_transaction_details`: after member matching, if the message is an
other-purpose, return `months = [trimmed message]` and skip
`ParseMonthReferences` and the date fallback. Update the doc comments
(months may hold one `other:…` purpose).
- `banksync.InferPayments` (`go/internal/services/banksync/infer.go`) and
`infer_payments.py`: no logic change — they already join months into
Purpose and write the row when either members or months are non-empty.
3. **Reconcile fallback** (rows with empty Person/Purpose)
- In the inference-fallback branch of both reconcile implementations: if
the inferred months are a single other-purpose, set the row's purpose to
it and mark it as other, so it goes to the member's other-transactions
ledger instead of the month allocation.
4. **Tests and fixtures**
- Seeds in `legacy/scripts/_fixture_seeds.py`, captured with the existing
capture/scrub scripts for these two functions only:
- `infer_transaction_details`: `other_prefix` (message
`other: tournament`, member in sender → that purpose, no month),
`other_prefix_uppercase` (`Other: …`), `other_not_at_start`
(`leden other: x` → old behaviour, month parsed).
- `reconcile`: `11_other_message_fallback` (empty Person/Purpose, message
starts with `other:` → lands in `other_transactions`, month stays
unpaid).
- Go unit tests: `IsOtherPurpose` table; cases in
`matching/infer_test.go`; a case in `banksync/infer_test.go` asserting
the written row (`Person`, `Purpose = "other: …"`, amount) and one with
no matching member; a fallback case in `reconcile/reconcile_test.go`.
- Python: cases in `legacy/tests/test_match_payments.py` for
`infer_transaction_details` and the reconcile fallback.
- Existing fixtures must stay byte-identical apart from the new files.
5. **Docs**
- `CLAUDE.md`: add the rule to the payments-sheet / inference description.
- `CHANGELOG.md` entry; copy this plan to
`docs/plans/2026-10-08-HHMM-infer-other-purpose.md`.
No change to the JSON API, schemas, templates or the sheet layout.
## Verification
1. `make go-test-all` (unit + parity, including the new fixtures) and
`make test`.
2. `git status go/tests/fixtures` shows only the four new fixture files.
3. Dry run against the live sheet in both implementations
(`fuj infer --dry-run`, Python `infer_payments.py --dry-run`): confirm it
runs cleanly and proposes nothing unexpected. The live sheet probably has
no un-inferred row starting with `other:`, so the new rule itself is
proven by the tests rather than by live data.
4. `make parity` between the two servers (or the two-container run used
earlier) still reports 3/3 routes matching.
+81 -5
View File
@@ -1,20 +1,25 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/io/fio"
"fuj-management/go/internal/io/sheets"
"fuj-management/go/internal/logging"
"fuj-management/go/internal/scheduler"
"fuj-management/go/internal/services/banksync"
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web"
"io"
"log/slog"
"os"
"strings"
"time"
_ "time/tzdata" // the container image has no zone database; SYNC_TZ needs one
)
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
@@ -90,11 +95,17 @@ func serverCmd(args []string) {
}
fioClients := buildFioClients(cfg)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
yr := time.Now().Year()
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
syncTimes, syncLoc, err := config.LoadSyncSchedule()
if err != nil {
fmt.Fprintf(os.Stderr, "fuj server: %v\n", err)
os.Exit(2)
}
// One runner serves both the /sync-bank page and the scheduler, so the two
// can never run at the same time.
runner := banksync.NewRunner(
func(ctx context.Context, out io.Writer) error {
from, to := banksync.DefaultWindow(time.Now())
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
@@ -112,6 +123,22 @@ func serverCmd(args []string) {
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
return nil
},
)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
return runner.Run(ctx, "manual", out)
},
SyncInfo: func() string { return syncInfo(runner, syncTimes, syncLoc, time.Now()) },
}
if len(syncTimes) == 0 {
logger.Info("scheduled bank sync disabled (SYNC_SCHEDULE=off)")
} else {
logger.Info("scheduled bank sync enabled", "times", fmt.Sprint(syncTimes), "tz", syncLoc.String())
go scheduler.Run(ctx, syncTimes, syncLoc, logger, func(ctx context.Context) {
scheduledSync(ctx, logger, runner, sources)
})
}
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
@@ -122,6 +149,55 @@ func serverCmd(args []string) {
}
}
// scheduledSyncTimeout bounds one scheduled run (two bank fetches plus sheet writes).
const scheduledSyncTimeout = 10 * time.Minute
// scheduledSync is the scheduler's job: the same sync → infer → flush-cache
// sequence as the /sync-bank page, with the outcome written to the log.
func scheduledSync(ctx context.Context, logger *slog.Logger, runner *banksync.Runner, sources membership.Sources) {
ctx, cancel := context.WithTimeout(ctx, scheduledSyncTimeout)
defer cancel()
logger.Info("scheduled bank sync starting")
var buf bytes.Buffer
err := runner.Run(ctx, "scheduled", &buf)
if errors.Is(err, banksync.ErrBusy) {
logger.Warn("scheduled bank sync skipped: a sync is already running")
return
}
output := strings.Join(strings.Fields(buf.String()), " ")
if _, ferr := sources.FlushCache(); ferr != nil {
logger.Error("scheduled bank sync: cache flush failed", "err", ferr)
}
if err != nil {
logger.Error("scheduled bank sync failed", "err", err, "output", output)
return
}
logger.Info("scheduled bank sync finished", "output", output)
}
// syncInfo renders the footer line: the last run since the server started
// and the next scheduled one.
func syncInfo(runner *banksync.Runner, times []scheduler.TimeOfDay, loc *time.Location, now time.Time) string {
const layout = "2006-01-02 15:04"
var parts []string
if st, ok := runner.Last(); ok {
result := "ok"
if st.Err != nil {
result = "FAILED"
}
parts = append(parts, fmt.Sprintf("last sync %s (%s) %s", st.Finished.In(loc).Format(layout), st.Trigger, result))
} else {
parts = append(parts, "no sync since start")
}
if next := scheduler.Next(now, times, loc); !next.IsZero() {
parts = append(parts, "next "+next.Format(layout))
} else {
parts = append(parts, "scheduled sync off")
}
return strings.Join(parts, " | ")
}
func feesCmd(args []string) {
fs := flag.NewFlagSet("fees", flag.ExitOnError)
fs.Usage = func() {
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"context"
"errors"
"fuj-management/go/internal/scheduler"
"fuj-management/go/internal/services/banksync"
"io"
"strings"
"testing"
"time"
)
func TestSyncInfo(t *testing.T) {
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
four := []scheduler.TimeOfDay{{Hour: 4}}
ok := banksync.NewRunner(func(context.Context, io.Writer) error { return nil })
if got := syncInfo(ok, four, time.UTC, now); got != "no sync since start | next 2026-10-09 04:00" {
t.Errorf("before any run: %q", got)
}
if err := ok.Run(context.Background(), "scheduled", io.Discard); err != nil {
t.Fatal(err)
}
got := syncInfo(ok, nil, time.UTC, now)
if !strings.HasPrefix(got, "last sync ") || !strings.HasSuffix(got, "(scheduled) ok | scheduled sync off") {
t.Errorf("after ok run, schedule off: %q", got)
}
bad := banksync.NewRunner(func(context.Context, io.Writer) error { return errors.New("boom") })
_ = bad.Run(context.Background(), "manual", io.Discard)
if got := syncInfo(bad, four, time.UTC, now); !strings.Contains(got, "(manual) FAILED | next 2026-10-09 04:00") {
t.Errorf("after failed run: %q", got)
}
}
+24
View File
@@ -1,6 +1,8 @@
package config
import (
"fmt"
"fuj-management/go/internal/scheduler"
"os"
"strconv"
"strings"
@@ -92,6 +94,28 @@ func Load() Config {
}
}
// Scheduled bank sync defaults (Go server only; the legacy Python app has no scheduler).
const (
DefaultSyncSchedule = "04:00" // SYNC_SCHEDULE: comma-separated HH:MM times, or "off"
DefaultSyncTimezone = "Europe/Prague" // SYNC_TZ: IANA zone the schedule is read in
)
// LoadSyncSchedule reads SYNC_SCHEDULE and SYNC_TZ. An empty schedule means
// the scheduled sync is disabled. Resolving the zone needs a zone database:
// the server binary embeds one via time/tzdata.
func LoadSyncSchedule() ([]scheduler.TimeOfDay, *time.Location, error) {
times, err := scheduler.ParseTimes(env("SYNC_SCHEDULE", DefaultSyncSchedule))
if err != nil {
return nil, nil, fmt.Errorf("SYNC_SCHEDULE: %w", err)
}
tz := env("SYNC_TZ", DefaultSyncTimezone)
loc, err := time.LoadLocation(tz)
if err != nil {
return nil, nil, fmt.Errorf("SYNC_TZ: unknown time zone %q", tz)
}
return times, loc, nil
}
// IBANAccountNum extracts the bare account number from a Czech IBAN.
// "CZ8520100000002800359168" → "2800359168"
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"testing"
)
func TestLoadSyncSchedule(t *testing.T) {
cases := []struct {
name, schedule, tz string
wantTimes int
wantZone string
wantErr bool
}{
{"defaults", "", "", 1, "Europe/Prague", false},
{"two times", "06:00,18:00", "", 2, "Europe/Prague", false},
{"off", "off", "", 0, "Europe/Prague", false},
{"other zone", "04:00", "UTC", 1, "UTC", false},
{"bad time", "25:00", "", 0, "", true},
{"bad zone", "04:00", "Mars/Olympus", 0, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SYNC_SCHEDULE", tc.schedule)
t.Setenv("SYNC_TZ", tc.tz)
times, loc, err := LoadSyncSchedule()
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if len(times) != tc.wantTimes {
t.Errorf("times = %v, want %d entries", times, tc.wantTimes)
}
if loc.String() != tc.wantZone {
t.Errorf("zone = %s, want %s", loc, tc.wantZone)
}
})
}
t.Setenv("SYNC_SCHEDULE", "")
t.Setenv("SYNC_TZ", "")
if times, _, _ := LoadSyncSchedule(); len(times) != 1 || times[0].String() != DefaultSyncSchedule {
t.Errorf("default schedule = %v, want [%s]", times, DefaultSyncSchedule)
}
}
+10 -7
View File
@@ -2,7 +2,7 @@
package fees
const (
AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
AdultFeeDefault = 600 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
AdultFeeSingle = 200 // CZK for exactly 1 practice
)
@@ -11,24 +11,27 @@ const (
var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
"2026-01": 750, "2026-02": 750, "2026-03": 350,
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 600, "2026-08": 600,
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 0, "2026-08": 0,
"2026-09": 600, "2026-10": 600, "2026-11": 600, "2026-12": 600,
}
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
// the given monthKey (format "YYYY-MM").
//
// 0 practices → 0
// 1 practice → AdultFeeSingle (200)
// 1 practice → AdultFeeSingle (200), capped at the month's rate so a
// 0-rate month is free for everyone
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
func CalculateFee(attendanceCount int, monthKey string) int {
if attendanceCount == 0 {
return 0
}
rate, ok := AdultFeeMonthlyRate[monthKey]
if !ok {
rate = AdultFeeDefault
}
if attendanceCount == 1 {
return AdultFeeSingle
return min(AdultFeeSingle, rate)
}
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
return rate
}
return AdultFeeDefault
}
+4 -2
View File
@@ -26,9 +26,11 @@ func TestCalculateFee(t *testing.T) {
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", 0},
{"zero empty month", 0, "", 0},
// Single practice returns AdultFeeSingle regardless of month.
// Single practice returns AdultFeeSingle, capped at the month's rate.
{"single practice", 1, "2026-05", AdultFeeSingle},
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
{"single unknown month", 1, "unknown", AdultFeeSingle},
{"single in free month is free", 1, "2026-07", 0},
{"two in free month is free", 2, "2026-08", 0},
// Two+ practices for a configured month: must use the map value, not the default.
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
// when rates are updated — the assertion verifies dispatch logic, not rate values.
+8 -5
View File
@@ -7,6 +7,7 @@ const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in
var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250,
"2026-03": 250,
"2026-08": 0,
}
// Expected is the result of a junior fee calculation.
@@ -21,17 +22,19 @@ type Expected struct {
// the given monthKey (format "YYYY-MM").
//
// 0 practices → Expected{Value: 0}
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?")
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?"),
// or Expected{Value: 0} when the month's rate is 0
// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault}
func CalculateJuniorFee(attendanceCount int, monthKey string) Expected {
if attendanceCount == 0 {
return Expected{Value: 0}
}
if attendanceCount == 1 {
rate, ok := JuniorFeeMonthlyRate[monthKey]
if !ok {
rate = JuniorFeeDefault
}
if attendanceCount == 1 && rate > 0 {
return Expected{Unknown: true}
}
if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok {
return Expected{Value: rate}
}
return Expected{Value: JuniorFeeDefault}
}
+3
View File
@@ -33,6 +33,9 @@ func TestCalculateJuniorFee(t *testing.T) {
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
// A 0-rate month is free, and a single practice needs no manual review.
{"two in free month is free", 2, "2026-08", Expected{Value: 0}},
{"single in free month is free", 1, "2026-08", Expected{Value: 0}},
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
+25 -2
View File
@@ -3,6 +3,7 @@ package matching
import (
"fmt"
"fuj-management/go/internal/domain/czech"
"strings"
"time"
)
@@ -17,12 +18,22 @@ type Transaction struct {
}
// InferredDetails is the result of InferTransactionDetails.
// Months holds "YYYY-MM" keys, or exactly one "other:…" purpose when the
// payment is not a monthly fee (see IsOtherPurpose).
type InferredDetails struct {
Members []Match
Months []string
SearchText string
}
// IsOtherPurpose reports whether s marks a payment that is not a monthly fee:
// it starts with "other:" (case-insensitive, leading whitespace ignored).
// Used for both the Purpose column and the bank message.
// Ports legacy/scripts/match_payments.py is_other_purpose.
func IsOtherPurpose(s string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")
}
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
//
// Search text for member matching: sender + message + user_id.
@@ -30,6 +41,10 @@ type InferredDetails struct {
// Fallback 1: if no members found, retry match on sender alone.
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
//
// A message that starts with "other:" is not a monthly fee: no month is
// inferred (neither from the text nor from the date) and Months holds the
// trimmed message as the single purpose.
//
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
//
@@ -38,12 +53,20 @@ func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear i
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
members := MatchMembers(searchText, memberNames)
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(members) == 0 {
members = MatchMembers(tx.Sender, memberNames)
}
if IsOtherPurpose(tx.Message) {
return InferredDetails{
Members: members,
Months: []string{strings.TrimSpace(tx.Message)},
SearchText: searchText,
}
}
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
if ym := inferMonthFromDate(tx.Date); ym != "" {
months = []string{ym}
+68
View File
@@ -32,6 +32,74 @@ import (
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
func TestIsOtherPurpose(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"other: tournament": true,
"other:shirt": true,
" Other: Tournament": true,
"OTHER:x": true,
"leden other: x": false,
"other tournament": false,
"another: x": false,
"2026-01": false,
"": false,
}
for in, want := range cases {
if got := IsOtherPurpose(in); got != want {
t.Errorf("IsOtherPurpose(%q) = %v, want %v", in, got, want)
}
}
}
func TestInferTransactionDetails_OtherMessage(t *testing.T) {
t.Parallel()
jana := []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}}
cases := []struct {
name string
tx Transaction
wantMembers []Match
wantMonths []string
}{
{
// Month words and the date must both be ignored.
name: "other prefix: message becomes the purpose, no month",
tx: Transaction{Sender: "Jana Novakova", Message: " other: turnaj leden 2026 ", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"other: turnaj leden 2026"},
},
{
name: "other prefix is case-insensitive and keeps the original text",
tx: Transaction{Sender: "Jana Novakova", Message: "Other: Dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"Other: Dres"},
},
{
name: "other prefix with no known member still yields the purpose",
tx: Transaction{Sender: "Unknown Payer", Message: "other: dres", Date: "2026-04-15"},
wantMembers: nil,
wantMonths: []string{"other: dres"},
},
{
name: "other: not at the start is handled the old way",
tx: Transaction{Sender: "Jana Novakova", Message: "leden other: dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"2026-01"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := InferTransactionDetails(tc.tx, inferMembers, 2026)
if len(got.Members) != len(tc.wantMembers) || (len(got.Members) > 0 && !reflect.DeepEqual(got.Members, tc.wantMembers)) {
t.Errorf("Members = %v, want %v", got.Members, tc.wantMembers)
}
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
t.Errorf("Months = %v, want %v", got.Months, tc.wantMonths)
}
})
}
}
func TestInferTransactionDetails(t *testing.T) {
t.Parallel()
+6 -1
View File
@@ -203,7 +203,7 @@ func Reconcile(
personStr := strings.TrimSpace(tx.Person)
purposeStr := strings.TrimSpace(tx.Purpose)
personStr = questionMarkRe.ReplaceAllString(personStr, "")
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
isOther := matching.IsOtherPurpose(purposeStr)
var matchedMembers []matching.Match
var matchedMonths []string
@@ -249,6 +249,11 @@ func Reconcile(
matchedMembers = inferred.Members
matchedMonths = inferred.Months
amount = tx.Amount
// A message starting with "other:" is inferred as a non-fee payment.
if len(matchedMonths) == 1 && matching.IsOtherPurpose(matchedMonths[0]) {
isOther = true
purposeStr = matchedMonths[0]
}
}
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
@@ -257,6 +257,33 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
}
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
// A row not yet processed by inference (empty Person/Purpose) whose message
// starts with "other:" must land in the other ledger, not in a month.
func TestReconcileOtherMessageFallback(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-01-10", Amount: 300,
Sender: "Alice", Message: "other: shirt",
}}
result := Reconcile(members, []string{"2026-01"}, txs, nil, 2026)
alice := result.Members["Alice"]
if paid := alice.Months["2026-01"].Paid; paid != 0 {
t.Errorf("2026-01 paid = %v, want 0", paid)
}
if len(alice.OtherTransactions) != 1 {
t.Fatalf("other transactions = %d, want 1", len(alice.OtherTransactions))
}
if got := alice.OtherTransactions[0]; got.Amount != 300 || got.Purpose != "other: shirt" {
t.Errorf("other entry = %+v", got)
}
if len(result.Unmatched) != 0 {
t.Errorf("unmatched = %d, want 0", len(result.Unmatched))
}
}
func TestReconcileOtherPurpose(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
+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)
}
})
}
}
+95
View File
@@ -0,0 +1,95 @@
// Package scheduler runs a job at fixed times of day.
package scheduler
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
)
// TimeOfDay is a wall-clock time without a date.
type TimeOfDay struct {
Hour, Minute int
}
func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) }
// ParseTimes parses a comma-separated list of HH:MM times, e.g. "04:00" or
// "06:00,18:00". "off" yields an empty schedule. The result is sorted and
// de-duplicated.
func ParseTimes(s string) ([]TimeOfDay, error) {
s = strings.TrimSpace(s)
if strings.EqualFold(s, "off") {
return nil, nil
}
seen := map[TimeOfDay]bool{}
var times []TimeOfDay
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
parsed, err := time.Parse("15:04", part)
if err != nil {
return nil, fmt.Errorf("invalid time %q: want HH:MM, a comma-separated list of them, or \"off\"", part)
}
t := TimeOfDay{Hour: parsed.Hour(), Minute: parsed.Minute()}
if !seen[t] {
seen[t] = true
times = append(times, t)
}
}
sort.Slice(times, func(i, j int) bool {
if times[i].Hour != times[j].Hour {
return times[i].Hour < times[j].Hour
}
return times[i].Minute < times[j].Minute
})
return times, nil
}
// Next returns the earliest scheduled instant strictly after now, with the
// times read in loc. It returns the zero time when times is empty.
func Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time {
local := now.In(loc)
var best time.Time
for dayOffset := 0; dayOffset <= 1; dayOffset++ {
for _, t := range times {
c := time.Date(local.Year(), local.Month(), local.Day()+dayOffset, t.Hour, t.Minute, 0, 0, loc)
if c.After(now) && (best.IsZero() || c.Before(best)) {
best = c
}
}
}
return best
}
// pollInterval bounds each wait so the loop follows the wall clock across
// clock adjustments and host suspend instead of relying on one long timer.
const pollInterval = time.Minute
// Run calls job at every scheduled time until ctx is cancelled. Nothing runs
// at startup. Runs do not overlap: the next time is computed after job returns.
func Run(ctx context.Context, times []TimeOfDay, loc *time.Location, logger *slog.Logger, job func(context.Context)) {
for {
next := Next(time.Now(), times, loc)
if next.IsZero() {
return
}
logger.Info("next scheduled run", "at", next.Format(time.RFC3339))
for {
wait := time.Until(next)
if wait <= 0 {
break
}
timer := time.NewTimer(min(wait, pollInterval))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
job(ctx)
}
}
+115
View File
@@ -0,0 +1,115 @@
package scheduler
import (
"context"
"io"
"log/slog"
"reflect"
"testing"
"time"
)
func TestParseTimes(t *testing.T) {
cases := []struct {
in string
want []TimeOfDay
wantErr bool
}{
{"04:00", []TimeOfDay{{4, 0}}, false},
{" 18:30 , 06:00,06:00 ", []TimeOfDay{{6, 0}, {18, 30}}, false},
{"off", nil, false},
{"OFF", nil, false},
{"", nil, true},
{"25:00", nil, true},
{"04:60", nil, true},
{"daily", nil, true},
{"04:00,", nil, true},
}
for _, tc := range cases {
got, err := ParseTimes(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("ParseTimes(%q): err = %v, wantErr %v", tc.in, err, tc.wantErr)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ParseTimes(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestNext(t *testing.T) {
prague, err := time.LoadLocation("Europe/Prague")
if err != nil {
t.Fatal(err)
}
at := func(s string) time.Time {
v, err := time.ParseInLocation("2006-01-02 15:04:05", s, prague)
if err != nil {
t.Fatal(err)
}
return v
}
four := []TimeOfDay{{4, 0}}
cases := []struct {
name string
now time.Time
times []TimeOfDay
want time.Time
}{
{"before today's time", at("2026-10-08 03:59:59"), four, at("2026-10-08 04:00:00")},
{"exactly at the time moves to tomorrow", at("2026-10-08 04:00:00"), four, at("2026-10-09 04:00:00")},
{"after today's time", at("2026-10-08 12:00:00"), four, at("2026-10-09 04:00:00")},
{"picks the next of several", at("2026-10-08 12:00:00"), []TimeOfDay{{6, 0}, {18, 0}}, at("2026-10-08 18:00:00")},
{"year rollover", at("2026-12-31 23:00:00"), four, at("2027-01-01 04:00:00")},
{"now given in UTC", at("2026-10-08 03:00:00").UTC(), four, at("2026-10-08 04:00:00")},
// Spring forward (2026-03-29 02:00 → 03:00): still 04:00 local, 23h after the previous run.
{"spring forward", at("2026-03-28 04:00:00"), four, at("2026-03-29 04:00:00")},
// Fall back (2026-10-25 03:00 → 02:00): still 04:00 local, 25h after the previous run.
{"fall back", at("2026-10-24 04:00:00"), four, at("2026-10-25 04:00:00")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Next(tc.now, tc.times, prague)
if !got.Equal(tc.want) {
t.Errorf("Next = %s, want %s", got, tc.want)
}
})
}
if got := Next(at("2026-10-08 12:00:00"), nil, prague); !got.IsZero() {
t.Errorf("Next with empty schedule = %s, want zero", got)
}
if d := at("2026-03-29 04:00:00").Sub(at("2026-03-28 04:00:00")); d != 23*time.Hour {
t.Errorf("spring-forward gap = %s, want 23h", d)
}
}
func TestRunStopsOnCancelAndEmptySchedule(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
called := false
job := func(context.Context) { called = true }
done := make(chan struct{})
go func() { Run(context.Background(), nil, time.UTC, logger, job); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run with empty schedule did not return")
}
ctx, cancel := context.WithCancel(context.Background())
done = make(chan struct{})
far := time.Now().UTC().Add(12 * time.Hour)
go func() {
Run(ctx, []TimeOfDay{{far.Hour(), far.Minute()}}, time.UTC, logger, job)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
if called {
t.Error("job must not run before its scheduled time")
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ func InferPayments(
if idx < 0 || idx >= len(row) {
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
@@ -53,6 +53,42 @@ func TestInferPayments_BasicMatch(t *testing.T) {
}
}
func TestInferPayments_OtherMessage(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:Z": {
paymentsHeader,
// Known member: Person filled, message copied to Purpose, no month.
{"2026-04-10", 300.0, "", "", "", "", "Jana Novakova", "", "other: dres", "", ""},
// Unknown sender: Purpose is still written, Person stays empty.
{"2026-04-11", 500.0, "", "", "", "", "Unknown Payer", "", "Other: turnaj", "", ""},
},
}}
att := &fakeAttendance{
adults: []reconcile.Member{{Name: "Jana Novakova", Tier: "A"}},
}
n, err := InferPayments(context.Background(), "SHEETID", sh, att, InferOpts{})
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("want 2 rows updated, got %d", n)
}
updates := sh.BatchUpdated[0].Updates
want := [][]any{
{"Jana Novakova", "other: dres", "300"},
{"", "Other: turnaj", "500"},
}
for i, w := range want {
got := updates[i].Values[0]
for j := range w {
if got[j] != w[j] {
t.Errorf("row %d col %d: want %q, got %q", i, j, w[j], got[j])
}
}
}
}
func TestInferPayments_SkipRule_ManualFix(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:Z": {
+63
View File
@@ -0,0 +1,63 @@
package banksync
import (
"context"
"errors"
"io"
"sync"
"time"
)
// ErrBusy is returned by Runner.Run when another run is still in progress.
var ErrBusy = errors.New("a sync is already running")
// RunStatus describes one finished run of a Runner.
type RunStatus struct {
Started, Finished time.Time
Trigger string // "manual" or "scheduled"
Err error
}
// Runner serialises bank sync runs so a scheduled run and a manual one never
// overlap (overlapping runs could append the same rows twice), and remembers
// the outcome of the last run for display.
type Runner struct {
job func(ctx context.Context, out io.Writer) error
running sync.Mutex
mu sync.Mutex
last RunStatus
hasLast bool
}
// NewRunner wraps job, which performs one full sync and writes a
// human-readable log to out.
func NewRunner(job func(ctx context.Context, out io.Writer) error) *Runner {
return &Runner{job: job}
}
// Run executes the job unless one is already running, in which case it
// returns ErrBusy without waiting.
func (r *Runner) Run(ctx context.Context, trigger string, out io.Writer) error {
if !r.running.TryLock() {
return ErrBusy
}
defer r.running.Unlock()
st := RunStatus{Started: time.Now(), Trigger: trigger}
st.Err = r.job(ctx, out)
st.Finished = time.Now()
r.mu.Lock()
r.last, r.hasLast = st, true
r.mu.Unlock()
return st.Err
}
// Last returns the outcome of the most recent finished run, if any.
func (r *Runner) Last() (RunStatus, bool) {
r.mu.Lock()
defer r.mu.Unlock()
return r.last, r.hasLast
}
@@ -0,0 +1,65 @@
package banksync
import (
"context"
"errors"
"io"
"testing"
)
func TestRunner_RecordsStatus(t *testing.T) {
r := NewRunner(func(context.Context, io.Writer) error { return nil })
if _, ok := r.Last(); ok {
t.Fatal("Last before any run: want ok=false")
}
if err := r.Run(context.Background(), "scheduled", io.Discard); err != nil {
t.Fatal(err)
}
st, ok := r.Last()
if !ok || st.Trigger != "scheduled" || st.Err != nil || st.Finished.Before(st.Started) {
t.Errorf("unexpected status %+v ok=%v", st, ok)
}
}
func TestRunner_RecordsError(t *testing.T) {
boom := errors.New("boom")
r := NewRunner(func(context.Context, io.Writer) error { return boom })
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, boom) {
t.Fatalf("Run err = %v, want boom", err)
}
if st, _ := r.Last(); !errors.Is(st.Err, boom) || st.Trigger != "manual" {
t.Errorf("unexpected status %+v", st)
}
}
func TestRunner_BusyWhileRunning(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
first := true
r := NewRunner(func(context.Context, io.Writer) error {
if first { // runs are serialised by Runner, so no extra locking is needed
first = false
close(started)
<-release
}
return nil
})
done := make(chan error, 1)
go func() { done <- r.Run(context.Background(), "scheduled", io.Discard) }()
<-started
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, ErrBusy) {
t.Errorf("second Run err = %v, want ErrBusy", err)
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
// The skipped run must not overwrite the status of the real one.
if st, _ := r.Last(); st.Trigger != "scheduled" {
t.Errorf("Last trigger = %q, want scheduled", st.Trigger)
}
if err := r.Run(context.Background(), "manual", io.Discard); err != nil {
t.Errorf("Run after release: %v", err)
}
}
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"fuj-management/go/internal/domain/synch"
"fuj-management/go/internal/io/fio"
"fuj-management/go/internal/io/sheets"
"os"
"strings"
"time"
@@ -35,6 +36,18 @@ type SyncOpts struct {
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
}
// DefaultWindow is the window used by the dashboard sync: the whole current
// calendar year plus the last two months of the previous one, so payments
// dated around New Year are still picked up in January. Re-fetching is safe
// because rows are deduplicated by Sync ID.
// Mirrors default_sync_window in legacy/scripts/sync_fio_to_sheets.py.
func DefaultWindow(now time.Time) (from, to time.Time) {
yr := now.Year()
from = time.Date(yr-1, time.November, 1, 0, 0, 0, 0, time.UTC)
to = time.Date(yr, time.December, 31, 23, 59, 59, 0, time.UTC)
return from, to
}
// SyncToSheets fetches Fio transactions from all provided clients and appends
// new ones to the payments sheet. Returns the number of rows appended.
// Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
@@ -130,7 +143,7 @@ func SyncToSheets(
newRows = append(newRows, []any{
tx.Date, tx.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 +154,7 @@ func SyncToSheets(
if opts.DryRun {
for _, row := range newRows {
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 {
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 {
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) {
@@ -189,3 +193,23 @@ func syncIDFor(tx fio.Transaction) string {
Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID,
})
}
func TestDefaultWindow(t *testing.T) {
cases := []struct {
now time.Time
from, to string
}{
{time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC), "2025-11-01", "2026-12-31"},
{time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
{time.Date(2027, 12, 31, 23, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
}
for _, tc := range cases {
from, to := DefaultWindow(tc.now)
if got := from.Format("2006-01-02"); got != tc.from {
t.Errorf("DefaultWindow(%s) from: want %s, got %s", tc.now.Format("2006-01-02"), tc.from, got)
}
if got := to.Format("2006-01-02"); got != tc.to {
t.Errorf("DefaultWindow(%s) to: want %s, got %s", tc.now.Format("2006-01-02"), tc.to, got)
}
}
}
+2 -2
View File
@@ -402,7 +402,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
if i < 0 || i >= len(row) {
return ""
}
return fmt.Sprint(row[i])
return sheets.CellString(row[i])
}
// getRaw returns row[i] without stringifying — needed for FormatDate to
@@ -425,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
amount := parseFloat(amountRaw)
var inferredAmount *float64
if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
if iv := getVal(row, idxInferred); iv != "" {
if f := parseFloat(iv); f != 0 {
inferredAmount = &f
}
@@ -123,6 +123,8 @@ func TestLoadTransactions(t *testing.T) {
{"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"},
{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)
@@ -131,8 +133,8 @@ func TestLoadTransactions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(txns) != 2 {
t.Fatalf("want 2 transactions, got %d", len(txns))
if len(txns) != 3 {
t.Fatalf("want 3 transactions, got %d", len(txns))
}
if txns[0].Person != "Alice" {
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" {
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) {
+6 -1
View File
@@ -242,7 +242,12 @@ func buildJuniorMemberRow(
tooltip := ""
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
// Unknown expected (one session, manual review) shows as "?", as in Python.
expectedText := strconv.Itoa(md.Expected)
if md.IsUnknown {
expectedText = "?"
}
tooltip = fmt.Sprintf("Received: %d, Expected: %s", paid, expectedText)
}
row.Months = append(row.Months, MonthCell{
+19 -10
View File
@@ -21,17 +21,26 @@ func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHan
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
}
// page builds the data shared by every page (nav highlight, build info, sync summary).
func (h *HTMLHandler) page(active string) PageData {
pd := PageData{Active: active, Build: h.build}
if h.actions.SyncInfo != nil {
pd.SyncInfo = h.actions.SyncInfo()
}
return pd
}
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleAdults(r.Context())
if err != nil {
h.renderer.Render(w, "adults", AdultsPageData{
PageData: PageData{Active: "adults", Build: h.build},
PageData: h.page("adults"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "adults", AdultsPageData{
PageData: PageData{Active: "adults", Build: h.build},
PageData: h.page("adults"),
Data: data,
})
}
@@ -40,13 +49,13 @@ func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleJuniors(r.Context())
if err != nil {
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
PageData: h.page("juniors"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
PageData: h.page("juniors"),
Data: data,
})
}
@@ -55,20 +64,20 @@ func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssemblePayments(r.Context())
if err != nil {
h.renderer.Render(w, "payments", PaymentsPageData{
PageData: PageData{Active: "payments", Build: h.build},
PageData: h.page("payments"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "payments", PaymentsPageData{
PageData: PageData{Active: "payments", Build: h.build},
PageData: h.page("payments"),
Data: data,
})
}
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
pd := PageData{Active: "sync", Build: h.build}
pd := h.page("sync")
if h.actions.BankSync == nil {
h.renderer.Render(w, "sync", SyncPageData{
@@ -96,7 +105,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
}
h.renderer.Render(w, "sync", SyncPageData{
PageData: pd,
PageData: h.page("sync"), // rebuilt so the footer reflects this run
Output: buf.String(),
Success: success,
})
@@ -105,7 +114,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
PageData: h.page("flush"),
})
}
@@ -113,7 +122,7 @@ func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request)
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
n, _ := h.apiHandler.FlushCache()
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
PageData: h.page("flush"),
Flushed: true,
Deleted: n,
})
+22
View File
@@ -314,6 +314,28 @@ func TestServeSync(t *testing.T) {
}
}
func TestFooterSyncInfo(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
get := func(actions web.ActionHandlers) string {
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
w := httptest.NewRecorder()
h.ServeFlushCacheGET(w, httptest.NewRequest(http.MethodGet, "/flush-cache", nil))
return w.Body.String()
}
const info = "last sync 2026-10-08 04:00 (scheduled) ok"
if body := get(web.ActionHandlers{SyncInfo: func() string { return info }}); !strings.Contains(body, "| "+info) {
t.Errorf("footer missing sync info %q", info)
}
if body := get(web.ActionHandlers{}); strings.Contains(body, "last sync") {
t.Errorf("footer shows sync info although none was provided")
}
}
func TestPaymentQRMarkup(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
+1
View File
@@ -12,6 +12,7 @@ import (
type PageData struct {
Active string
Build BuildInfo
SyncInfo string // last/next bank sync summary shown in the footer; empty hides it
}
// AdultsPageData is the view model for the /adults HTML page.
+3
View File
@@ -26,6 +26,9 @@ type ActionHandlers struct {
// BankSync runs sync+infer and writes a human-readable log to out.
// nil disables the /sync-bank action (renders an error instead).
BankSync func(ctx context.Context, out io.Writer) error
// SyncInfo returns a one-line summary of the last and next bank sync for
// the page footer. nil or an empty result shows nothing.
SyncInfo func() string
}
// Run registers routes and starts the HTTP server on addr.
@@ -1,3 +1,3 @@
{{define "footer"}}
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}{{if .SyncInfo}} | {{.SyncInfo}}{{end}}</div>
{{end}}
+1 -1
View File
@@ -9,7 +9,7 @@
{{end}}
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
{{else}}
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
<p class="description">Fetches Fio transactions for the current year and the last two months of the previous one, infers payment details, and flushes the cache.</p>
{{end}}
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
{{end}}
@@ -0,0 +1,12 @@
{
"case": "one_session_free_july",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 1,
"month_key": "2026-07"
},
"output": {
"fee": 0
}
}
@@ -1,12 +1,12 @@
{
"case": "two_sessions_default_fallback",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2099-01"
},
"output": {
"fee": 700
"fee": 600
}
}
@@ -0,0 +1,12 @@
{
"case": "two_sessions_free_july",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2026-07"
},
"output": {
"fee": 0
}
}
@@ -0,0 +1,12 @@
{
"case": "two_sessions_october",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2026-10"
},
"output": {
"fee": 600
}
}
@@ -0,0 +1,13 @@
{
"case": "one_session_free_august",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 1,
"month_key": "2026-08"
},
"output": {
"value": 0,
"unknown": false
}
}
@@ -0,0 +1,13 @@
{
"case": "two_sessions_free_august",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-10-08",
"input": {
"attendance_count": 2,
"month_key": "2026-08"
},
"output": {
"value": 0,
"unknown": false
}
}
@@ -0,0 +1,36 @@
{
"case": "other_not_at_start",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Jana Novakova",
"message": "leden other: dres",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [
{
"name": "Jana Nováková",
"confidence": "auto"
}
],
"months": [
"2026-01"
],
"search_text": "Jana Novakova leden other: dres "
}
}
@@ -0,0 +1,36 @@
{
"case": "other_prefix",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Jana Novakova",
"message": "other: tournament leden 2026",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [
{
"name": "Jana Nováková",
"confidence": "auto"
}
],
"months": [
"other: tournament leden 2026"
],
"search_text": "Jana Novakova other: tournament leden 2026 "
}
}
@@ -0,0 +1,31 @@
{
"case": "other_prefix_uppercase",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-10-08",
"input": {
"tx": {
"sender": "Test Payer",
"message": " Other: Dres ",
"user_id": "",
"date": {
"type": "string",
"value": "2026-04-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [],
"months": [
"Other: Dres"
],
"search_text": "Test Payer Other: Dres "
}
}
@@ -3,7 +3,7 @@
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Filip Halamka - prosinec, leden, unor",
"text": "Member_607b748d - prosinec, leden, unor",
"default_year": 2026
},
"output": {
@@ -3,7 +3,7 @@
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Jáchym Kubík: 01/2026+03/2026+04/2026",
"text": "Member_71343342: 01/2026+03/2026+04/2026",
"default_year": 2026
},
"output": {
@@ -3,7 +3,7 @@
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Martin Bolvansky Pausal Prosinec Leden",
"text": "Member_51a8ec2d Pausal Prosinec Leden",
"default_year": 2026
},
"output": {
@@ -0,0 +1,69 @@
{
"case": "11_other_message_fallback",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-10-08",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-10",
"amount": 300,
"manual_fix": "",
"person": "",
"purpose": "",
"inferred_amount": 300,
"sender": "Member_d035d9f9",
"message": "other: tournament",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 0.0,
"transactions": []
}
},
"other_transactions": [
{
"amount": 300.0,
"date": "2026-01-10",
"sender": "Member_d035d9f9",
"message": "other: tournament",
"purpose": "other: tournament",
"confidence": "auto"
}
],
"total_balance": -750
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": -750
}
}
}
+14 -7
View File
@@ -30,7 +30,7 @@ from views import (
adapt_junior_members,
)
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
from sync_fio_to_sheets import sync_to_sheets
from sync_fio_to_sheets import sync_to_sheets, default_sync_window
from infer_payments import infer_payments
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
@@ -121,6 +121,8 @@ def flush_cache_endpoint():
deleted = flush_cache()
return render_template("flush-cache.html", flushed=True, deleted=deleted)
# Scheduled (daily) bank sync exists only in the Go server, on purpose: this
# app is legacy and being phased out. Here the sync runs only on request.
@app.route("/sync-bank")
def sync_bank():
import contextlib
@@ -128,13 +130,14 @@ def sync_bank():
success = True
try:
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
# sync_to_sheets: equivalent of make sync-2026
output.write("=== Syncing Fio transactions (2026) ===\n")
# sync_to_sheets: equivalent of make sync-year (same default window as Go)
date_from, date_to = default_sync_window()
output.write(f"=== Syncing Fio transactions ({date_from} to {date_to}) ===\n")
sync_to_sheets(
spreadsheet_id=PAYMENTS_SHEET_ID,
credentials_path=CREDENTIALS_PATH,
date_from_str="2026-01-01",
date_to_str="2026-12-31",
date_from_str=date_from,
date_to_str=date_to,
sort_by_date=True,
)
output.write("\n=== Inferring payment details ===\n")
@@ -179,7 +182,9 @@ def api_adults():
datetime.now().strftime("%Y-%m"),
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")
def api_juniors():
@@ -203,7 +208,9 @@ def api_juniors():
datetime.now().strftime("%Y-%m"),
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")
def api_payments():
+53
View File
@@ -115,6 +115,15 @@ SEEDS[("calculate_fee", "two_sessions_reduced_march")] = {
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
"attendance_count": 2, "month_key": "2099-01"
}
SEEDS[("calculate_fee", "two_sessions_free_july")] = {
"attendance_count": 2, "month_key": "2026-07"
}
SEEDS[("calculate_fee", "one_session_free_july")] = {
"attendance_count": 1, "month_key": "2026-07"
}
SEEDS[("calculate_fee", "two_sessions_october")] = {
"attendance_count": 2, "month_key": "2026-10"
}
# --- calculate_junior_fee ---
@@ -137,6 +146,12 @@ SEEDS[("calculate_junior_fee", "two_sessions_reduced_sep")] = {
SEEDS[("calculate_junior_fee", "two_sessions_default_fallback")] = {
"attendance_count": 2, "month_key": "2099-06"
}
SEEDS[("calculate_junior_fee", "two_sessions_free_august")] = {
"attendance_count": 2, "month_key": "2026-08"
}
SEEDS[("calculate_junior_fee", "one_session_free_august")] = {
"attendance_count": 1, "month_key": "2026-08"
}
# --- parse_czk_amount ---
@@ -349,6 +364,36 @@ SEEDS[("infer_transaction_details", "serial_date")] = {
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix")] = {
"tx": {
"sender": "Jana Novakova",
"message": "other: tournament leden 2026",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix_uppercase")] = {
"tx": {
"sender": "Test Payer",
"message": " Other: Dres ",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_not_at_start")] = {
"tx": {
"sender": "Jana Novakova",
"message": "leden other: dres",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "no_member_no_month")] = {
"tx": {
"sender": "Unknown Person",
@@ -467,6 +512,14 @@ SEEDS[("reconcile", "07_other_purpose_split")] = _reconcile_seed(
transactions=[_tx("2026-01-10", 800, "Alice Dvořák, Bob Kratochvíl", "other:tournament", sender="Alice Dvořák")],
)
# 11 — message starts with "other:" on a row not yet inferred (empty Person/Purpose):
# goes to the other ledger, the month stays unpaid
SEEDS[("reconcile", "11_other_message_fallback")] = _reconcile_seed(
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
sorted_months=["2026-01"],
transactions=[_tx("2026-01-10", 300, "", "", sender="Alice Dvořák", message="other: tournament")],
)
# 08 — junior with attendance=1 (expected=0 in reconcile, unknown in UI)
SEEDS[("reconcile", "08_junior_question_mark")] = _reconcile_seed(
members=[_member("Karel Junior", "A", {"2026-01": (0, 1)})],
+17 -10
View File
@@ -10,7 +10,7 @@ from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
ADULT_FEE_DEFAULT = 700 # CZK, for 2+ practices in a month
ADULT_FEE_DEFAULT = 600 # CZK, for 2+ practices in a month
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
ADULT_FEE_MONTHLY_RATE = {
"2025-09": 750,
@@ -23,14 +23,19 @@ ADULT_FEE_MONTHLY_RATE = {
"2026-04": 700,
"2026-05": 450,
"2026-06": 600,
"2026-07": 600,
"2026-08": 600,
"2026-07": 0,
"2026-08": 0,
"2026-09": 600,
"2026-10": 600,
"2026-11": 600,
"2026-12": 600,
}
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
JUNIOR_MONTHLY_RATE = {
"2025-09": 250,
"2026-03": 250 # reduced fee for March 2026
"2026-03": 250, # reduced fee for March 2026
"2026-08": 0,
}
ADULT_MERGED_MONTHS = {
"2025-09": "2025-10", # keys are merged into values
@@ -91,21 +96,23 @@ def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, s
def calculate_fee(attendance_count: int, month_key: str) -> int:
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750)."""
"""Apply fee rules: 0 → 0, 1 → 200 (capped at the month's rate), 2+ → configured rate (default 600)."""
if attendance_count == 0:
return 0
rate = ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
if attendance_count == 1:
return ADULT_FEE_SINGLE
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
return min(ADULT_FEE_SINGLE, rate)
return rate
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
"""Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500)."""
"""Apply junior fee rules: 0 → 0, 1 → '?' (0 in a 0-rate month), 2+ → Configured Rate (default 500)."""
if attendance_count == 0:
return 0
if attendance_count == 1:
rate = JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
if attendance_count == 1 and rate > 0:
return "?"
return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
return rate
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
+30 -5
View File
@@ -150,27 +150,48 @@ def match_members(
# Reconciliation
# ---------------------------------------------------------------------------
def is_other_purpose(text) -> bool:
"""True when text marks a payment that is not a monthly fee.
It starts with "other:" (case-insensitive, leading whitespace ignored).
Used for both the Purpose column and the bank message.
"""
return str(text or "").strip().lower().startswith("other:")
def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
"""Infer member(s) and month(s) for a single transaction.
A message that starts with "other:" is not a monthly fee: no month is
inferred (neither from the text nor from the date) and 'months' holds the
trimmed message as the single purpose.
Returns:
{
'members': [(name, confidence)],
'months': [YYYY-MM],
'months': [YYYY-MM] or ['other:…'],
'matched_text': str
}
"""
# Combine sender + message for searching
search_text = f"{tx.get('sender', '')} {tx.get('message', '')} {tx.get('user_id', '')}"
matched_members = match_members(search_text, member_names)
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_members:
# Try matching sender name alone with more lenient matching
matched_members = match_members(tx.get("sender", ""), member_names)
if is_other_purpose(tx.get("message", "")):
return {
"members": matched_members,
"months": [str(tx.get("message", "")).strip()],
"search_text": search_text
}
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_months:
# If no month specified, try to infer from payment date
tx_date = tx.get("date")
@@ -394,7 +415,7 @@ def reconcile(
# Strip markers like [?]
person_str = re.sub(r"\[\?\]\s*", "", person_str)
is_other = purpose_str.lower().startswith("other:")
is_other = is_other_purpose(purpose_str)
if person_str and purpose_str:
# We have pre-matched data (either from script or manual)
@@ -415,6 +436,10 @@ def reconcile(
inference = infer_transaction_details(tx, member_names)
matched_members = inference["members"]
matched_months = inference["months"]
# A message starting with "other:" is inferred as a non-fee payment.
if len(matched_months) == 1 and is_other_purpose(matched_months[0]):
is_other = True
purpose_str = matched_months[0]
amount = tx.get("amount", 0)
try:
amount = float(amount)
+23 -1
View File
@@ -59,6 +59,28 @@ def get_sheets_service(credentials_path: str):
return _SHEETS_SERVICE
def default_sync_window(today=None) -> tuple[str, str]:
"""Window used by the dashboard sync, as (from, to) in YYYY-MM-DD.
The whole current calendar year plus the last two months of the previous
one, so payments dated around New Year are still picked up in January.
Re-fetching is safe because rows are deduplicated by Sync ID.
Mirrors DefaultWindow in go/internal/services/banksync/sync.go.
"""
year = (today or datetime.now()).year
return f"{year - 1}-11-01", f"{year}-12-31"
def as_text_cell(value) -> str:
"""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:
"""Generate a unique SHA-256 hash for a transaction.
@@ -209,7 +231,7 @@ def sync_to_sheets(
"", # Purpose
"", # Inferred Amount
tx.get("sender", ""),
tx.get("vs", ""),
as_text_cell(tx.get("vs", "")),
tx.get("message", ""),
tx.get("bank_id", ""),
sync_id,
+17 -1
View File
@@ -6,7 +6,7 @@ from app import app
EXPECTED_ADULTS_KEYS = {
"months", "raw_months", "results", "totals", "member_data", "month_labels",
"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_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
@@ -23,6 +23,20 @@ class TestWebApp(unittest.TestCase):
app.config['TESTING'] = True
self.client = app.test_client()
@patch('app.flush_cache', return_value=0)
@patch('app.infer_payments')
@patch('app.sync_to_sheets')
def test_sync_bank_uses_default_window(self, mock_sync, mock_infer, mock_flush):
"""/sync-bank syncs the current year plus Nov-Dec of the previous one, not a hardcoded year."""
from datetime import datetime
year = datetime.now().year
response = self.client.get('/sync-bank')
self.assertEqual(response.status_code, 200)
kwargs = mock_sync.call_args.kwargs
self.assertEqual(kwargs['date_from_str'], f"{year - 1}-11-01")
self.assertEqual(kwargs['date_to_str'], f"{year}-12-31")
mock_infer.assert_called_once()
def test_index_page(self):
"""Test that / returns the refresh meta tag"""
response = self.client.get('/')
@@ -139,6 +153,7 @@ class TestWebApp(unittest.TestCase):
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], 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.fetch_sheet_data')
@@ -166,6 +181,7 @@ class TestWebApp(unittest.TestCase):
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], 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.fetch_sheet_data')
+7 -7
View File
@@ -51,21 +51,21 @@ class TestMatchMembersExact(unittest.TestCase):
def test_shared_first_name_junior_in_roster_wins_exact(self):
# Regression: two members share first name "Jáchym"; message has full name
# of the junior-only member → exact match must win, no [?] on the adult.
roster = ["Jáchym Hrušák (G)", "Jáchym Kubík"]
roster = ["Jáchym Novotný (G)", "Jáchym Dvořák"]
result = match_members(
"JIŘÍ KUBÍK Jáchym Kubík: 01/2026+03/2026+04/2026", roster
"JIŘÍ DVOŘÁK Jáchym Dvořák: 01/2026+03/2026+04/2026", roster
)
self.assertEqual(result, [("Jáchym Kubík", "auto")])
self.assertEqual(result, [("Jáchym Dvořák", "auto")])
def test_shared_first_name_without_junior_in_roster_falls_back(self):
# Without Kubík in the roster (old behaviour), Hrušák wins via first-name
# Without Dvořák in the roster (old behaviour), Novotný wins via first-name
# partial match — confirms the roster-expansion fix is the real solution.
roster = ["Jáchym Hrušák (G)"]
roster = ["Jáchym Novotný (G)"]
result = match_members(
"JIŘÍ KUBÍK Jáchym Kubík: 01/2026+03/2026+04/2026", roster
"JIŘÍ DVOŘÁK Jáchym Dvořák: 01/2026+03/2026+04/2026", roster
)
names = [r[0] for r in result]
self.assertIn("Jáchym Hrušák (G)", names)
self.assertIn("Jáchym Novotný (G)", names)
if __name__ == "__main__":
+65 -1
View File
@@ -1,6 +1,9 @@
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, infer_transaction_details, is_other_purpose, reconcile,
)
class TestCanonicalMemberKey(unittest.TestCase):
@@ -67,3 +70,64 @@ class TestReconcileTolerantPersonMatching(unittest.TestCase):
if __name__ == "__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")
class TestOtherPurposeInference(unittest.TestCase):
"""A message starting with "other:" is not a monthly fee."""
MEMBERS = ["Jana Nováková", "Adam Novák"]
def test_is_other_purpose(self):
for text in ("other: tournament", "other:shirt", " Other: Tournament", "OTHER:x"):
self.assertTrue(is_other_purpose(text), text)
for text in ("leden other: x", "other tournament", "another: x", "2026-01", "", None):
self.assertFalse(is_other_purpose(text), text)
def test_message_becomes_purpose_and_no_month_is_inferred(self):
tx = {"sender": "Jana Novakova", "message": " other: turnaj leden 2026 ", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["members"], [("Jana Nováková", "auto")])
self.assertEqual(result["months"], ["other: turnaj leden 2026"])
def test_unknown_member_still_yields_purpose(self):
tx = {"sender": "Unknown Payer", "message": "Other: Dres", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["members"], [])
self.assertEqual(result["months"], ["Other: Dres"])
def test_other_not_at_start_is_handled_the_old_way(self):
tx = {"sender": "Jana Novakova", "message": "leden other: dres", "date": "2026-04-15"}
result = infer_transaction_details(tx, self.MEMBERS)
self.assertEqual(result["months"], ["2026-01"])
def test_reconcile_fallback_puts_it_in_other_ledger(self):
members = [("Jana Nováková", "A", {"2026-01": (750, 3)})]
tx = {"date": "2026-01-10", "amount": 300, "person": "", "purpose": "",
"sender": "Jana Novakova", "message": "other: dres", "inferred_amount": ""}
result = reconcile(members, ["2026-01"], [tx], {})
member = result["members"]["Jana Nováková"]
self.assertEqual(member["months"]["2026-01"]["paid"], 0)
self.assertEqual(len(member["other_transactions"]), 1)
self.assertEqual(member["other_transactions"][0]["amount"], 300)
self.assertEqual(member["other_transactions"][0]["purpose"], "other: dres")
self.assertEqual(result["unmatched"], [])
+41
View File
@@ -0,0 +1,41 @@
import contextlib
import io
import unittest
from datetime import datetime
from unittest.mock import MagicMock, patch
from scripts.sync_fio_to_sheets import COLUMN_LABELS, as_text_cell, default_sync_window, sync_to_sheets
class TestDefaultSyncWindow(unittest.TestCase):
def test_current_year_plus_last_two_months_of_previous(self):
self.assertEqual(default_sync_window(datetime(2026, 10, 8)), ("2025-11-01", "2026-12-31"))
self.assertEqual(default_sync_window(datetime(2027, 1, 1)), ("2026-11-01", "2027-12-31"))
self.assertEqual(default_sync_window(datetime(2027, 12, 31)), ("2026-11-01", "2027-12-31"))
class TestAsTextCell(unittest.TestCase):
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")