Compare commits

...
Author SHA1 Message Date
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
kacerrandClaude Opus 5.5 022facc957 refactor: move Python implementation to legacy/, make Go primary
Deploy to K8s / deploy (push) Successful in 20s
Go (go/) becomes the primary implementation. The original Python
version moves to legacy/ and is kept runnable; new features are still
built in both (see the Dual implementation rule in CLAUDE.md).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:37:29 +02:00
kacerrandClaude Opus 4.8 29938d7a0c chore(changelog): log gitops-update workflow addition
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:32:20 +02:00
kacerr 1df1863725 Merge pull request 'feat(ci): gitops image-update PR workflow for home-kubernetes' (#39) from feat/gitops-pr-action into main
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #39
2026-06-12 19:31:51 +02:00
kacerrandClaude Opus 4.8 995abfacb2 feat(ci): add gitops-update workflow to open image-bump PR in home-kubernetes
Deploy to K8s / deploy (push) Successful in 12s
After a successful Go image build, uh-cli opens a PR against
kacerr/home-kubernetes that bumps the fuj-management Deployment
(namespace fuj) to the newly published image tag. Supports
workflow_run auto-trigger and workflow_dispatch with dry-run option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:28:29 +02:00
kacerrandClaude Opus 4.8 f047150004 fix(tests): derive fee test expectations from constants, not hardcoded values
Deploy to K8s / deploy (push) Successful in 9s
Configured-month cases now read expected values from AdultFeeMonthlyRate /
JuniorFeeMonthlyRate via a mustRate helper that panics if a test month is
removed from the map. Fallback cases use AdultFeeDefault / JuniorFeeDefault.

This way the tests verify dispatch logic (0/1/2+ branching, map vs. fallback)
without breaking when rates are intentionally updated in the map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:37:52 +02:00
kacerr 6f2994b8ad Merge pull request 'feat(display): limit /adults and /juniors to last N months by default' (#38) from feat/months-to-show into main
Deploy to K8s / deploy (push) Successful in 14s
Build and Push / build (push) Successful in 9s
Build and Push / build-go (push) Successful in 1m10s
Reviewed-on: #38
2026-06-08 09:31:46 +00:00
kacerrandClaude Opus 4.8 c2a381bb63 fix(display): default from-selector to last N months; keep all months selectable
Deploy to K8s / deploy (push) Successful in 12s
Instead of hiding older months entirely, show all months in the from/to
selectors but default the from-select to the last MONTHS_TO_SHOW months
on page load. The "All" button resets to full history as before.

Python: passes months_to_show to render_template, IIFE sets fromSelect.value.
Go: adds MonthsToShow to response structs, data-months-to-show attr in
templates, filters.js reads it and defaults fromSelect after hideFutureMonths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:28:40 +02:00
kacerrandClaude Opus 4.8 c0487e3af0 feat(display): limit /adults and /juniors to last N months by default
Deploy to K8s / deploy (push) Successful in 17s
Show only the last MONTHS_TO_SHOW months (default 5) in the fee table columns
so the page fits on screen without horizontal scrolling. Reconciliation still
runs over the full month history so balances, credits, and debts are unaffected.
Set MONTHS_TO_SHOW=0 to show all months. Implemented in both Python and Go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:18:55 +02:00
kacerrandClaude Opus 4.8 37fc17cf9c chore(settings): accumulate Claude Code permission allowlist entries
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:11:36 +02:00
kacerrandClaude Opus 4.8 20b618685f fix(reconcile): handle '?' expected fee in fill-first allocation
Juniors with exactly 1 session get expected='?' (manual-review marker
from attendance.py). The fill-first allocation block summed and cast
expected values numerically without guarding against this, causing a
TypeError: unsupported operand type(s) for +: 'int' and 'str' on the
/juniors route whenever any matched payment landed on such a month.

Add _expected_amount() helper that coerces non-numeric markers to 0
(same convention the final-balance calculation at line 512 already used)
and apply it in the two failing spots plus the existing isinstance check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:10:41 +02:00
kacerrandClaude Sonnet 4.6 72e29b1882 chore(changelog): add entry for fee rate update 2026-05 through 2026-08
Build and Push / build (push) Successful in 11s
Build and Push / build-go (push) Successful in 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:58:25 +02:00
kacerr 241fecfb2c Merge pull request 'feat: multi-account Fio sync + switch QR default to 2502035405/2010' (#37) from feat/multi-account-bank-sync into main
Deploy to K8s / deploy (push) Successful in 22s
Reviewed-on: #37
2026-05-24 19:57:52 +00:00
kacerrandClaude Sonnet 4.6 723591cbce feat(fees): update adult monthly rates for 2026-05 through 2026-08
Deploy to K8s / deploy (push) Successful in 40s
- 2026-05: 700 → 450 CZK
- 2026-06, 07, 08: 600 CZK (new months)

Changes are mirrored in both Python (scripts/attendance.py) and Go (go/internal/domain/fees/fees.go).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:56:41 +02:00
kacerrandClaude Opus 4.7 69af4c1e3b feat: multi-account Fio sync + switch QR default to 2502035405/2010
Deploy to K8s / deploy (push) Successful in 24s
Add second Fio account (CZ0820100000002502035405 / 2502035405/2010).
Both accounts are fetched on every sync run and combined before dedup,
so the payments sheet accumulates transactions from either account.
QR codes now default to the new account.

Go:
- config.go: hardcoded Accounts/LoadedAccount slice replaces scalar
  BankAccount + FioAPIToken; Config.BankAccount renamed QRAccount;
  per-account tokens via FIO_API_TOKEN_NEW / FIO_API_TOKEN_OLD
- banksync.SyncToSheets: accepts []fio.Client, loops to combine txns
- cmd/fuj/main.go: buildFioClients helper; both sync call sites updated
- html_handler + build_adults/juniors: use Config.QRAccount
- New TestSyncToSheets_MultiAccount covers cross-account dedup

Python:
- config.py: ACCOUNTS list + LOADED_ACCOUNTS (tokens from env)
- fio_utils.py: fetch_transactions_for (per-account) +
  fetch_transactions_all (loops all accounts)
- sync_fio_to_sheets.py: uses fetch_transactions_all

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:42:47 +02:00
kacerr 152908fec6 Merge pull request 'gitignore go/parity' (#36) from fix/fill-first-multi-month-allocation into main
Deploy to K8s / deploy (push) Successful in 34s
Reviewed-on: #36
2026-05-24 19:16:27 +00:00
kacerr fbc5a41d12 gitignore go/parity
Deploy to K8s / deploy (push) Successful in 22s
2026-05-24 21:15:38 +02:00
kacerr 7f801d27f5 Merge branch 'feat/go-m6-7-embed-verify'
Deploy to K8s / deploy (push) Successful in 15s
2026-05-24 21:13:15 +02:00
kacerr 10e2e9dc04 Merge pull request 'fix(reconcile): fill earliest month deficit first in multi-month allocations' (#35) from fix/fill-first-multi-month-allocation into main
Deploy to K8s / deploy (push) Successful in 13s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Successful in 52s
Reviewed-on: #35
2026-05-11 22:01:36 +00:00
kacerrandClaude Opus 4.7 8734089223 fix(reconcile): fill earliest month deficit first in multi-month allocations
Deploy to K8s / deploy (push) Successful in 9s
Replace proportional split with a fill-first loop that allocates
min(remaining, deficit) to each matched month in user-supplied order,
where deficit = expected - already_paid. Prior transactions' contributions
are now properly accounted for, so a second payment on overlapping months
fills only what's still owed instead of splitting proportionally by total
expected. Surplus after all deficits are covered goes to the credit bucket.

Fixes: Matyáš Thér 200+550 showing 566/183 instead of 500/250.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 23:59:36 +02:00
kacerrandClaude Opus 4.7 aaa876e593 fix(python): parse Fio 2-digit-year dates + add make sync-debug
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Successful in 59s
Deploy to K8s / deploy (push) Successful in 8s
Fio's transparent page now serves recent rows as DD.MM.YY while older
rows stay DD.MM.YYYY. parse_czech_date only knew the 4-digit form so
every recent transfer was silently dropped — make sync-2026 reported
zero new transactions. Adds %d.%m.%y and %d/%m/%y to the format list,
mirroring the Go-side fix from 2026-05-07.

Also adds a Python analog of make go-sync-debug:
- --dry-run skips header write / append / sort and prints "would …" lines
- --print-fio-table prints aligned per-txn table with NEW/DUP status
- make sync-debug [DAYS=N] wrapper (default DAYS=30)
- always-on stderr diagnostics in fio_utils: which fetcher was chosen
  (with FIO_API_TOKEN-unset lag warning) + raw-vs-filtered counts, so
  this class of "scraper drops everything" bug surfaces immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 22:56:49 +02:00
kacerrandClaude Sonnet 4.6 d981392593 feat(go): M6.7 — single-binary embed verification
Deploy to K8s / deploy (push) Successful in 8s
Add TestEmbedCompleteness and TestStaticAssetsServed in
go/internal/web/assets_test.go. The completeness guard walks the
on-disk templates/ and static/ directories and asserts every file is
present in the corresponding embed.FS, catching forgotten files on
future additions. The static mux test hits /static/css/app.css and all
JS files through the same http.FileServerFS wiring used in server.go,
confirming assets are served from the embedded FS with correct
Content-Type and a 404 for unknown paths.

Standalone binary smoke test passed manually: binary copied to /tmp
(no adjacent templates/ or static/), assets served correctly.

Closes M6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:24:47 +02:00
kacerrandClaude Opus 4.7 f25552eef2 chore: CHANGELOG + tick M6.6 (f6ba85b) and M6.6.1 (4276d7b) in progress tracker
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:56:57 +02:00
kacerr 4276d7b915 Merge pull request 'feat(go): M6.6.1 — QR payment popup modal on /adults and /juniors' (#33) from feat/go-m6-6-1-payment-qr-modal into main
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #33
2026-05-08 12:54:23 +00:00
kacerr f6ba85b18f Merge pull request 'feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages' (#32) from feat/go-m6-6-action-pages into main
Deploy to K8s / deploy (push) Has been cancelled
Reviewed-on: #32
2026-05-08 12:54:16 +00:00
kacerrandClaude Opus 4.7 919845518c feat(go): M6.6.1 — QR payment popup modal on /adults and /juniors
Deploy to K8s / deploy (push) Successful in 9s
Replace bare <a href=/qr> Pay buttons with <button data-*> elements that
open an in-page #qrModal (matching Python's showPayQR UX), driven by a
new payment-qr.js vanilla-JS IIFE module.  Remove the now-dead qrHref /
qrHrefAll template helpers from render.go.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:49:35 +02:00
kacerrandClaude Opus 4.7 fe935235e8 feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages
Deploy to K8s / deploy (push) Successful in 8s
- GET /qr: Czech QR Platba PNG; ports Python qr_code() exactly
  (account validation, amount clamping, * stripping, SPD format)
- GET /sync-bank: Fio sync → infer → cache flush with captured log
- GET+POST /flush-cache: form + action, shows deleted count
- GET /version: JSON alias of /api/version (Python parity)
- FlushCache() added to membership.Sources; wired through api.Handler
- web.ActionHandlers{BankSync} closure-based dep injection for sync
- New dep: github.com/skip2/go-qrcode
- TestQRBuildSPD (9 cases), TestServeQR, TestServeFlushCache{GET,POST},
  TestServeSync, TestServeVersion added

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:26:54 +02:00
kacerrandClaude Opus 4.7 e22ab8cc49 chore: CHANGELOG + tick M6.5 in progress tracker — SHA e53e238
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:19:19 +02:00
kacerr e53e238ca6 Merge pull request 'feat(go): M6.5 — member-detail modal JS module' (#31) from feat/go-m6-5-modal-js into main
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #31
2026-05-08 11:18:46 +00:00
kacerrandClaude Opus 4.7 309c26f209 feat(go): M6.5 — member-detail modal JS module for /adults and /juniors
Deploy to K8s / deploy (push) Successful in 8s
Adds static/js/member-detail.js: fetches /api/<page> once on page load,
caches the response, and renders a per-member detail modal on [i] row click.
Keyboard nav: Esc closes, ↑/↓ walk visible (filtered) rows. All modal CSS
was already in place from M6.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:14:41 +02:00
kacerrandClaude Opus 4.7 01573faced chore: CHANGELOG + tick M6.4 in progress tracker — SHA 689f1c0
Deploy to K8s / deploy (push) Successful in 10s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:48:42 +02:00
kacerr 689f1c01fd Merge pull request 'feat(go): M6.4 — Go-native /payments page (grouped-by-person ledger)' (#30) from feat/go-m6-4-payments-page into main
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #30
2026-05-08 10:48:04 +00:00
kacerrandClaude Opus 4.7 cb8a09b571 feat(go): M6.4 — Go-native /payments page (grouped-by-person ledger)
Deploy to K8s / deploy (push) Successful in 6s
- Extract AssemblePayments(ctx) from ServePayments in api/handler.go,
  mirroring the AssembleAdults/AssembleJuniors pattern
- Add PaymentsPageData view-model wrapper in render.go
- Rewire html_handler.go ServePayments to call AssemblePayments and
  render with PaymentsPageData
- Replace payments.tmpl placeholder with real grouped-by-person ledger:
  alphabetical member blocks, txn-table (Date/Amount/Purpose/Message),
  newest-first rows, Unmatched/Unknown bucket
- Append ledger CSS classes to app.css (.ledger-container, .member-block,
  .txn-table, .txn-date/amount/purpose/message, tr:hover)
- Add TestPaymentsPage markup test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:29:32 +02:00
kacerrandClaude Opus 4.7 7f87e63b7c chore: tick M6.3 in progress tracker — SHA 9564103
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:19:13 +02:00
kacerr 95641036cc Merge pull request 'feat(go): M6.3 — Go-native /juniors page' (#29) from feat/go-m6-3-juniors-page into main
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #29
2026-05-08 10:18:40 +00:00
kacerrandClaude Opus 4.7 505d635c66 chore: CHANGELOG entry for M6.3 Go juniors page
Deploy to K8s / deploy (push) Failing after 11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:26:08 +02:00
kacerrandClaude Opus 4.7 9f0e4b0ac3 feat(go): M6.3 — juniors page (table, filters, credits/debts, Pay buttons)
- Extract AssembleJuniors(ctx) from ServeJuniors JSON handler so HTML
  and JSON share the same view-model path (mirrors AssembleAdults pattern)
- Add JuniorsPageData wrapper in render.go
- Wire HTMLHandler.ServeJuniors to AssembleJuniors + render template
- Replace 4-line placeholder juniors.tmpl with full template:
  member table, name filter, month-range filter, totals row,
  Credits + Debts sections, Pay / Pay All buttons via /qr links
  (no Unmatched section — matches Python juniors.html parity)
- J/A attendance breakdown ("3/500 CZK (4:2J,1A)") and "?" sentinel
  rendered via MonthCell.Text from buildJuniorMemberRow, no extra
  template logic needed
- All tests pass; make parity reports 3/3 routes OK

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:25:50 +02:00
kacerr 2cbd98df1a Merge pull request 'fix: restore Sep+Oct adult merge and stop auto-truncating period selector' (#28) from fix/period-selector-restore into main
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #28
2026-05-08 09:13:14 +00:00
kacerrandClaude Opus 4.7 e618e906ef fix: restore Sep+Oct adult merge and stop auto-truncating period selector
Deploy to K8s / deploy (push) Successful in 9s
Two regressions made older periods invisible on the adults dashboard:

- 1257f0d (Mar 9) commented out ADULT_MERGED_MONTHS, removing the
  Sep+Oct 2025 merged label. Restored only the 2025-09 → 2025-10
  mapping (Dec and Jan are billed separately for adults; the
  Dec → Jan mapping stays disabled per product decision). Mirrored
  on the Go side. Test fixtures in sources_test.go now assert Sep
  dates land in merged 2025-10 instead of 2025-09.

- 7774301 (Apr 9) added a JS onload default that set the From
  selector to maxMonthIdx − 4 and immediately filtered the table,
  hiding everything older than 5 months on first load. Dropped that
  default in templates/adults.html and templates/juniors.html so
  the From-selector starts at the oldest available month. Future
  months are still removed from the dropdowns and hidden in the
  table — only the past-month truncation is gone.

Note: the live adults attendance sheet had also been pruned to
start at 02.12.2025; restoring Sep/Oct/Nov 2025 columns from
Sheets version history is required to actually see those periods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:12:26 +02:00
kacerrandClaude Opus 4.7 96e574e6c7 chore: tick M6.2 in progress tracker — SHA c85748b
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:38:28 +02:00
kacerr 6a8aa37198 Merge pull request 'feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)' (#27) from feat/go-m6-2-adults-page into main
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #27
2026-05-08 08:35:20 +00:00
kacerrandClaude Opus 4.7 aa0c17f521 fix(go): align adults cell class names with Python; un-underline Pay buttons
Deploy to K8s / deploy (push) Successful in 10s
- Map unpaid|partial → cell-unpaid (or cell-unpaid-current for current
  month) and surplus → cell-overridden, matching Python's Jinja logic;
  avoids emitting non-existent cell-partial/cell-surplus classes that
  caused Pay buttons to escape the table.
- Add text-decoration: none to .pay-btn so anchor-based Pay links don't
  show the default underline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:33:30 +02:00
kacerrandClaude Opus 4.7 464eeeb2b1 fix(go): wrap adults table in <div class="table-container">
Deploy to K8s / deploy (push) Successful in 9s
The lifted CSS defines .table-container with border: 1px solid #333 and
max-width: 1200px — without the wrapper the table stretched to full
viewport width and showed no border. Mirrors templates/adults.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:20:49 +02:00
kacerrandClaude Opus 4.7 daac5d7392 fix(go): adults template — emit markup that the lifted CSS expects
Deploy to K8s / deploy (push) Successful in 9s
The lifted-from-Python app.css already styles .list-container/.list-item,
.unmatched-row, .balance-pos/.balance-neg, and cell-{status}; the M6.2
template invented .credits-list / .unmatched-table / .balance-cell that
had no rules, so those sections rendered unstyled.

- Credits / Debts: <ul><li> → <div class="list-container"><div class="list-item">
  <span class="list-item-name"> + <span class="list-item-val"> (debts red inline).
- Unmatched: <table> → <div class="list-container"> + <div class="unmatched-row">.
- Balance cell: balance-pos / balance-neg with style="position: relative;";
  Pay-All button now lives inside it (no separate trailing column).
- Total row: cell-{status} + caption span "received / expected" + bold/dark inline styles.
- Drop redundant .cell wrapper class; balance value drops trailing "CZK".
- Section headings: "Credits (Advance Payments / Surplus)" + "Debts (Missing Payments)".
- Source links: <div class="description"> block under h1 (was at page bottom).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:17:03 +02:00
kacerrandClaude Opus 4.7 c85748b3aa feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)
Deploy to K8s / deploy (push) Successful in 8s
- Extract AssembleAdults(ctx) from ServeAdults so HTML and JSON API share one reconcile path.
- HTMLHandler gains *api.Handler; ServeAdults loads real data and renders adults.tmpl.
- AdultsPageData view model + qrHref/qrHrefAll funcMap (URL-encode /qr params, YYYY-MM→MM/YYYY).
- adults.tmpl: full reconcile table, per-cell status classes + cell-unpaid-current, Pay button hrefs,
  totals row, credits/debts/unmatched sections, filter controls, sheet links.
- static/js/filters.js: NFD-normalize name filter + month-range column hiding; future months hidden by default.
- TestAdultsPage asserts member name and cell text against fixture data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:09:47 +02:00
kacerr 216b5b437a Merge pull request 'feat(go): M6.1 — template skeleton, embed.FS, HTML routes' (#26) from feat/go-m6-1-template-skeleton into main
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #26
2026-05-07 22:48:22 +00:00
kacerrandClaude Opus 4.7 78e5059759 feat(go): M6.1 — template skeleton, embed.FS, HTML routes
Deploy to K8s / deploy (push) Successful in 7s
Stand up the Go-native HTML frontend foundation:
- base.tmpl layout + nav/footer partials (three-tier nav, active-link highlighting)
- terminal-green-on-black theme extracted to static/css/app.css (served via embed.FS)
- HTMLHandler with stub pages for all five routes; / redirects to /adults
- NewRenderer parses per-page template sets at startup so parse failures abort boot
- Smoke test: each route returns 200 text/html with exactly one class="active" link

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 00:45:22 +02:00
kacerrandClaude Sonnet 4.6 2f635db2b4 chore: CHANGELOG for M5.4 parity coercions (PR #25)
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:27:34 +02:00
kacerr 709a2f2335 Merge pull request 'fix(py): parity coercions — amount/message types + junior '?' sticky' (#25) from fix/py-parity-coercions into main
Deploy to K8s / deploy (push) Successful in 8s
Reviewed-on: #25
2026-05-07 22:26:39 +00:00
kacerrandClaude Sonnet 4.6 58973473c9 fix(py): make junior '?' cell text sticky across exception overrides
Deploy to K8s / deploy (push) Successful in 8s
Go's build_juniors sets cellText = "?" + countStr whenever
md.IsUnknown is true, regardless of whether an exception overrides the
expected amount. Python was checking expected == "?" for this branch,
but reconcile replaces expected with the exception amount (e.g. 0)
before the view builder runs, so the "?" was silently dropped to "-".

Fix: derive is_unknown from original_expected == "?" (set before
exception substitution) instead of expected == "?". Also align the
tooltip guard: Go only shows Received/Expected tooltip for non-unknown
months (or when paid > 0), matching the same is_unknown flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:26:04 +02:00
kacerrandClaude Sonnet 4.6 b68d95d217 fix(py): coerce amount to float and message to string in tx projection
Two remaining make parity diffs vs Go:

- amount: non-numeric sheet values like "---" passed through as strings;
  Go's parseFloat silently returns 0.0 for unparseable values. Add
  get_float helper that matches that behaviour.
- message: numeric cell values (e.g. a bank reference in the message
  column) passed through as float64; Go's getVal uses fmt.Sprint and
  always emits a string. Apply get_str to the message field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:26:04 +02:00
kacerrandClaude Sonnet 4.6 07ca1cd9e1 fix(py): coerce VS column to string in payments tx projection
Deploy to K8s / deploy (push) Successful in 7s
The Sheets API returns VS (variabilní symbol) cells as float64 when
the column is number-formatted, so Python was emitting vs: 0 (a JSON
number) while Go's getVal uses fmt.Sprint and always emits vs: "0"
(a string). Add get_str helper that converts whole-number floats via
int() first (matching Go's %g formatting), applied to the vs field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:59:35 +02:00
kacerrandClaude Sonnet 4.6 5dcac25c13 chore: CHANGELOG entry for M5.4 fix #2 (py vs/sync_id)
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:52:04 +02:00
kacerr fc47606b1c Merge pull request 'feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection' (#23) from fix/py-payments-add-vs-syncid into main
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #23
2026-05-07 21:51:12 +00:00
kacerrandClaude Sonnet 4.6 65694ad378 feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
Deploy to K8s / deploy (push) Successful in 7s
Python's fetch_sheet_data read 9 sheet columns but skipped VS and
Sync ID, causing make parity to report extra fields on every raw
payment row emitted by the Go backend. Both columns are already on
the sheet; add idx_vs / idx_sync_id lookups and the matching keys
to the tx dict so the Python /api/* wire shape matches Go's
RawTransaction.

Update /api/* test fixtures to include vs/sync_id keys for realism.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:50:33 +02:00
kacerr 092dff25a5 Merge pull request 'fix(go): accept single-digit day/month in attendance date headers' (#22) from fix/go-attendance-date-parser into main
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #22
2026-05-07 21:39:02 +00:00
kacerrandClaude Opus 4.7 56c21bcf03 fix(go): accept single-digit day/month in attendance date headers
Deploy to K8s / deploy (push) Successful in 8s
parseDates was using "02.01.2006" / "01/02/2006" which require
zero-padded fields. The Czech attendance sheet headers contain dates
like "1.6.2026", "23.3.2026", "6.4.2026" — Go silently dropped those
columns while Python's strptime accepted them. Effect was a missing
2026-06 month on /api/juniors plus undercounted attendance in any month
with single-digit columns; surfaced via make parity.

Use the unpadded reference forms "2.1.2006" / "1/2/2006" instead — Go's
time.Parse accepts both padded and unpadded inputs against them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:38:06 +02:00
kacerr 208f762c18 Merge pull request 'feat(go): M5.4 — parity diff binary + make parity' (#19) from feat/go-m5-4-parity-binary into main
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #19
2026-05-07 21:25:23 +00:00
kacerr 4d035213b5 Merge pull request 'fix(go): pass raw value to FormatDate so numeric dates format' (#21) from fix/go-date-format into main
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #21
2026-05-07 21:24:42 +00:00
kacerrandClaude Opus 4.7 2b15280d03 fix(go): exclude /api/version from parity diff — identity, not contract
Deploy to K8s / deploy (push) Successful in 7s
/api/version returns each binary's own tag/commit/build_date, which
differs by design between independently built backends. Diffing it
always produces a false positive. Drop it from allRoutes; the route
remains reachable via `make parity ARGS="-route /api/version"`.

Also remove the vestigial `build_meta` allowlist entry (Python returns
the build dict as the top-level response body, not nested under
build_meta, so the scrubber never matched anything).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:23:38 +02:00
kacerrandClaude Opus 4.7 723152cdad fix(go): pass raw value to FormatDate so numeric serial-day dates format
Deploy to K8s / deploy (push) Successful in 11s
The transaction-row parser in services/membership/sources.go used a
helper (`getVal`) that did `fmt.Sprint(row[i])` before passing to
`matching.FormatDate`.  The Sheets API returns date-formatted cells
as `float64` (Sheets serial-day numbers); pre-stringifying defeated
`FormatDate`'s `case float64:` dispatch, so values like 46147 leaked
through unchanged as the string "46147" instead of being converted
to "2026-05-05".

Surfaced by `make parity` (M5.4) — every `transactions[].date` on
/api/adults and /api/juniors differed between Python and Go.  Python
side passes the raw value through directly (`isinstance(val, (int,
float))` in scripts/match_payments.py format_date), so it was always
correct.

Added a `getRaw` helper that returns row[i] without stringifying;
only the date column needs it.  Extended TestLoadTransactions with
a numeric-serial-day row to lock in the regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:17:45 +02:00
kacerrandClaude Opus 4.7 fe0e49a134 feat(go): M5.4 — parity diff binary + make parity
Deploy to K8s / deploy (push) Successful in 8s
Adds cmd/parity/main.go: a standalone Go binary that GETs
/api/version, /api/adults, /api/juniors, /api/payments from both
the Python (:5001) and Go (:8080) backends, scrubs an allowlist
(render_time.total, build_meta), and prints cmp.Diff for any
remaining differences.  Exits 0 on full match, 1 on diffs, 2 on
fetch/parse errors — CI-friendly for M7.2.

- go/cmd/parity/main.go: flags (-py, -go, -route, -timeout), fetch
  helper, allowlist scrubber (dotted-path aware), exit-code logic.
- go/cmd/parity/scrub_test.go: 4 unit tests for the scrubber.
- go/go.mod: promote github.com/google/go-cmp to direct dep.
- Makefile: parity target + help entry.
- Progress tracker: M5.4 ticked; milestone updated to M5 complete.
- Plan archived to docs/plans/2026-05-07-2254-m5-4-parity-binary.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:08:42 +02:00
kacerr e5a272b682 Merge pull request 'fix(go): default CacheDir to tmp/go to avoid Python collision' (#20) from fix/cache-collision into main
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #20
2026-05-07 21:07:18 +00:00
kacerrandClaude Opus 4.7 8b3064ffab fix(go): default CacheDir to tmp/go to avoid Python collision
Deploy to K8s / deploy (push) Successful in 9s
Previously both backends defaulted to `CacheDir=tmp` and used the
same cache keys (`attendance_regular`, `attendance_juniors`,
`payments_transactions`, `exceptions_dict`) but stored different
shapes: Python caches post-processed view-model tuples
(e.g. `(members, sorted_months)`), Go caches raw sheet rows.
Whichever backend wrote last poisoned the cache for the other,
producing `ValueError: too many values to unpack (expected 2,
got 68)` on Python's /adults after the Go side populated the
file with 68 raw CSV rows.

This breaks the M5.4 `make parity` workflow that requires both
backends running side-by-side.

Fix: change Go's default to `tmp/go` so the two cache trees
never overlap.  `CACHE_DIR` env var override still works.
`os.MkdirAll` already handles creating the new subdirectory on
first write.

Recovery for users with poisoned `tmp/`: hit /flush-cache on
the Python side once after pulling, then restart the Go server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:06:34 +02:00
kacerr 423c3e2a4b Merge pull request 'feat(py): M5.3 — Python /api/* shadow endpoints' (#18) from feat/go-m5-3-python-api-shadow into main
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #18
2026-05-07 20:42:54 +00:00
kacerrandClaude Opus 4.7 f4c497681f chore: CHANGELOG and progress tracker for M5.3
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:37:52 +02:00
kacerrandClaude Opus 4.7 40e4a9e45e feat(py): M5.3 — add Python /api/* shadow endpoints
Four new JSON routes mirror the Go /api/* handlers so the M5.4 parity
tool can diff them: /api/version, /api/adults, /api/juniors,
/api/payments. A small _unwrap_view_model_for_api() helper in app.py
expands the three pre-serialised JSON strings in the view-model dicts
and renames month_labels_json → month_labels and
raw_payments_json → raw_payments to match the Go wire contract.

Tests in test_app.py assert top-level key sets match the Go API schema
and that member_data, month_labels, raw_payments are objects not strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:37:14 +02:00
kacerr 68810369bd Merge pull request 'feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version' (#17) from feat/go-m5-2-api-handlers into main
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #17
2026-05-07 19:08:01 +00:00
kacerrandClaude Sonnet 4.6 2b7eff14c4 chore: CHANGELOG and progress tracker for M5.2
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:02:54 +02:00
kacerrandClaude Sonnet 4.6 7d48e8f607 feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
Deploy to K8s / deploy (push) Successful in 10s
- Add web/api/handler.go: Handler struct wiring Sources+Config into ServeAdults,
  ServeJuniors, ServePayments, ServeVersion
- Add web/api/build_common.go: getMonthLabels, groupRawPaymentsByPerson, settledBalance,
  domain-to-wire converters, ensureSlice generic helper
- Add web/api/build_adults.go: buildAdultsResponse + buildAdultMemberRow mirroring
  scripts/views.py:build_adults_view_model
- Add web/api/build_juniors.go: buildJuniorsResponse + buildJuniorMemberRow mirroring
  scripts/views.py:build_juniors_view_model, including "?" sentinel and :NJ,MA breakdown
- Add web/api/build_payments.go: buildPaymentsResponse with Unmatched/Unknown bucket
- Extend reconcile.FeeData/MonthData with IsUnknown, JuniorAttendance, AdultAttendance
- Extend reconcile.Transaction with ManualFix, VS, BankID, SyncID for raw_payments wire field
- Export membership.AdultMergedMonths and JuniorMergedMonths
- Update sources.go to propagate new FeeData fields and parse extra transaction columns
- Wire sources+cfg into web.Run; register /api/* routes via Go 1.22 method+path patterns
- Fix pre-existing gofumpt formatting in fio_test.go and fio_table.go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:13:38 +02:00
kacerr be4ecef20f Merge pull request 'feat(go): M5.1 — hand-author /api/* wire types + JSON Schemas' (#16) from feat/go-m5-1-api-structs into main
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #16
2026-05-07 17:50:55 +00:00
kacerrandClaude Opus 4.7 da5b82fcdb chore: CHANGELOG and progress tracker for M5.1
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:38:00 +02:00
kacerrandClaude Opus 4.7 f253e3fcb1 feat(go): M5.1 — hand-author /api/* wire types + JSON Schemas
Deploy to K8s / deploy (push) Successful in 7s
Add internal/web/api package with Go structs for every /api/X route:
AdultsResponse, JuniorsResponse, PaymentsResponse, VersionResponse.
All fields carry explicit json: tags matching the Python view-model keys.

Key design choices:
- member_data / month_labels / raw_payments are nested objects (not
  the pre-serialised JSON strings used in Jinja templates)
- Expected{Value int; Unknown bool} with custom MarshalJSON emits int
  or the string "?" for junior single-attendance months
- RawTransaction covers the full 11-column payments sheet row

schemagen_test.go reflects all four response types via
github.com/invopop/jsonschema and golden-compares against committed
schemas in tests/fixtures/api-schema/. The JSONSchema() method on
Expected lives in the test file so the prod binary has no jsonschema
dependency.

Closes M5.1 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:36:46 +02:00
kacerrandClaude Opus 4.7 59223c0da4 chore: CHANGELOG for Python view-model extraction
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:27:17 +02:00
kacerr 32a16ff50d Merge pull request 'refactor(app): extract view-model builders into scripts/views.py' (#15) from feat/m5-python-views-extraction into main
Deploy to K8s / deploy (push) Successful in 5s
Reviewed-on: #15
2026-05-07 13:26:42 +00:00
kacerrandClaude Opus 4.7 2eec51bb34 fix(app): restore missing import re needed by qr_code route
Deploy to K8s / deploy (push) Successful in 6s
Accidentally removed when moving group_payments_by_person to views.py;
re.match in qr_code caused a 500 on every QR request.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:24:40 +02:00
kacerrandClaude Opus 4.7 b562ce3201 refactor(app): extract view-model builders into scripts/views.py
Deploy to K8s / deploy (push) Successful in 8s
Pull 350+ lines of inline per-row computation out of adults_view,
juniors_view, and payments into three pure builder functions with no
Flask globals or IO dependencies. Route handlers now contain only
cache/IO calls and a single render_template. No behaviour change —
all 27 tests pass.

Also moves get_month_labels, group_payments_by_person, and
adapt_junior_members out of app.py. Prep for /api/* shadow endpoints
(M5 Go parity).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:22:12 +02:00
kacerrandClaude Opus 4.7 f0de300292 chore: CHANGELOG for --print-fio-table, debug logging, and date parser fix
Deploy to K8s / deploy (push) Successful in 9s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:13:56 +02:00
kacerr 2164e99866 Merge pull request 'feat(go): add --print-fio-table debug flag to fuj sync' (#14) from feat/fuj-sync-print-fio-table into main
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #14
2026-05-07 12:13:19 +00:00
182 changed files with 12125 additions and 1398 deletions
+28 -1
View File
@@ -10,7 +10,34 @@
"Bash(./bin/fuj help *)",
"Bash(./bin/fuj version *)",
"Bash(make go-test *)",
"Bash(make go-lint *)"
"Bash(make go-lint *)",
"Bash(tea pr create --title 'fix\\(go\\): pass raw value to FormatDate so numeric dates format' --description ' *)",
"Bash(git checkout *)",
"Bash(go build *)",
"Bash(go test *)",
"Bash(make parity *)",
"Bash(tea pr create --title 'fix\\(go\\): accept single-digit day/month in attendance date headers' --description ' *)",
"Bash(lsof -nP -iTCP:8080 -sTCP:LISTEN)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management checkout -b fix/period-selector-restore)",
"Bash(git pull *)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log --oneline -20)",
"Bash(curl -s -o /tmp/fio-transparent.html \"https://ib.fio.cz/ib/transparent?a=2800359168\")",
"Read(//tmp/**)",
"Bash(grep -oE '[0-9]{1,2}\\\\.[0-9]{1,2}\\\\.[0-9]{2,4}' /tmp/fio-transparent.html | head -20)",
"Read(//private/tmp/**)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management status)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat HEAD)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log -8 --oneline)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management tag --sort=-v:refname)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch --show-current)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management show --stat --format='%H %s%n%nbranch?: %d' 0.33)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management for-each-ref --format='%\\(refname:short\\) %\\(objectname:short\\) %\\(subject\\)' refs/tags)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch -r --contains 0.33)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat main HEAD -- Makefile scripts/fio_utils.py scripts/sync_fio_to_sheets.py CHANGELOG.md)",
"Bash(cp /Users/jan.novak/.claude/plans/in-python-app-i-m-distributed-muffin.md /Users/jan.novak/srv/personal/fuj-management/docs/plans/2026-06-08-1110-junior-expected-fix.md)"
],
"additionalDirectories": [
"/Users/jan.novak/srv/personal/fuj-management/docs/plans"
]
}
}
+2 -2
View File
@@ -31,11 +31,11 @@ jobs:
TAG=${{ inputs.tag }}
fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -f build/Dockerfile \
docker build -f legacy/build/Dockerfile \
--build-arg GIT_TAG=$TAG \
--build-arg GIT_COMMIT=${{ github.sha }} \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-t $IMAGE .
-t $IMAGE legacy/
docker push $IMAGE
build-go:
+105
View File
@@ -0,0 +1,105 @@
name: GitOps image update
on:
# Auto-fires when "Build and Push" completes successfully (tag push).
workflow_run:
workflows: ["Build and Push"]
types: [completed]
# Manual trigger for dry-runs and one-off bumps.
workflow_dispatch:
inputs:
tag:
description: "Git tag to deploy (without the -go suffix, e.g. 0.37)"
required: true
dry_run:
description: "Dry run — print diff, do not open a PR"
type: boolean
default: false
uh_cli_version:
description: "uh-cli version override (e.g. v0.2.0). Defaults to v0.1.0."
required: false
env:
TEA_VERSION: "0.9.2"
# Resolved priority: manual input → repo/org variable → hardcoded default.
UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}
jobs:
gitops-pr:
runs-on: ubuntu-latest
# Skip if triggered by workflow_run that did not succeed.
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
container:
image: ubuntu:latest
env:
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
steps:
- name: Install git, curl, ca-certificates, jq
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git curl ca-certificates jq
- name: Install tea
run: |
curl -fsSL \
"https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
-o /usr/local/bin/tea
chmod +x /usr/local/bin/tea
- name: Install uh-cli
run: |
curl -fsSL \
"https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64" \
-o /usr/local/bin/uh-cli
chmod +x /usr/local/bin/uh-cli
- name: Resolve image tag
id: resolve
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
else
# workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
# Look up the tag name that points to the triggering commit SHA via the API.
SHA="${{ github.event.workflow_run.head_sha }}"
GIT_TAG=$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.home.hrajfrisbee.cz/api/v1/repos/${{ github.repository }}/tags?limit=50" \
| jq -r --arg sha "$SHA" '.[] | select(.commit.sha == $sha) | .name')
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "Resolved image: ${IMAGE}"
- name: Configure git identity and credentials
run: |
git config --global user.name "uh-cli bot"
git config --global user.email "bot@hrajfrisbee.cz"
# Store credentials separately so the --git-repo URL stays clean.
# Tea matches the login URL against the remote URL; embedded credentials
# break that matching and cause "path segment [0] is empty" on pr create.
git config --global credential.helper store
echo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz" >> ~/.git-credentials
- name: Authenticate tea
run: |
tea login add \
--name ci \
--url https://gitea.home.hrajfrisbee.cz \
--token "$GITEA_TOKEN"
- name: Open image-update PR (or dry run)
run: |
set -x
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "${{ steps.resolve.outputs.image }}" \
--git-repo "https://gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}
-111
View File
@@ -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/
+1
View File
@@ -7,3 +7,4 @@ tmp/
# go build output
bin/
go/parity
+5 -3
View File
@@ -2,14 +2,16 @@
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Flask",
"name": "Python Debugger: Flask (legacy)",
"type": "debugpy",
"request": "launch",
"module": "flask",
"python": "${workspaceFolder}/.venv/bin/python",
"python": "${workspaceFolder}/legacy/.venv/bin/python",
"cwd": "${workspaceFolder}/legacy",
"env": {
"FLASK_APP": "app.py",
"FLASK_DEBUG": "1"
"FLASK_DEBUG": "1",
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
},
"args": [
"run",
+262 -1
View File
@@ -1,5 +1,266 @@
# 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.
- `CLAUDE.md` rewritten for the Go-first layout, with a "Dual implementation rule" section.
- Makefile Python targets keep their names and point at `legacy/`; virtualenv is `legacy/.venv`, Python cache is `legacy/tmp/`; `CREDENTIALS_PATH` is exported pointing at the root `.secret/` file.
- CI builds the Python image with `legacy/` as context; the image no longer contains the Makefile. `legacy/.dockerignore` now takes effect and excludes nested `__pycache__`.
- Go comments, fixture README, capture script and VS Code launch config updated to the new paths.
## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow
- Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag.
- Supports `workflow_run` auto-trigger and `workflow_dispatch` with `dry_run` / `uh_cli_version` inputs.
- Requires `GITOPS_TOKEN` repo secret (Gitea PAT with write+PR access to `home-kubernetes`).
## 2026-05-24 21:58 CEST — feat(fees): update adult monthly rates for 2026-05 through 2026-08
- 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).
- Mirrored in both `scripts/attendance.py` and `go/internal/domain/fees/fees.go`.
## 2026-05-24 21:42 CEST — feat: multi-account Fio sync + switch QR default to 2502035405/2010
- Added second bank account `2502035405/2010` (IBAN `CZ0820100000002502035405`) to sync.
- Both accounts are fetched on every sync; dedup by existing sync_id keeps the payments sheet clean.
- QR codes now default to the new account (`CZ0820100000002502035405`).
- Go: `config.go` gains hardcoded `Accounts`/`LoadedAccount` slice; `Config.BankAccount` renamed to `Config.QRAccount`; `FioAPIToken` removed (tokens are per-account via `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
- Go: `SyncToSheets` now accepts `[]fio.Client`; new `TestSyncToSheets_MultiAccount` test.
- Python: `config.py` gains `ACCOUNTS` / `LOADED_ACCOUNTS`; `fio_utils.py` adds `fetch_transactions_for` and `fetch_transactions_all`; `sync_fio_to_sheets.py` uses `fetch_transactions_all`.
- Key files: `go/internal/config/config.go`, `go/internal/services/banksync/sync.go`, `go/cmd/fuj/main.go`, `scripts/config.py`, `scripts/fio_utils.py`, `scripts/sync_fio_to_sheets.py`.
## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations
- Multi-month payment allocation now fills the earliest in-window deficit first and spills
any remainder to later months, accounting for prior transactions' contributions to each month.
Previously a single transaction was split proportionally to each month's total expected fee,
ignoring what earlier transactions had already paid — surfaced by Matyáš Thér's 200+550 case
showing 566/183 instead of 500/250.
- Files: `scripts/match_payments.py`, `go/internal/domain/reconcile/reconcile.go`, tests, parity fixtures.
## 2026-05-11 22:56 CEST — fix(python): parse Fio 2-digit-year dates + add `make sync-debug` dry-run tool
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths).
- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime.
- Key files: `go/internal/web/assets_test.go` (new).
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
- Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG).
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
- Implemented `GET /sync-bank`: runs Fio sync → infer payments → cache flush, captures output into `sync.tmpl` page with success/error banner.
- Implemented `GET /flush-cache` + `POST /flush-cache`: form + action that deletes cache files and shows deleted count.
- Added `GET /version` as a JSON alias of `GET /api/version` (Python parity).
- Added `FlushCache() (int, error)` to `membership.Sources` interface; implemented on `realSources` via `cache.FileCache.Flush()`.
- Introduced `web.ActionHandlers{BankSync}` — closure-based dep injection for sync, constructed in `serverCmd` with fio + sheets clients.
- New dependency: `github.com/skip2/go-qrcode`.
- Key files: `go/internal/web/qr.go`, `go/internal/web/html_handler.go`, `go/internal/web/server.go`, `go/internal/services/membership/loader.go`, `go/internal/web/templates/sync.tmpl`, `go/internal/web/templates/flush_cache.tmpl`.
## 2026-05-08 13:19 CEST — feat(go): M6.5 — member-detail modal JS module
- Added `static/js/member-detail.js`: fetches `/api/adults` or `/api/juniors` once on page load, caches the response, renders a per-member detail modal on `[i]` row click.
- Modal sections: status-per-month table, fee exceptions, other transactions, matched payment history, toggleable raw-payments debug view.
- Keyboard nav: `Esc` closes, `↑`/`↓` walk visible (name-filtered) rows; click outside modal content closes it.
- Added `[i]` info icon and `#memberModal` markup to `adults.tmpl` and `juniors.tmpl`; all CSS was already in place from M6.1.
- Added `TestModalMarkup` assertions to `html_handler_test.go`.
## 2026-05-08 12:48 CEST — feat(go): M6.4 — Go-native /payments page
- Extracted `AssemblePayments(ctx)` from the inlined JSON handler body, following the M6.2/M6.3 `AssembleAdults`/`AssembleJuniors` pattern.
- Added `PaymentsPageData` to `render.go`; wired `HTMLHandler.ServePayments` to call `AssemblePayments` and render the new template.
- Replaced the "Coming in M6.4" placeholder in `payments.tmpl` with the full grouped-by-person ledger: alphabetical `<h2>` member blocks, each with a `txn-table` (Date / Amount / Purpose / Bank Message), newest-first rows, `"Unmatched / Unknown"` bucket.
- Appended ledger CSS classes to `app.css` (`.ledger-container`, `.member-block`, `.txn-table`, `.txn-{date,amount,purpose,message}`, `tr:hover`).
- Added `TestPaymentsPage` markup test. No filters, no JS — matches the Python page.
## 2026-05-08 11:25 CEST — feat(go): M6.3 — Go-native /juniors page
- Extracted `AssembleJuniors(ctx)` from the JSON handler so HTML and JSON share one view-model path (mirrors `AssembleAdults`).
- Added `JuniorsPageData` to `render.go`; wired `HTMLHandler.ServeJuniors` to call `AssembleJuniors` and render the new template.
- Replaced the 4-line "Coming in M6.3" placeholder in `juniors.tmpl` with the full template: member table, name/month-range filters, totals row, Credits + Debts sections, Pay / Pay All links to `/qr`. No Unmatched section (parity with Python).
- J/A attendance breakdown and `"?"` sentinel rendered via `MonthCell.Text` produced by `buildJuniorMemberRow` — no extra template logic.
- `make parity` reports 3/3 routes OK; all Go tests pass.
## 2026-05-08 11:11 CEST — fix: period selector showed only Dec 2025+ on adults
- Restored the `2025-09 → 2025-10` adult merge in `scripts/attendance.py` and `go/internal/services/membership/sources.go` (commented-out by `1257f0d`); the `2025-12 → 2026-01` mapping stays disabled per product decision (Dec and Jan are billed separately for adults).
- Dropped the `defaultFrom = maxMonthIdx − 4` JS auto-default in `templates/adults.html` and `templates/juniors.html` (introduced by `7774301`); the From-selector now starts at the oldest available month so all non-future periods render on first load. Future-month removal is preserved.
- `go/internal/services/membership/sources_test.go`: `TestLoadAdults` / `TestLoadAdults_Fee` now assert that Sep dates land in the merged `2025-10` bucket.
- **Independent of these code changes**: the live adults attendance Google Sheet header had been pruned to start at `02.12.2025` (Sep/Oct/Nov 2025 columns deleted); restoring those columns from Sheets version history is required to actually see those periods on the dashboard.
## 2026-05-08 10:15 CEST — fix(go): adults template — use lifted CSS classes for visual parity
- Use existing `.balance-pos` / `.balance-neg` (drop invented `balance-cell` / `balance-negative`); Pay-All button now lives inside the balance cell with `position: relative` (matches Python; no separate trailing column).
- Credits / Debts / Unmatched sections rewritten from `<ul>` / `<table>` to `<div class="list-container"><div class="list-item">…` / `<div class="unmatched-row">…` so the lifted CSS actually applies.
- Section headings get the descriptive Python text: "Credits (Advance Payments / Surplus)", "Debts (Missing Payments)".
- Source links moved from page bottom to a `<div class="description">` block under the h1, matching Python's "Source: Attendance Sheet | Payments Ledger".
- Total row uses `cell-{{Status}}` plus the small "received / expected" caption span and Python's inline-styled bold/dark background.
- Drop the redundant `class="cell"` wrapper; debts amount turns red via inline style; balance value drops the trailing "CZK".
## 2026-05-08 01:09 CEST — feat(go): M6.2 — adults page (table, filters, Pay buttons)
- `go/internal/web/api/handler.go`: extracted `ServeAdults` body into `AssembleAdults(ctx)` — shared by the JSON API route and the new HTML handler.
- `go/internal/web/render.go`: added `AdultsPageData` view model (`PageData` + `api.AdultsResponse` + `Error`); `tmplFuncs` with `qrHref` / `qrHrefAll` (URL-encode QR Platba params, convert YYYY-MM → MM/YYYY).
- `go/internal/web/html_handler.go`: `HTMLHandler` gains `*api.Handler`; `ServeAdults` loads real reconcile data and renders the full adults page.
- `go/internal/web/templates/adults.tmpl`: full table (per-member rows, per-cell status classes, `data-month-idx`, Pay button hrefs to `/qr`), totals row, credits/debts/unmatched sections, filter controls, sheet links.
- `go/internal/web/static/js/filters.js`: name filter (NFD-normalize) + month-range hide/show by `data-month-idx`; future months hidden by default.
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
- `go/internal/web/static/css/app.css`: terminal-green-on-black theme extracted once from Python `templates/adults.html` — shared by all Go HTML pages via `<link>`.
- `go/internal/web/assets.go`: `//go:embed templates static` for single-binary deployment.
- `go/internal/web/render.go`: `Renderer` parses a fresh `*template.Template` per page at startup; `Render(w, name, data)` executes the "base" template block.
- `go/internal/web/html_handler.go`: `HTMLHandler` with one method per route (`ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeSync`, `ServeFlushCache`).
- `go/internal/web/server.go`: drops `helloHandler`; `GET /{$}` now redirects to `/adults`; HTML + `/static/` routes registered alongside the existing `/api/*` routes.
- `go/internal/web/html_handler_test.go`: smoke test — each route returns 200 `text/html` with exactly one `class="active"` on the matching nav link.
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.
- `scripts/views.py`: junior month cell `"?"` text is now sticky across exception overrides. Previously `reconcile` replaced `expected` with the exception amount before the view builder ran, silently turning `"?"` into `"-"` when the override was 0. Fixed by deriving `is_unknown` from `original_expected == "?"` instead of `expected == "?"`. Also aligned tooltip guard: only show Received/Expected for non-unknown months (or when paid > 0), matching Go's `!md.IsUnknown` condition.
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person` → `_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
- `sources_test.go::TestParseDates_SingleDigitDayMonth` added as a regression guard covering both Czech and US format flavours with and without leading zeros.
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
- Why: both backends used `tmp/<key>_cache.json` with the same keys (`attendance_regular`, `attendance_juniors`, `payments_transactions`, `exceptions_dict`) but different shapes — Python caches post-processed view-model tuples, Go caches raw rows. Whichever wrote last poisoned the cache; running both in parallel produced `ValueError: too many values to unpack (expected 2, got 68)` on Python's `/adults` after the Go server populated `attendance_regular_cache.json` with raw CSV rows.
- After upgrading: stop the Go server, hit `/flush-cache` on the Python side once (rewrites `tmp/*.json` with correct shapes), then restart `make web-go` — it will use `tmp/go/` going forward. Required for the M5.4 `make parity` workflow which assumes both backends run side-by-side.
## 2026-05-07 22:55 CEST — feat(go): M5.4 — parity diff binary + `make parity`
- `go/cmd/parity/main.go`: new standalone binary that GETs `/api/adults`, `/api/juniors`, `/api/payments` from both Python (:5001) and Go (:8080) backends, scrubs an allowlist (`render_time.total`), and prints `cmp.Diff` for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. `/api/version` is excluded by design (returns binary identity — tag/commit/build_date — which differs between independently built backends); still accessible via `make parity ARGS="-route /api/version"`.
- `go/cmd/parity/scrub_test.go`: 4 unit tests covering top-level delete, nested delete, missing path, and non-map parent.
- `go/go.mod`: `github.com/google/go-cmp` promoted to direct dependency.
- `Makefile`: `parity` target added (`.PHONY`, help, `cd go && go run ./cmd/parity`).
- `docs/plans/2026-05-07-2254-m5-4-parity-binary.md`: plan archived.
## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints
- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` to match Go wire contract.
- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings).
## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`.
- `web/api/build_{adults,juniors,payments,common}.go`: ports of `scripts/views.py` view-model builders; `buildJuniorMemberRow` handles `"?"` sentinel, `:NJ,MA` breakdown, unknown-month skip.
- Extended `reconcile.FeeData`/`MonthData` with `IsUnknown`, `JuniorAttendance`, `AdultAttendance`; `Transaction` with `ManualFix`, `VS`, `BankID`, `SyncID`.
- `sources.go` exports `AdultMergedMonths`/`JuniorMergedMonths`; parses new FeeData and transaction columns.
- `web/server.go` + `cmd/fuj/main.go` wired to register `/api/*` routes.
- PR #17.
## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
- `Expected{Value int; Unknown bool}` custom `MarshalJSON` emits integer or `"?"` for junior single-attendance months.
- `schemagen_test.go` golden-tests four JSON Schemas committed to `go/tests/fixtures/api-schema/`. `JSONSchema()` on `Expected` lives in the test file — production binary has no jsonschema dep.
- PR #16.
## 2026-05-07 15:26 CEST — refactor(app): extract view-model builders into scripts/views.py
- Pulled ~350 lines of inline per-row computation out of `adults_view`, `juniors_view`, and `payments` into three pure functions in `scripts/views.py`: `build_adults_view_model`, `build_juniors_view_model`, `build_payments_view_model`.
- Moved `get_month_labels`, `group_payments_by_person`, `adapt_junior_members` from `app.py` to `scripts/views.py`. Route handlers now ~25 lines each.
- Hotfixed missing `import re` that caused 500 on `/qr` after the refactor.
- No behaviour change; all 27 tests pass. Prep for `/api/*` shadow endpoints (M5).
## 2026-05-07 14:13 CEST — feat(go): --print-fio-table + Fio debug logging + date parser fix
- Added `--print-fio-table` flag to `fuj sync --dry-run`: prints an aligned table of every Fio transaction in the window with `STATUS=NEW/DUP`, using `text/tabwriter`. Key files: `go/internal/services/banksync/fio_table.go`, `sync.go`, `cmd/fuj/main.go`.
- Added `LOG_LEVEL=DEBUG` debug logging on the Fio fetch path: client variant selected, full GET URL (token redacted on API path), HTTP status, body bytes, and per-parse drop-reason counters (`raw_rows`, `kept`, `dropped_bad_date`, `dropped_nonpositive_amount`). Key files: `go/internal/io/fio/{client,api,transparent}.go`.
- Fixed `parseCzechDate` to accept `DD.MM.YY` (2-digit year) in addition to the 4-digit variant — Fio's transparent page now serves this format. Key file: `go/internal/io/fio/transparent.go`.
- Added `make go-sync-debug [DAYS=N]` Makefile target (default 30 days).
## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync
- `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`.
@@ -40,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
+108 -27
View File
@@ -4,41 +4,92 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
- **Go is the primary implementation** (`go/`). It is the version that is built, deployed and developed first.
- **Python is legacy** (`legacy/`). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on.
## Dual implementation rule
Until the Python version is retired, **every new feature and every behaviour change must be implemented in both Go and Python**:
1. Implement and test in Go first (`go/`).
2. Mirror the change in the legacy Python code (`legacy/`), including its tests.
3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (`/api/adults`, `/api/juniors`, `/api/payments`) must stay identical. The HTML frontends are allowed to diverge.
4. If a pure function's behaviour changes, add or update seeds in `legacy/scripts/_fixture_seeds.py`, run `make capture-fixtures`, and make sure `make go-test-all` passes. With both servers running, `make parity` diffs the live JSON APIs.
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
### Deliberately Go-only features
These exist only in the Go app on purpose. Do not port them to the legacy Python app, which is being phased out:
- **Scheduled bank sync** — the Go server runs sync → infer → flush-cache by itself at the times in `SYNC_SCHEDULE`. Python's multi-worker gunicorn setup would need a cross-process lock for this, which is not worth building in code that is going away. In Python the sync still runs only from `/sync-bank` or the CLI.
## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
- 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
This project uses `uv` for dependency management.
Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
```bash
uv venv && uv sync
source .venv/bin/activate
make go-build # builds bin/fuj
```
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically).
Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`.
```bash
make venv # uv sync --project legacy → legacy/.venv
```
The Makefile sets `PYTHONPATH` (`legacy/scripts:legacy`) and `CREDENTIALS_PATH` (`.secret/fuj-management-bot-credentials.json` at the repo root) for the Python targets. Set both yourself when running Python scripts directly.
## Commands
Go (primary):
```bash
make web # Start dashboard at http://localhost:5001
make web-go # Build and start dashboard at http://localhost:8080
make go-build # Build bin/fuj
make go-test # Unit tests (go test -race ./...)
make go-parity # Parity tests against the captured Python fixtures
make go-test-all # Both of the above
make go-lint # golangci-lint
make go-run ARGS="fees" # Run a subcommand: server, fees, reconcile, sync, infer, version
make go-sync-debug [DAYS=N] # Dry-run bank sync with debug logs
```
Python (legacy):
```bash
make web-py # Start dashboard at http://localhost:5001 (make web is an alias)
make web-debug # Same with FLASK_DEBUG=1
make 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 Docker image
make image # Build the legacy Python Docker image
```
Cross-implementation:
```bash
make capture-fixtures # Regenerate go/tests/fixtures from the Python implementation
make parity # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running
```
Run a single test:
```bash
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route
cd go && go test ./internal/domain/reconcile -run TestName
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route
```
## Architecture
@@ -46,47 +97,77 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
### Data flow
```
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/
Google Sheets (payments) ──► match_payments.py ──┘
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments)
Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
Google Sheets (payments) ──► payments loader ──┘
Fio Bank API ──► bank sync ──► Google Sheets (payments)
```
### Key modules
### Repository layout
- `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`
- `scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
- `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
- `scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `tmp/`.
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
- `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
- `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
- `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
- `docs/` — documentation; `docs/plans/` holds plans and task lists.
- `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
### Key modules — Go (primary)
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`, `SYNC_SCHEDULE`, `SYNC_TZ`).
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference; `Runner` serialises manual and scheduled runs).
- `go/internal/scheduler` — runs a job at fixed times of day; used by `fuj server` for the scheduled bank sync.
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
### Key modules — Python (legacy)
- `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*`
- `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
- `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
- `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`.
- `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
- `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
- `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API.
- `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`).
- `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go.
### Member tiers
Tiers are set in column B of the attendance sheet:
- `A` — Adult, pays fees (per-month rate from `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
`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `attendance.py` map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
`AdultMergedMonths` / `JuniorMergedMonths` in `go/internal/services/membership/sources.go` (`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `legacy/scripts/attendance.py`) map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
### Caching
`get_cached_data()` in `app.py` checks the Drive API `modifiedTime` before each request and serves a JSON file from `tmp/` when the sheet hasn't changed. Cache is warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST) or `flush_cache()`.
Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
### 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 `infer_payments.py` and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
`Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
**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
+45 -28
View File
@@ -1,26 +1,30 @@
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures 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
export PYTHONPATH := scripts:$(PYTHONPATH)
VENV := .venv
PYTHON := $(VENV)/bin/python3
CREDENTIALS := .secret/fuj-management-bot-credentials.json
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
LEGACY := legacy
export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
VENV := $(LEGACY)/.venv
PYTHON := $(CURDIR)/$(VENV)/bin/python3
CREDENTIALS := $(CURDIR)/.secret/fuj-management-bot-credentials.json
# Both implementations read CREDENTIALS_PATH; the Python default would resolve under legacy/.
export CREDENTIALS_PATH ?= $(CREDENTIALS)
GO_SRC := go
GO_BIN := bin/fuj
$(PYTHON): .venv/.last_sync
$(PYTHON): $(VENV)/.last_sync
.venv/.last_sync: pyproject.toml
uv sync
touch .venv/.last_sync
$(VENV)/.last_sync: $(LEGACY)/pyproject.toml
uv sync --project $(LEGACY)
touch $(VENV)/.last_sync
help:
@echo "Available targets:"
@echo " make fees - Calculate monthly fees from the attendance sheet"
@echo " make match - Match Fio bank payments against expected attendance fees"
@echo " make web - Start Python dashboard (alias for web-py, until M8)"
@echo " make web-py - Start Python dashboard on :5001"
@echo " make web-go - Build and start Go dashboard on :8080"
@echo " make web - Start legacy Python dashboard (alias for web-py)"
@echo " make web-py - Start legacy Python dashboard on :5001"
@echo " make web-go - Build and start Go dashboard on :8080 (primary)"
@echo " make web-debug - Start Python dashboard in debug mode"
@echo " make go-build - Build Go binary to bin/fuj"
@echo " make go-test - Run Go unit tests"
@@ -29,11 +33,14 @@ help:
@echo " make go-lint - Run golangci-lint on Go code"
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
@echo " make image - Build Python OCI container image"
@echo " make run - Run the built Python Docker image locally"
@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"
@echo " make venv - Sync virtual environment with pyproject.toml"
@@ -42,21 +49,21 @@ help:
@echo " make docs - Serve documentation in a browser"
venv:
uv sync
uv sync --project $(LEGACY)
fees: $(PYTHON)
$(PYTHON) scripts/calculate_fees.py
$(PYTHON) $(LEGACY)/scripts/calculate_fees.py
match: $(PYTHON)
$(PYTHON) scripts/match_payments.py
$(PYTHON) $(LEGACY)/scripts/match_payments.py
web: web-py
web-py: $(PYTHON)
$(PYTHON) app.py
$(PYTHON) $(LEGACY)/app.py
web-debug: $(PYTHON)
FLASK_DEBUG=1 $(PYTHON) app.py
FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
go-build:
cd $(GO_SRC) && go build -trimpath \
@@ -80,10 +87,10 @@ capture-fixtures: $(PYTHON)
infer_transaction_details format_date reconcile; do \
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
mkdir -p "$$dir"; \
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \
$(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \
| while IFS= read -r line; do \
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
echo "$$line" | $(PYTHON) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
done; \
echo " $$func done"; \
done
@@ -102,36 +109,46 @@ go-lint:
web-go: go-build
./$(GO_BIN) server
parity:
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
image:
docker build -t fuj-management:latest \
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-f build/Dockerfile .
-f $(LEGACY)/build/Dockerfile $(LEGACY)
run:
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
sync: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS)
sync-2025: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2025-10-01 --to 2025-12-31 --sort-by-date
sync-2026: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
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
infer: $(PYTHON)
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
$(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
reconcile: ## Match payments against attendance
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
reconcile: $(PYTHON) ## Match payments against attendance
$(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
test: $(PYTHON) ## Run web application tests
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests
cd $(LEGACY) && $(PYTHON) -m unittest discover tests
test-v: $(PYTHON) ## Run tests with verbose output
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests
docs: ## Serve documentation locally
@echo "Starting documentation server at http://localhost:8000"
-626
View File
@@ -1,626 +0,0 @@
import sys
from pathlib import Path
from datetime import datetime
import re
import time
import os
import io
import qrcode
import logging
from flask import Flask, render_template, g, send_file, request
# Configure logging, allowing override via LOG_LEVEL environment variable
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(asctime)s - %(name)s:%(filename)s:%(lineno)d [%(funcName)s] - %(levelname)s - %(message)s')
# Add scripts directory to path to allow importing from it
scripts_dir = Path(__file__).parent / "scripts"
sys.path.append(str(scripts_dir))
from config import (
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
BANK_ACCOUNT, CREDENTIALS_PATH,
)
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key
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 infer_payments import infer_payments
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
mod_time = get_sheet_modified_time(cache_key)
if mod_time:
cached = read_cache(cache_key, mod_time)
if cached is not None:
return deserialize(cached) if deserialize else cached
data = fetch_func(*args, **kwargs)
if mod_time:
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
return data
def get_month_labels(sorted_months, merged_months):
labels = {}
for m in sorted_months:
dt = datetime.strptime(m, "%Y-%m")
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
merged_in = sorted([k for k, v in merged_months.items() if v == m])
if merged_in:
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
years = {d.year for d in all_dts}
if len(years) > 1:
parts = [d.strftime("%b %Y") for d in all_dts]
labels[m] = "+".join(parts)
else:
parts = [d.strftime("%b") for d in all_dts]
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
else:
labels[m] = dt.strftime("%b %Y")
return labels
def group_payments_by_person(transactions, member_names=None):
canonical_by_key = (
{canonical_member_key(n): n for n in member_names} if member_names else {}
)
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
continue
for p in person.split(","):
p = re.sub(r"\[\?\]\s*", "", p).strip()
if not p:
continue
key = canonical_by_key.get(canonical_member_key(p), p)
grouped.setdefault(key, []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
return grouped
def warmup_cache():
"""Pre-fetch all cached data so first request is fast."""
logger = logging.getLogger(__name__)
logger.info("Warming up cache...")
credentials_path = CREDENTIALS_PATH
get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
get_cached_data("exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
logger.info("Cache warmup complete.")
app = Flask(__name__)
import json as _json
_meta_path = Path(__file__).parent / "build_meta.json"
BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
"tag": "dev", "commit": "local", "build_date": ""
}
warmup_cache()
@app.before_request
def start_timer():
g.start_time = time.perf_counter()
g.steps = []
def record_step(name):
g.steps.append((name, time.perf_counter()))
@app.context_processor
def inject_render_time():
def get_render_time():
total = time.perf_counter() - g.start_time
breakdown = []
last_time = g.start_time
for name, timestamp in g.steps:
duration = timestamp - last_time
breakdown.append(f"{name}:{duration:.3f}s")
last_time = timestamp
# Add remaining time as 'render'
render_duration = time.perf_counter() - last_time
breakdown.append(f"render:{render_duration:.3f}s")
return {
"total": f"{total:.3f}",
"breakdown": " | ".join(breakdown)
}
return dict(get_render_time=get_render_time, build_meta=BUILD_META)
@app.route("/")
def index():
# Redirect root to /adults for convenience while there are no other apps
return '<meta http-equiv="refresh" content="0; url=/adults" />'
@app.route("/flush-cache", methods=["GET", "POST"])
def flush_cache_endpoint():
if request.method == "GET":
return render_template("flush-cache.html")
deleted = flush_cache()
return render_template("flush-cache.html", flushed=True, deleted=deleted)
@app.route("/sync-bank")
def sync_bank():
import contextlib
output = io.StringIO()
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(
spreadsheet_id=PAYMENTS_SHEET_ID,
credentials_path=CREDENTIALS_PATH,
date_from_str="2026-01-01",
date_to_str="2026-12-31",
sort_by_date=True,
)
output.write("\n=== Inferring payment details ===\n")
infer_payments(PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
output.write("\n=== Flushing cache ===\n")
deleted = flush_cache()
output.write(f"Deleted {deleted} cache files.\n")
output.write("\n=== Done ===\n")
except Exception as e:
import traceback
output.write(f"\n!!! Error: {e}\n")
output.write(traceback.format_exc())
success = False
return render_template("sync.html", output=output.getvalue(), success=success)
@app.route("/version")
def version():
return BUILD_META
@app.route("/adults")
def adults_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = CREDENTIALS_PATH
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
record_step("fetch_members")
if not members_data:
return "No data."
members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
record_step("fetch_exceptions")
result = reconcile(members, sorted_months, transactions, exceptions)
record_step("reconcile")
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
current_month = datetime.now().strftime("%Y-%m")
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
formatted_results = []
for name in adult_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
expected = mdata.get("expected", 0)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
else:
is_overridden = False
fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected > 0:
amount_to_pay = max(0, expected - paid)
if paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
else:
cell_text = "-"
amount_to_pay = 0
if expected > 0 or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status
})
def settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"])
unmatched = result["unmatched"]
import json
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members])
record_step("process_data")
return render_template(
"adults.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
totals=formatted_totals,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
raw_payments_json=json.dumps(raw_payments_by_person),
credits=credits,
debts=debts,
unmatched=unmatched,
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=BANK_ACCOUNT,
current_month=current_month
)
@app.route("/juniors")
def juniors_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = CREDENTIALS_PATH
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
record_step("fetch_junior_members")
if not junior_members_data:
return "No data."
junior_members, sorted_months = junior_members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
record_step("fetch_exceptions")
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
# to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
adapted_members = []
for name, tier, fees_dict in junior_members:
adapted_fees = {}
for m, fee_data in fees_dict.items():
if len(fee_data) == 4:
fee, total_count, _, _ = fee_data
adapted_fees[m] = (fee, total_count)
else:
fee, count = fee_data
adapted_fees[m] = (fee, count)
adapted_members.append((name, tier, adapted_fees))
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
record_step("reconcile")
# Format month labels
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
junior_names = sorted([name for name, tier, _ in adapted_members])
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
current_month = datetime.now().strftime("%Y-%m")
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
formatted_results = []
for name in junior_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
expected = mdata.get("expected", 0)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
if expected != "?" and isinstance(expected, int):
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
orig_fee_data = junior_members_dict.get(name, {}).get(m)
adult_count = 0
junior_count = 0
if orig_fee_data and len(orig_fee_data) == 4:
_, _, adult_count, junior_count = orig_fee_data
breakdown = ""
if adult_count > 0 and junior_count > 0:
breakdown = f":{junior_count}J,{adult_count}A"
elif junior_count > 0:
breakdown = f":{junior_count}J"
elif adult_count > 0:
breakdown = f":{adult_count}A"
count_str = f" ({count}{breakdown})" if count > 0 else ""
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
else:
is_overridden = False
fee_display = f"{expected} CZK{count_str}"
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected == "?" or (isinstance(expected, int) and expected > 0):
if expected == "?":
status = "empty"
cell_text = f"?{count_str}"
elif paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
amount_to_pay = expected - paid
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
amount_to_pay = expected
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
if (isinstance(expected, int) and expected > 0) or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status
})
# Format credits and debts
def junior_settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
junior_all_names = [name for name, _, _ in adapted_members]
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
unmatched = result["unmatched"]
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members])
import json
record_step("process_data")
return render_template(
"juniors.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
totals=formatted_totals,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
raw_payments_json=json.dumps(raw_payments_by_person),
credits=credits,
debts=debts,
unmatched=unmatched,
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=BANK_ACCOUNT,
current_month=current_month
)
@app.route("/payments")
def payments():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = CREDENTIALS_PATH
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
member_names = []
if adults_data:
member_names.extend(name for name, _, _ in adults_data[0])
if juniors_data:
member_names.extend(name for name, _, _ in juniors_data[0])
grouped = group_payments_by_person(transactions, member_names)
# payments page also groups unmatched rows under a fallback key
for tx in transactions:
if not str(tx.get("person", "")).strip():
grouped.setdefault("Unmatched / Unknown", []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
sorted_people = sorted(grouped.keys())
record_step("process_data")
return render_template(
"payments.html",
grouped_payments=grouped,
sorted_people=sorted_people,
attendance_url=attendance_url,
payments_url=payments_url
)
@app.route("/qr")
def qr_code():
account = request.args.get("account", BANK_ACCOUNT)
amount = request.args.get("amount", "0")
message = request.args.get("message", "")
# Validate account: allow IBAN (letters+digits) or Czech format (digits/digits)
if not re.match(r'^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$', account):
account = BANK_ACCOUNT
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
acc_parts = account.split('/')
if len(acc_parts) == 2:
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
else:
acc_str = account
try:
amt_val = float(amount)
if amt_val < 0 or amt_val > 10_000_000:
amt_val = 0
amt_str = f"{amt_val:.2f}"
except ValueError:
amt_str = "0.00"
# Message max 60 characters, strip SPD delimiters to prevent injection
msg_str = message[:60].replace("*", "")
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
img = qrcode.make(qr_data)
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return send_file(buf, mimetype='image/png')
if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=5001)
+2
View File
@@ -4,6 +4,8 @@ Welcome to the documentation for the FUJ Management application.
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
> **Go is the primary implementation** (`go/`). The project was originally developed in Python; that version now lives in `legacy/` and is considered legacy. New features are still built in both. Most guides below predate this change and describe the Python version with its old paths (`app.py`, `scripts/` are now `legacy/app.py`, `legacy/scripts/`).
## Navigation
Use the sidebar to explore the documentation:
+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,9 +2,9 @@
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
**Current milestone:** M4 — IO layer behind interfaces ✅
**Current milestone:** M6 — Go-native HTML frontend
**Started:** 2026-05-04
**Last updated:** 2026-05-07
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
## How to use
@@ -97,10 +97,10 @@ Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narr
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
- [ ] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/`
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
- [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
- [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e`
- [x] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
@@ -110,13 +110,14 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
- [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` — `c85748b`
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge)
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
@@ -154,6 +155,7 @@ Goal: Go is the one true backend.
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
- 2026-05-07 — `/api/version` excluded from parity diff by design. Each binary's tag/commit/build_date is identity, not a data contract — diffing it would always flag a diff between independently built backends. Route remains reachable via `make parity ARGS="-route /api/version"` for manual inspection.
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
@@ -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,185 @@
# Python view-model cleanup (M5 prep — Python side only)
Scoped-down precursor to M5 of the Go rewrite. See:
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md)
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md)
## Context
The Python app is still production. Before adding `/api/X` shadow
routes, JSON DTOs, parity tooling, and Go handlers, get the Python
side into a clean shape. Today the `/adults` and `/juniors` routes
each carry 150–200 lines of inline view-model construction inside
the Flask handler:
- [adults_view](app.py#L179) — 167 LOC, computes per-row
status/cell_text/balance/credits/debts inline.
- [juniors_view](app.py#L347) — 205 LOC, same plus `"?"` sentinel
branching and `:NJ,MA` breakdown.
- [payments](app.py#L553) — 35 LOC, lighter but still mixes IO and
grouping.
Pulling that into pure builder functions:
- shrinks `app.py` and makes each route handler do only what a route
handler should (IO + cache + step timing + render);
- gives us **already-tested** pure functions that future M5 work can
call from a `/api/X` shadow endpoint with one line of `jsonify(...)`;
- has zero behavioural change (existing tests in
[test_app.py](tests/test_app.py) act as the regression guard).
This is a Python-only change. No Go work, no JSON contract, no shadow
routes — those come after.
## Approach
Three pure builder functions in a new `scripts/views.py` module. Each
takes already-loaded, deserialized inputs (no Flask globals, no IO,
no cache calls) and returns the exact dict that today's route passes
to `render_template`.
```python
def build_adults_view_model(
members, sorted_months, transactions, exceptions, current_month,
*, attendance_url, payments_url, bank_account,
) -> dict: ...
def build_juniors_view_model(
junior_members, sorted_months, transactions, exceptions, current_month,
*, attendance_url, payments_url, bank_account,
) -> dict: ...
def build_payments_view_model(
transactions, member_names,
*, attendance_url, payments_url,
) -> dict: ...
```
The route handlers shrink to: load data (with `get_cached_data` and
`record_step` calls staying in `app.py`), call the builder, render.
```python
@app.route("/adults")
def adults_view():
members, sorted_months = ... # cached loads + record_step calls
transactions = ...
exceptions = ...
result_meta = ...
record_step("process_data")
vm = build_adults_view_model(
members, sorted_months, transactions, exceptions,
datetime.now().strftime("%Y-%m"),
attendance_url=..., payments_url=..., bank_account=BANK_ACCOUNT,
)
return render_template("adults.html", **vm)
```
## Decisions
1. **Pure builders, no Flask state.** No `record_step`, no `g.*`, no
`get_cached_data` inside builders. They take plain args, return
plain dicts. This is what makes them trivially unit-testable and,
later, trivially reusable from `/api/X`.
2. **Preserve byte-equal behaviour.** The dicts returned must match
today's `render_template(...)` kwargs key-for-key, value-for-value
— including the existing `json.dumps(member_data)` /
`json.dumps(month_labels_json)` / `json.dumps(raw_payments_json)`
wrappers. Those wrappers exist for inline JS in templates; the
refactor doesn't touch them. (When `/api/X` lands later, that route
will produce a sibling dict with the wrappers stripped, but that's
future work.)
3. **`reconcile()` stays inside the route, not the builder.** It
crosses the IO/cache boundary in spirit (its inputs come from
cache); but it's also pure-domain and called by both adults and
juniors. Keep the call site in the route so `record_step("reconcile")`
timing isn't lost. Builder takes `result` as an argument.
4. **Shared helpers stay in `app.py`** for now —
[get_month_labels](app.py#L41) and
[group_payments_by_person](app.py#L60) are already module-level
pure functions, used by routes and now by builders. Either leave
them in `app.py` and import into `scripts/views.py`, or move them
into `scripts/views.py`. **Choose: move into `scripts/views.py`**
— they're view-model concerns, not Flask concerns, and `app.py`
should keep shrinking.
5. **No new test file needed.** Existing
[tests/test_app.py](tests/test_app.py) tests
`test_adults_route`, `test_juniors_route`, `test_payments_route`
exercise the rendered HTML end-to-end. If they pass after the
refactor, behaviour is preserved. Adding builder-level unit tests
is a *nice-to-have* but not required for this iteration.
## Tasks
### 1. Create `scripts/views.py` with the three builders + shared helpers
- Move `get_month_labels` and `group_payments_by_person` from
[app.py:41–77](app.py#L41-L77) into `scripts/views.py`. Update the
import in `app.py`.
- Implement `build_adults_view_model` by extracting
[app.py:200–344](app.py#L200-L344) (everything between `result =
reconcile(...)` and `return render_template(...)`). Take `result`
as a parameter; emit the same dict that's currently passed as
`**kwargs` to `render_template`.
- Implement `build_juniors_view_model` by extracting
[app.py:370–550](app.py#L370-L550). Same shape — including the
`adapted_members` adapter loop, the junior `?`-sentinel branches,
and the `:NJ,MA` breakdown.
- Implement `build_payments_view_model` by extracting
[app.py:570–586](app.py#L570-L586) (the `group_payments_by_person`
call + `Unmatched / Unknown` bucket + sort).
### 2. Slim down the route handlers in `app.py`
Each handler keeps:
- `attendance_url`/`payments_url` URL building
- `get_cached_data` calls (with `record_step` between)
- `reconcile(...)` call (adults/juniors) with `record_step("reconcile")`
- `record_step("process_data")` after the builder call
- `return render_template("X.html", **view_model)`
Each handler **drops** all the per-row computation, totals
formatting, credits/debts sorting, `raw_payments_by_person` building,
and `import json` lines.
Target post-refactor LOC for each route handler: ~25–30 lines
(currently 167 / 205 / 35).
### 3. Run the existing test suite + manual smoke
```
make test # all tests green
make web-py # browse /adults /juniors /payments visually
```
## Critical files
- [app.py](app.py) — routes shrink dramatically; `get_month_labels`
and `group_payments_by_person` get moved out.
- New: `scripts/views.py` — three builders + the two helpers.
- [tests/test_app.py](tests/test_app.py) — unchanged; serves as the
regression guard.
## Verification
1. `make test` — all four `TestWebApp` tests pass unchanged.
2. `make web-py` and visit `/adults`, `/juniors`, `/payments` — each
renders identically to before (same table contents, same totals,
same credits/debts, same `?` rendering on juniors).
3. `git diff app.py` shows substantial deletions (route bodies
shrink) and only thin glue calling the new builders.
4. Optional sanity check: temporarily add `print(repr(view_model))`
in the route before `render_template` on `main` and on the branch,
diff for one fixture run — should be byte-identical dicts.
## Out of scope (future iterations, not this plan)
- `/api/<route>` shadow endpoints in Flask
- Go `internal/web/api/` DTOs and JSON Schemas
- Go `internal/services/membership/views.go` aggregators
- Go HTTP handlers for `/api/X`
- `cmd/parity/main.go` and `make parity` target
- Junior breakdown sidecar work in
`go/internal/services/membership/sources.go`
These are the original M5 tasks (M5.1–M5.4 in the progress tracker).
They become much easier once today's refactor lands, because the
shadow `/api/X` routes will be one-liners over the new builders.
@@ -0,0 +1,240 @@
# M5.1 — Hand-author Go API structs + emit JSON Schemas
Companion to:
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (progress tracker — M5.1 row)
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep — already merged as `b562ce3` / `32a16ff` / `59223c0`)
## Context
M4 (IO layer behind interfaces) just landed. M5 is the JSON-parity contract phase — byte-equal JSON between Python and Go for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version`. Within M5, the work splits four ways:
- **M5.1 — this plan.** Define the wire contract: hand-authored Go structs with explicit `json:` tags matching Python keys, plus committed JSON Schemas generated by `github.com/invopop/jsonschema`. **Schemas only — no handlers, no Python `/api/X` routes, no parity tool.**
- M5.2 — Implement Go handlers that compose `services/*` results into these structs.
- M5.3 — Add Python `/api/X` shadow endpoints in [app.py](app.py).
- M5.4 — `cmd/parity/main.go` + `make parity` target.
The recent Python view-model extraction (`scripts/views.py`) lays the groundwork: every Python builder now returns a plain dict that an `/api/X` shadow can `jsonify` (with one minor unwrap step — see decision #1 below). M5.1 is the matching Go side: types and schemas that pin down the contract before any code writes JSON to a wire.
## Key design decisions
1. **Wire format is nested objects, not strings-of-JSON.** The Python view-model dicts contain three template-only fields that are pre-serialized JSON strings: `member_data`, `month_labels_json`, `raw_payments_json`. Those exist purely to feed inline `<script>` blocks in Jinja templates ([scripts/views.py:227-237](scripts/views.py#L227-L237)). The `/api/X` JSON contract uses the **un-stringified** nested form. Rationale:
- The master design doc references `total_balance`, `original_expected`, `attendance_count` as struct fields ([2026-05-03-2349-go-backend-rewrite.md:223-225](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L223-L225)), which are *inside* `member_data` — only meaningful if it's a nested object.
- The `Expected` `MarshalJSON` design (emit `42` or `"?"`) ([same doc:233-238](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L233-L238)) only fires inside `member_data`; pointless if that field is a string.
- Strings-of-JSON make `invopop/jsonschema` schemas useless for those fields (`{"type": "string"}` describes nothing).
- The Python prep plan ([2026-05-07-1431-m5-json-api-parity.md:87-89](docs/plans/2026-05-07-1431-m5-json-api-parity.md#L87-L89)) already anticipates this: "that route will produce a sibling dict with the wrappers stripped".
This refines (does not contradict) M5.3's "no transformation" wording. M5.3's `/api/X` will be `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
2. **New package: `internal/web/api/`.** The Go side has no `api/` package today — only [internal/web/server.go](go/internal/web/server.go) with one hello route. The api package owns wire types and (in M5.2) handlers. Sub-files per route keep diffs small.
3. **Wire types are separate from `domain/reconcile.Result`.** [domain/reconcile/reconcile.go:88-92](go/internal/domain/reconcile/reconcile.go#L88-L92) defines `Result`, `MemberResult`, `MonthData`, `TxEntry`, etc. — none have `json:` tags. **Don't tag the domain types**: that bleeds wire concerns into pure logic and locks the JSON contract to internal field names. Define wire types in `internal/web/api/` and convert in M5.2's handlers.
4. **`Expected{Value int; Unknown bool}` with custom `MarshalJSON`/`UnmarshalJSON`** for junior `expected` and `original_expected`. Already prescribed by the master design.
5. **Transaction `amount` / `inferred_amount` may be `int` or `""`** (Sheets `UNFORMATTED_VALUE` returns empty string for blank cells per `scripts/match_payments.py` rows 250-260 in the views.py exploration). Use a custom `SheetsNumber` type with `MarshalJSON`/`UnmarshalJSON` that emits `0`/`null` for empty and the number otherwise. Document in code comment with a one-liner. Verify exact behavior by inspecting one or two existing scrubbed reconcile fixtures before coding.
6. **Schema generation lives in `internal/web/api/schemagen_test.go`** with `-update` flag, à la golden tests. Default test run (`go test ./internal/web/api/...`) re-generates schemas in memory and asserts byte-equality vs the committed files in `tests/fixtures/api-schema/`. `go test -update ./internal/web/api/...` rewrites the committed files. Avoids a separate `cmd/gen-api-schema` binary; CI catches drift automatically.
7. **One schema per route**, named to match the Go type:
```
go/tests/fixtures/api-schema/adults.schema.json
go/tests/fixtures/api-schema/juniors.schema.json
go/tests/fixtures/api-schema/payments.schema.json
go/tests/fixtures/api-schema/version.schema.json
```
8. **Adults and Juniors are *not* the same type.** Their outer shapes match (same keys), but `MonthCell.Text` semantics differ (juniors render `"?"`, `"?(3)"`, `:NJ,MA` breakdowns) and `member_data` semantics differ (juniors carry `Expected` sentinel). Define `AdultsResponse` and `JuniorsResponse` separately even if they share most field types — clearer schemas and easier to evolve independently. Share scalar types (`Transaction`, `Exception`, `MonthCell`, `TotalCell`) in `types.go`.
9. **Money is integer CZK** ([master design:55-56](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L55-L56)). All amount fields use `int`. The one exception: `member_data[name].months[YYYY-MM].paid` is a `float64` in the Python output (proportional allocation produces fractional CZK like `33.333333`). Use `float64` only there; document the why in a one-line comment.
## Files to create
```
go/internal/web/api/
├── types.go # SheetsNumber, Expected, Exception, Transaction, MonthCell, TotalCell, MemberRow
├── adults.go # AdultsResponse + adults-specific MemberData
├── juniors.go # JuniorsResponse + juniors-specific MemberData (with Expected fields)
├── payments.go # PaymentsResponse
├── version.go # VersionResponse
└── schemagen_test.go # generates + golden-asserts schemas
go/tests/fixtures/api-schema/
├── adults.schema.json
├── juniors.schema.json
├── payments.schema.json
└── version.schema.json
```
## Struct skeleton (illustrative, not final wording)
```go
// internal/web/api/types.go
// SheetsNumber wraps a value that may arrive from Google Sheets as either
// a JSON number or "" (empty string for blank cells). Marshals as 0 when
// missing; the consumer treats Missing+Value=0 as "no data".
type SheetsNumber struct {
Value float64
Missing bool
}
// Expected carries a junior's expected fee or the "?" sentinel
// (single-attendance month requires manual review).
type Expected struct {
Value int
Unknown bool
}
type Exception struct {
Amount int `json:"amount"`
Note string `json:"note"`
}
type Transaction struct {
Date string `json:"date"`
Amount SheetsNumber `json:"amount"`
ManualFix string `json:"manual_fix"`
Person string `json:"person"`
Purpose string `json:"purpose"`
InferredAmount SheetsNumber `json:"inferred_amount"`
Sender string `json:"sender"`
Message string `json:"message"`
BankID string `json:"bank_id"`
}
type MonthCell struct {
Text string `json:"text"`
Overridden bool `json:"overridden"`
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
Amount int `json:"amount"`
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
RawMonth string `json:"raw_month"` // YYYY-MM
Tooltip string `json:"tooltip"`
}
type TotalCell struct {
Text string `json:"text"`
Status string `json:"status"`
}
type MemberRow struct {
Name string `json:"name"`
Months []MonthCell `json:"months"`
Balance int `json:"balance"`
UnpaidPeriods string `json:"unpaid_periods"`
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
PayableAmount int `json:"payable_amount"`
}
type Credit struct {
Name string `json:"name"`
Amount int `json:"amount"`
}
```
```go
// internal/web/api/adults.go
type AdultsMonthData struct {
Status string `json:"status"`
Expected int `json:"expected"`
Paid float64 `json:"paid"` // float — proportional allocator can produce 33.333…
Exception *Exception `json:"exception"`
AmountToPay int `json:"amount_to_pay"`
// ... transactions, others, etc — match exact keys from result["members"][name]["months"][YYYY-MM]
}
type AdultsMemberData struct {
TotalBalance int `json:"total_balance"`
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM key
Transactions []Transaction `json:"transactions"`
}
type AdultsResponse struct {
Months []string `json:"months"`
RawMonths []string `json:"raw_months"`
Results []MemberRow `json:"results"`
Totals []TotalCell `json:"totals"`
MemberData map[string]AdultsMemberData `json:"member_data"`
MonthLabels map[string]string `json:"month_labels"` // was month_labels_json (string)
RawPayments map[string][]Transaction `json:"raw_payments"` // was raw_payments_json (string)
Credits []Credit `json:"credits"`
Debts []Credit `json:"debts"`
Unmatched []Transaction `json:"unmatched"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
}
```
`JuniorsResponse` mirrors `AdultsResponse` but the inner `MonthData` carries `Expected` and `OriginalExpected` (both `Expected` type), and adds the `:NJ,MA` breakdown fields produced by [scripts/views.py:290-298](scripts/views.py#L290-L298).
`PaymentsResponse`:
```go
type PaymentsResponse struct {
GroupedPayments map[string][]Transaction `json:"grouped_payments"`
SortedPeople []string `json:"sorted_people"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
}
```
`VersionResponse` mirrors Python's `BUILD_META` ([app.py:67](app.py#L67)):
```go
type VersionResponse struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
}
```
Exact fields inside the per-month `MonthData`/`MemberData` will be finalized by inspecting **one** scrubbed `member_data` JSON dump from a current `/adults` and `/juniors` call (see Verification step 1) — names and types have to be identical.
## Reusable existing code
- `domain/reconcile.Result` ([go/internal/domain/reconcile/reconcile.go:88](go/internal/domain/reconcile/reconcile.go#L88)) — source data for adults/juniors. M5.2 maps it to wire types; M5.1 only needs to know the field set.
- `web.BuildInfo` ([go/internal/web/server.go:11-15](go/internal/web/server.go#L11-L15)) — already wired through `cmd/fuj/main.go:79`. The `VersionResponse` type is a thin renaming of this with json tags. Decide in M5.2 whether to *also* tag `BuildInfo` directly (it's not really domain) or keep `VersionResponse` separate. M5.1 just defines the wire struct.
- `scripts/views.py` builders — the spec for every key/type. Treat as authoritative.
## Tasks
1. **Add `github.com/invopop/jsonschema` dependency.** `cd go && go get github.com/invopop/jsonschema && go mod tidy`. Single commit.
2. **Capture one fresh `member_data` dump for adults and juniors** to pin down inner-dict field names and types precisely (especially `paid` precision, `transactions[]` shape, `others[]` if present). Run `make web-py` on a known-good cache, hit `/adults` and `/juniors`, dump `view_model["member_data"]` to a scratch file (gitignored), and inspect. **Do not commit raw dumps** — PII rule. This is for shape inspection only.
3. **Author `internal/web/api/types.go`** with `SheetsNumber`, `Expected`, `Exception`, `Transaction`, `MonthCell`, `TotalCell`, `MemberRow`, `Credit`. Implement `MarshalJSON`/`UnmarshalJSON` for `SheetsNumber` and `Expected`. Use struct tags `json:"snake_case_key"` matching Python exactly.
4. **Author `internal/web/api/adults.go`** with `AdultsMonthData`, `AdultsMemberData`, `AdultsResponse`. Cross-check every key against the dump from step 2.
5. **Author `internal/web/api/juniors.go`** similarly, with `Expected` fields and the J/A breakdown.
6. **Author `internal/web/api/payments.go` and `version.go`** (small).
7. **Author `internal/web/api/schemagen_test.go`** that:
- Imports `github.com/invopop/jsonschema`.
- Defines a `schemaCases` slice: `{name: "adults", typ: AdultsResponse{}, ...}` etc.
- For each case, generates a schema, marshals indented JSON, compares against committed file at `../../tests/fixtures/api-schema/<name>.schema.json`.
- Honors a `-update` flag (`flag.Bool`) that rewrites the committed file instead of asserting.
- One `t.Run(case.name, ...)` per route for clear failure output.
8. **Run with `-update` once to populate** `tests/fixtures/api-schema/*.schema.json`. Eyeball each schema: required fields list, oneOf for `Expected`, additionalProperties on map types. Commit.
9. **Lint + test:** `cd go && go vet ./... && make go-test && make go-lint`. Fix any issues. (Expect zero — this is read-only data structures.)
10. **CHANGELOG entry** + tick `M5.1` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:100](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L100) with the merge SHA.
11. **Branch + MR per CLAUDE.md workflow:** branch `feat/go-m5-1-api-structs`, push with `-u`, open MR via `tea pr create`. Do not merge from CLI.
## Verification
1. `cd go && go test ./internal/web/api/...` — **schemas regenerate identically** to committed files.
2. `cd go && go test ./internal/web/api/... -update` then `git diff go/tests/fixtures/api-schema/` — diff is empty (idempotent generation).
3. `cd go && make go-lint` — clean.
4. `cd go && go vet ./...` — clean.
5. Manual schema inspection: open `adults.schema.json`, confirm:
- Top-level `required` list contains every Python key.
- `member_data` is `additionalProperties: { ... AdultsMemberData ... }` (a map keyed by name).
- `expected` and `original_expected` (juniors only) are `oneOf: [{type: integer}, {const: "?"}]`.
- `amount` and `inferred_amount` on `Transaction` accept number or empty/null.
6. **No production code paths exercised yet** — handlers come in M5.2. Compile-time success + schema golden-test = M5.1 done.
## Out of scope (later M5 tasks)
- Wiring Go HTTP handlers for `/api/X` (M5.2).
- Adding Python `/api/X` shadow endpoints (M5.3) — including the `unwrap_json_strings(view_model)` shim noted in design decision #1.
- `cmd/parity/main.go` and `make parity` target (M5.4).
- Tagging `domain/reconcile.Result` with `json:` tags — explicitly avoided.
- Refactoring the strings-of-JSON fields out of the Python view-model — they stay in `views.py` for the template path, the `/api/X` shadow unwraps them.
@@ -0,0 +1,113 @@
# M5.3 — Python `/api/X` shadow endpoints
Companion to:
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (M5.3 row)
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep)
- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types)
## Context
M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against.
The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `<script>` blocks. M5.1's plan called this out explicitly: M5.3's `/api/X` is `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
## Approach
Add four shadow routes to [app.py](app.py) and one private unwrap helper. Builders and templates are untouched.
### Decisions
1. **Unwrap shim lives in `app.py`**, not `scripts/views.py`. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keeping `views.py` free of HTTP-layer concerns means cleaner deletion later.
2. **No data-loading helper extraction.** Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread `attendance_url` / `payments_url` / `bank_account` and the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale.
3. **Drop `record_step` calls in API routes.** `record_step` only feeds `inject_render_time` (a Jinja `context_processor`); JSON responses don't go through templates, so timing breakdown has no consumer.
4. **`/api/version` is a one-liner.** `BUILD_META` already has the keys (`tag`, `commit`, `build_date`) Go emits. Just `jsonify(BUILD_META)`. Existing `/version` route stays as-is — the new endpoint sits alongside.
5. **Tests assert key sets and unwrap, not values.** Hard-code `EXPECTED_ADULTS_KEYS` etc. as module constants in [tests/test_app.py](tests/test_app.py). Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (`isinstance(member_data, dict)` etc.) to prove the shim ran.
### The shim
```python
def _unwrap_view_model_for_api(vm: dict) -> dict:
out = dict(vm)
out["member_data"] = _json.loads(out.pop("member_data"))
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
return out
```
Note the **rename**: `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` (matches Go contract per [adults.go](go/internal/web/api/adults.go) JSON tags).
### Route skeleton (`/api/adults`)
```python
@app.route("/api/adults")
def api_adults():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
if not members_data:
return jsonify({"error": "no data"}), 503
members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
result = reconcile(members, sorted_months, transactions, exceptions)
vm = build_adults_view_model(
members, sorted_months, result, transactions,
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))
```
`/api/juniors` mirrors with `adapt_junior_members` + `JUNIOR_SHEET_GID` + `build_juniors_view_model`. `/api/payments` skips the unwrap (its builder has no JSON-string fields): `return jsonify(vm)`. `/api/version`: `return jsonify(BUILD_META)`.
## Files to modify
- [app.py](app.py)
- L10: add `jsonify` to `from flask import ...`.
- Add `_unwrap_view_model_for_api` near [BUILD_META](app.py#L67) (it already imports `json as _json`).
- Add four routes: `/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`. Place them after [`/version`](app.py#L143) for grouping.
- [tests/test_app.py](tests/test_app.py)
- Add `EXPECTED_ADULTS_KEYS`, `EXPECTED_JUNIORS_KEYS`, `EXPECTED_PAYMENTS_KEYS`, `EXPECTED_VERSION_KEYS` module constants (sourced from [adults.go](go/internal/web/api/adults.go) / [juniors.go](go/internal/web/api/juniors.go) / [payments.go](go/internal/web/api/payments.go) / [version.go](go/internal/web/api/version.go) JSON tags).
- Four new test functions: `test_api_adults`, `test_api_juniors`, `test_api_payments`, `test_api_version`. Reuse the existing `_bypass_cache` patcher and the `fetch_sheet_data` / `fetch_exceptions` / `get_members_with_fees` / `get_junior_members_with_fees` mocks already in the file.
- Each adults/juniors test asserts: `200`, `response.is_json`, `set(json.keys()) == EXPECTED_*_KEYS`, `isinstance(json["member_data"], dict)`, `isinstance(json["month_labels"], dict)`, `isinstance(json["raw_payments"], dict)`.
- [CHANGELOG.md](CHANGELOG.md): post-merge entry per CLAUDE.md format.
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L102): tick M5.3 with merge SHA.
## Reusable existing code
- [`build_adults_view_model`](scripts/views.py#L64), [`build_juniors_view_model`](scripts/views.py#L240), [`build_payments_view_model`](scripts/views.py#L432) — call as-is, no changes.
- [`reconcile`](scripts/match_payments.py) and [`adapt_junior_members`](scripts/views.py#L48) — same.
- [`get_cached_data`](app.py#L36), [`fetch_sheet_data`](scripts/match_payments.py), [`fetch_exceptions`](scripts/match_payments.py), [`get_members_with_fees`](scripts/attendance.py), [`get_junior_members_with_fees`](scripts/attendance.py) — same call sites as the HTML routes.
- [`BUILD_META`](app.py#L67) — already shaped to match Go's `VersionResponse`.
## Verification
1. `make test` — all existing tests still pass; four new `test_api_*` tests pass.
2. `python -c "import app"` — catches the missing `jsonify` import.
3. `make web-py`, then:
- `curl -s localhost:5001/api/version | jq .` → `{tag, commit, build_date}`.
- `curl -s localhost:5001/api/adults | jq 'keys'` → 14 keys, no `_json` suffix anywhere.
- `curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'` → all `"object"` (proves unwrap).
- Same checks on `/api/juniors` and `/api/payments`.
4. Visit `/adults`, `/juniors`, `/payments` in browser — HTML still renders identically (regression check on builders).
5. **Optional pre-M5.4 peek:** `make web-go` on :8080 + `make web-py` on :5001, then `diff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .)`. Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
## Out of scope (M5.4 will surface and resolve)
These are known parity friction points; **don't fix in M5.3** — the whole point of M5.4 is to enumerate them:
- **`raw_payments[name][i]` row shape**: Python emits raw Google Sheets row dicts with column-header keys (e.g. `"VS"`, `"Sync ID"`); Go's `RawTransaction` uses snake_case (`vs`, `sync_id`). Keys and types will diverge.
- **`unmatched[]`**: same divergence (same raw row dicts).
- **`null` vs missing keys**: Python omits keys never set; Go zero-value structs may emit `null`/`""` depending on `omitempty`.
- **`Decimal` / float precision** in `grouped_payments` amounts.
- **Field insertion order**: `jsonify` preserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
## Branch + MR
Per CLAUDE.md: branch `feat/go-m5-3-python-api-shadow`, push with `-u`, open MR via `tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow`. Do not merge from CLI.
@@ -0,0 +1,152 @@
# M5.4 — Parity diff binary (`cmd/parity`) + `make parity`
## Context
Per [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:103](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L103), M5.4 is the next milestone in the Go rewrite. M5.1–M5.3 already landed:
- M5.1: hand-authored Go response structs at [go/internal/web/api/](go/internal/web/api/) + JSON Schemas in [go/tests/fixtures/api-schema/](go/tests/fixtures/api-schema/).
- M5.2: Go `/api/version|adults|juniors|payments` handlers in [go/internal/web/api/handler.go](go/internal/web/api/handler.go).
- M5.3: Python shadow endpoints in [app.py:157-224](app.py#L157-L224) using `_unwrap_view_model_for_api` for the same JSON shape.
What's missing: a tool that proves the two backends actually agree on the wire. The M5 gate says "byte-equal JSON between Python and Go for every route." Without a diffing tool, drift between the two implementations slips in silently and we can't gate further milestones (M6 frontend, M7 watch period) on parity.
This task delivers the **parity contract enforcer**: a Go binary that fetches `/api/*` from both backends, scrubs an allowlist of expected diffs, and prints `cmp.Diff` for everything else. Both backends read the same live Google Sheets — that shared state is what makes parity meaningful, no scenario fixtures needed.
## Scope
In scope:
- New binary `go/cmd/parity/main.go` plus a small support package for the allowlist scrubber.
- A unit test for the scrubber.
- New `make parity` target.
- `go-cmp` promoted to a direct dependency.
Out of scope (explicitly):
- CI integration / nightly job — that's M7.2.
- Fixture-driven offline parity — pure-fn parity already runs via `make go-parity` (M3).
- Hooking `make parity` into `make go-test-all` — leave it manual since it requires two live servers.
## Approach
### 1. New binary: `go/cmd/parity/main.go`
Standalone binary (mirrors task spec — not a subcommand of `fuj`). Stdlib `flag`, no third-party CLI lib.
**Flags:**
- `-py` — Python base URL, default `http://localhost:5001`
- `-go` — Go base URL, default `http://localhost:8080`
- `-route` — optional single route to diff (e.g. `/api/adults`); empty means iterate all four
- `-timeout` — per-request timeout, default `30s` (sheet fetches are slow on cold cache)
**Routes**, hard-coded:
```go
var routes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
```
All `GET`, no query params (verified in [app.py:157-224](app.py#L157-L224) and [go/internal/web/api/handler.go:27-68](go/internal/web/api/handler.go#L27-L68)).
**Per route, the binary:**
1. `GET py+route` and `GET go+route` (sequential — keep it simple; total wall time ~1–2s on warm cache).
2. Verify both return HTTP 200; on non-200, print body and mark route as ERROR.
3. Decode each body into `map[string]any` (NOT into `api.AdultsResponse` — using the typed struct would silently drop unknown Python-side keys, defeating the diff. Map gives `cmp.Diff` clean dotted-path field names for free.).
4. Run `scrub(m, allowlist)` on both decoded maps.
5. `diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())`.
6. If `diff != ""`, print `=== /api/X ===` header followed by the diff; track for exit code.
**Exit codes:**
- `0` — all routes match (or the only diffs were under the allowlist).
- `1` — at least one route had a non-allowlisted diff.
- `2` — at least one route failed to fetch / parse (HTTP error, timeout, non-JSON body).
This makes the binary CI-friendly when M7.2 lands.
**Output**: human-readable to stdout — header per route, then "OK" or the diff. Final summary line: `parity: 4/4 routes match` or `parity: 2/4 match, 1 diff, 1 error`.
### 2. Allowlist scrubber
Live in same package (`main`). Keep it tiny — no need for a separate sub-package.
```go
var defaultAllowlist = []string{"render_time.total", "build_meta"}
// scrub walks m and deletes any key whose dotted path matches an allowlist entry.
// "render_time.total" deletes m["render_time"]["total"] only (preserves render_time.breakdown).
// "build_meta" (no dot) deletes m["build_meta"] entirely.
func scrub(m map[string]any, paths []string) { ... }
```
Today these fields **don't appear in the JSON** — they're Jinja template-only ([app.py:91-110](app.py#L91-L110)) and the Go side only logs render time via [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go). So today the scrub is a no-op. The implementation is forward-compatible insurance: if someone later adds either field to a JSON response on one side only, the parity binary already tolerates it.
The implementation note in [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go) ("the elapsed value maps to render_time.total in the M5 JSON allowlist") confirms this is the intended design.
### 3. Unit test
Add `go/cmd/parity/scrub_test.go` with a couple of cases:
- Scrubbing top-level key (`build_meta`) — key removed; siblings untouched.
- Scrubbing nested key (`render_time.total`) — only `total` removed; `breakdown` preserved.
- Path that doesn't exist — no-op, no error.
- Map without the parent key — no-op (don't panic).
Runs as part of normal `make go-test` (no `-tags` needed).
### 4. Makefile target
Add to [Makefile](Makefile):
```make
parity:
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
```
Add `parity` to the `.PHONY` line at [Makefile:1](Makefile#L1) and a `help` entry like:
```
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
```
Don't depend on `go-build` — `go run` compiles ad-hoc, and parity is interactive enough that the slight rebuild cost doesn't matter.
### 5. Dependency
`github.com/google/go-cmp v0.7.0` is already a transitive dep ([go/go.sum:24-25](go/go.sum#L24-L25)) but not in `go.mod`'s `require` block. After adding the import, run `go mod tidy` inside `go/` to promote it to direct. No new external deps.
## Files to add / modify
**Add:**
- [go/cmd/parity/main.go](go/cmd/parity/main.go) — flags, route loop, fetch+scrub+diff, exit codes
- [go/cmd/parity/scrub_test.go](go/cmd/parity/scrub_test.go) — unit tests for the scrubber
**Modify:**
- [Makefile](Makefile) — `.PHONY`, help block, `parity` target
- [go/go.mod](go/go.mod) — promote `go-cmp` to direct (via `go mod tidy`)
- [go/go.sum](go/go.sum) — likely unchanged (already pinned)
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M5.4, add commit SHA
- [CHANGELOG.md](CHANGELOG.md) — entry per project convention
**Per [CLAUDE.md](CLAUDE.md) plans convention:** copy this plan to `docs/plans/2026-05-07-HHMM-m5-4-parity-binary.md` (timestamp via `date "+%Y-%m-%d-%H%M"`) before opening the MR, since plan files are committed for posterity.
## Existing utilities to reuse
- `cmp.Diff` + `cmpopts.EquateEmpty()` from `github.com/google/go-cmp/cmp` (already in go.sum).
- Stdlib `net/http`, `encoding/json`, `flag` — sufficient; no need to pull a CLI framework.
- No existing scrubber to reuse — the one in [scripts/scrub_fixtures.py](scripts/scrub_fixtures.py) operates on the *capture* path and renames PII; the parity scrubber is a different concern (path-based deletion of expected diffs).
## Verification
Manual smoke test (the binary is meant to be run against live servers):
1. **Sanity / build:** `cd go && go build ./cmd/parity && go test ./cmd/parity/...` — compiles + scrubber unit test passes.
2. **Both backends up:**
- Terminal A: `make web-py`
- Terminal B: `make web-go`
- Terminal C: `make parity`
Expected: `parity: 4/4 routes match`, exit 0.
3. **Negative test:** add a literal extra field to one Go handler temporarily (e.g. `"diagnostic": "test"` in `VersionResponse`), rebuild, re-run `make parity`. Expected: non-zero exit, diff shown for `/api/version`. Revert.
4. **Allowlist test:** in a unit test (or by manually constructing a payload with `render_time.total` injected), confirm the scrubber removes it before the diff stage.
5. **CHANGELOG** entry added; progress tracker ticked with the merge commit SHA.
Per [CLAUDE.md](CLAUDE.md) branching policy: this is a feature, so work happens on `feat/go-m5-4-parity-binary`, push with `-u`, open MR with `tea pr create --base main --head feat/go-m5-4-parity-binary`. User merges in Gitea.
## Notes & risks
- **Cache-warmth dependency:** Both backends cache aggressively. A cold-cache fetch on Python can take 30s+ — hence the configurable timeout. If parity is run immediately after `flush-cache`, expect the first run to be slow.
- **Order-sensitive lists:** `cmp.Diff` on `map[string]any` treats slices as ordered. If either backend ever returns members/transactions in a different order, the diff will flag it — that's a real parity bug, not a false positive, so this is correct behavior. If we hit ordering instability later, fix the source, don't add `cmpopts.SortSlices`.
- **Float formatting:** Both sides go through `encoding/json` (Go) and `jsonify` (Python `json.dumps`). Floats in totals/balances may format differently (`123` vs `123.0`). If this surfaces, the Go side already uses typed structs with `int` where appropriate — investigate at the source rather than allowlisting.
@@ -0,0 +1,88 @@
# Plan: Go rewrite — M6.1 Template skeleton
## Context
M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106).
M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+.
This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge").
## Current Go web state (what we're building on)
- [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields.
- No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate.
## Approach
1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`.
2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`.
3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505).
4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `<link>` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting.
5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data.
6. **Replace the existing `helloHandler`**: `GET /{$}` → `http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)).
7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`.
8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it.
## Files to create / modify
```
go/internal/web/
├── assets.go NEW — //go:embed templates/* static/*
├── render.go NEW — Renderer wraps parsed *template.Template
├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/
├── html/
│ ├── handler.go NEW — html.Handler + page methods
│ └── handler_test.go NEW — httptest smoke per route
├── templates/
│ ├── base.tmpl NEW — <html>/<head>/<body>; nav + content + footer blocks
│ ├── partials/
│ │ ├── nav.tmpl NEW — three-tier nav, active highlighting
│ │ └── footer.tmpl NEW — build meta line
│ ├── adults.tmpl NEW — placeholder "Coming in M6.2"
│ ├── juniors.tmpl NEW — placeholder "Coming in M6.3"
│ ├── payments.tmpl NEW — placeholder "Coming in M6.4"
│ ├── sync.tmpl NEW — placeholder "Coming in M6.6"
│ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6"
└── static/
└── css/
└── app.css NEW — verbatim from templates/adults.html:8-487
```
## Key design notes
- **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request.
- **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid.
- **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it).
- **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed.
- **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery.
- **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look.
- **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory.
## Critical files to read
- [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1).
- [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim.
- [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers).
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`.
## Verification
End-to-end smoke from a fresh `make go-build`:
1. `make web-go &` — server boots; no template-parse errors in slog output.
2. `curl -i localhost:8080/` → `302` to `/adults`.
3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only.
4. `curl -sI localhost:8080/static/css/app.css` → `200`, `Content-Type: text/css`.
5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`.
6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows.
7. `make go-test` — `web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor.
8. `make go-lint` clean.
9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`).
10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA.
## Branch & MR
`feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.
@@ -0,0 +1,131 @@
# Plan: Go rewrite — M6.2 Adults page
## Context
M6.1 landed the template skeleton, embed.FS, and HTML routes ([progress §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113), commit `78e5059`). Every page renders chrome + a "Coming in M6.X" placeholder body. **M6.2 fills in `/adults`** — the most data-rich page and the template that drives the rest of the M6 work (juniors mostly mirrors it).
Acceptance criteria, verbatim from [progress §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114):
> `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
The Go side has done excellent prep: `buildAdultsResponse` in [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) already produces the full view model (currently consumed only by `/api/adults`), the wire types in [go/internal/web/api/types.go](../../go/internal/web/api/types.go) match the Python view-model 1:1, and the CSS is already lifted into [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css). M6.2 is therefore mostly **template authoring + handler wiring + a small filters script**, not a fresh port.
## Current Go web state (what we're building on)
- [go/internal/web/server.go:49](../../go/internal/web/server.go#L49) — `mux.HandleFunc("GET /adults", hh.ServeAdults)` already wired.
- [go/internal/web/html_handler.go:16-18](../../go/internal/web/html_handler.go#L16-L18) — placeholder handler renders a `PageData{Active, Build}` shell only. Needs to gain access to the data layer.
- [go/internal/web/render.go:11](../../go/internal/web/render.go#L11) — `PageData` struct is currently `{Active, Build}` only. Needs to extend or be wrapped by a typed adults view model.
- [go/internal/web/api/handler.go:36-45](../../go/internal/web/api/handler.go#L36-L45) — `ServeAdults` already does `loadAll` → `Reconcile` → `buildAdultsResponse`. We will extract that body into an exported method so the HTML handler can reuse it without duplication.
- [go/internal/web/api/build_adults.go:17](../../go/internal/web/api/build_adults.go#L17) — `buildAdultsResponse(...) AdultsResponse` returns everything the template needs (`Months`, `Results`, `Totals`, `Credits`, `Debts`, `Unmatched`, `BankAccount`, `CurrentMonth`, …). We pass this struct straight into the template.
- [go/internal/web/templates/adults.tmpl](../../go/internal/web/templates/adults.tmpl) — currently a 5-line placeholder. Replace contents.
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — all selectors needed (`.cell-ok`, `.cell-unpaid`, `.cell-unpaid-current`, `.cell-overridden`, `.unmatched-row`, `.filter-container`, `.pay-btn`, `.member-row`, …) already present from M6.1.
Reference for parity: [templates/adults.html](../../templates/adults.html) (Python source). Sections to mirror in markup terms:
- Reconcile table — [adults.html:534-585](../../templates/adults.html#L534-L585)
- Totals row — [adults.html:571-582](../../templates/adults.html#L571-L582)
- Credits / Debts — [adults.html:587-609](../../templates/adults.html#L587-L609)
- Unmatched — [adults.html:611-629](../../templates/adults.html#L611-L629)
- Filter controls — [adults.html:515-532](../../templates/adults.html#L515-L532)
- Filter JS (name + month range) — [adults.html:1019-1051](../../templates/adults.html#L1019-L1051)
The member-detail modal, Pay-preview modal, and JSON-hydrated `memberData`/`rawPaymentsByPerson` globals are explicitly **out of scope for M6.2** — they belong to M6.5 (modal JS).
## Approach
1. **Share data assembly between HTML and JSON** — extract `ServeAdults`'s body into `(*api.Handler).AssembleAdults(ctx) (AdultsResponse, error)` and have `ServeAdults` call it. The HTML handler then calls the same method, keeping `/adults` and `/api/adults` byte-identical in semantics (same loaded data, same reconcile, same view model).
2. **Wire HTMLHandler to data** — extend `HTMLHandler` with an `apiHandler *api.Handler` field and pass it from `Run()`. `ServeAdults` becomes: `AssembleAdults` → on error render an error body (or 500) → render `adults.tmpl` with a typed view model wrapping `PageData` + `api.AdultsResponse`.
3. **Per-page typed view model** — add `AdultsPageData{ PageData; Data api.AdultsResponse }` in [render.go](../../go/internal/web/render.go) (or a new `view.go`). Template references `.Active`, `.Build` (chrome) and `.Data.Results`, `.Data.Totals`, `.Data.Months`, etc. Keeps the chrome contract for nav/footer untouched.
4. **Author `adults.tmpl`** — port markup from [templates/adults.html:489-629](../../templates/adults.html#L489-L629). Notable mechanics:
- Filter controls block with `<input id="nameFilter">`, `<select id="fromMonth">`, `<select id="toMonth">`, `Apply` / `All` buttons. Month dropdown options rendered server-side from `.Data.Months` (no client-side hydration needed for filters).
- Reconcile table iterates `.Data.Results`; each row's months iterate `row.Months`. Cell `<td>` gets class `cell-{{.Status}}`, plus `cell-unpaid-current` when `.RawMonth >= .Data.CurrentMonth`, plus `cell-overridden` when `.Overridden`. Cells carry `data-month-idx="{{$i}}"` so the filter script can hide columns.
- Per-cell Pay button visible on hover when `(unpaid|partial)` and `RawMonth < CurrentMonth` — `<a class="pay-btn" href="/qr?...">Pay</a>` with the QR query string built server-side via a template helper (see Key design notes).
- Per-row "Pay All" button when `.PayableAmount > 0`, same href construction using `.UnpaidPeriods` for display and `.RawUnpaidPeriods` for the QR message.
- Totals `<tr>` iterates `.Data.Totals`, classes `total-cell-{{.Status}}`.
- Credits / Debts / Unmatched sections rendered conditionally on non-empty slices.
5. **Tiny filter script** — new [go/internal/web/static/js/filters.js](../../go/internal/web/static/js/filters.js):
- `nameFilter` `input` event: NFD-normalize + lowercase + substring match against `.member-row [data-name]` (or row's first cell text); toggle `display:none` on non-matches.
- `fromMonth` / `toMonth` `change` event + `Apply` button: read selected `data-month-idx` range, toggle `month-hidden` class on `[data-month-idx]` `<th>`/`<td>` outside the range.
- `All` button: clear filters, restore all rows/cells.
- Match Python's behaviour byte-for-byte from [adults.html:864-1051](../../templates/adults.html#L864-L1051) but trimmed of modal/`memberData` calls.
- Loaded via `<script src="/static/js/filters.js" defer></script>` from the adults template (or base, if juniors will reuse it — yes, it should be in base or a content-block include).
6. **`<a href="/qr?…">` Pay buttons now, modal in M6.5** — the M6.6 milestone adds `/qr`. Until then, hrefs return 404. M6.5 will layer modal preview behaviour on top by wrapping clicks; the markup stays the same. This is the simplest staged rollout.
7. **Template helper for QR href** — add a `funcMap` to `Renderer` with `qrHref(account, amount, name, month string) string` (and a periods-list variant) that builds `/qr?account=…&amount=…&message=…` with proper URL encoding. Implementation: `net/url.Values{}.Encode()`. This keeps URL-construction logic out of the template syntax (`html/template` URL escaping isn't enough — query-param building deserves a Go helper).
8. **Active-link key** — `Active: "adults"` already set; nav highlighting works as-is (verified in M6.1 smoke test).
## Files to create / modify
```
go/internal/web/
├── api/
│ └── handler.go MODIFY — extract ServeAdults body to AssembleAdults(ctx)
├── html_handler.go MODIFY — hold *api.Handler; ServeAdults loads + renders
├── render.go MODIFY — add AdultsPageData type; add funcMap with qrHref()
├── server.go MODIFY — pass *api.Handler into NewHTMLHandler
├── html_handler_test.go MODIFY — add adults markup-level assertions w/ stub Sources
├── templates/
│ └── adults.tmpl MODIFY — replace placeholder w/ full table + filters + sections
└── static/
└── js/
└── filters.js NEW — name + month-range client-side filtering
```
No new packages; no domain or wire-type changes.
## Key design notes
- **Reuse `buildAdultsResponse` verbatim** — no parallel "view model" needed. `AdultsResponse` already has every field the template wants. `/adults` and `/api/adults` consume the same struct.
- **Extracting `AssembleAdults`** preserves the parity contract: anything that changes the JSON also changes the HTML, by construction. (It also sets up the same pattern for M6.3 juniors and M6.4 payments.)
- **Filter UX is DOM-driven**, no JSON hydration. `member_data` / `raw_payments` JSON payloads stay deferred to M6.5 (modal needs them; filters do not).
- **`current_month` boundary is server-side** — `AdultsResponse.CurrentMonth` (set from `time.Now().Format("2006-01")` in `loadAll`) is what the template compares `RawMonth` against for `cell-unpaid-current` styling and Pay-button visibility. Same value Python passes through `vm["current_month"]`.
- **`html/template` autoescaping is sufficient** for member names, sender, message, etc. — but Pay-button URLs need explicit `url.Values` encoding (Czech names have diacritics and spaces). Hence the `qrHref` funcMap helper.
- **Error rendering**: if `AssembleAdults` fails, render the base shell with an error banner inside `content` rather than `http.Error`. Keeps nav visible so the user can navigate away. Match Python's `"No data."` fallback at [app.py:233](../../app.py#L233) for empty results.
- **No JS framework, no bundler** — filter script is plain ES2020, ~80 lines, inline-readable. Matches M6.1's "no JS in M6.1" follow-through (we add JS in M6.2, but kept minimal).
- **NFD-normalize** in JS via `s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()` to match Python's `unicodedata.normalize('NFD', ...)`. Python ref: [adults.html:868-873](../../templates/adults.html#L868-L873).
- **Filter persistence is out of scope** — Python's filters are session-only (no localStorage). Same here.
## Critical files to read
- [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) — full view model already built; the template just iterates over `AdultsResponse`.
- [go/internal/web/api/types.go:91-122](../../go/internal/web/api/types.go#L91-L122) — `MonthCell`, `TotalCell`, `MemberRow`, `Credit` shapes.
- [go/internal/web/api/handler.go:36-96](../../go/internal/web/api/handler.go#L36-L96) — pattern for `loadAll` + `Reconcile` + `buildAdultsResponse`; the bit to refactor into `AssembleAdults`.
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — class names available; no edits needed.
- [templates/adults.html:489-629](../../templates/adults.html#L489-L629) — markup reference for filter controls, table, totals, credits/debts, unmatched.
- [templates/adults.html:864-1051](../../templates/adults.html#L864-L1051) — JS reference for filter behaviour (NFD-normalize, month range hiding, Apply/All).
- [tests/test_app.py:50-75](../../tests/test_app.py#L50-L75) — `test_adults_route` in Python, mirror its assertions in Go: status 200, body contains member name + `750/750 CZK (4)` + `Adults Dashboard`, does not contain `OK`.
## Verification
End-to-end smoke after `make go-build`:
1. `make web-go &` — server boots, no template-parse errors.
2. `curl -i localhost:8080/adults` → `200`, `Content-Type: text/html`.
3. Browser at `http://localhost:8080/adults` against real data:
- Table renders with one row per adult member, one column per month, totals row at bottom.
- Cell colors match `/api/adults` JSON — pick a `Credits` member from JSON, confirm green-ish status; pick a `Debts` member, confirm red-ish.
- Credits / Debts / Unmatched sections render with content matching the JSON arrays for those keys.
- Per-cell `Pay` buttons appear on hover for past-month unpaid/partial cells; href contains `/qr?account=...&amount=...&message=`.
- Per-row "Pay All" button shows for members with `payable_amount > 0`.
4. Filters:
- Type a partial member name in `#nameFilter` → only matching rows visible. Test with diacritics (e.g. `nemec` matches `Němec`).
- Pick `fromMonth=2026-02`, `toMonth=2026-04`, click `Apply` → only those columns visible (table + totals row).
- Click `All` → everything restored.
5. `curl -s localhost:8080/adults | grep -F 'Adults Dashboard'` matches; `grep -F 'Coming in M6.2'` does **not** match.
6. `make go-test` — `html_handler_test.go` adults assertions pass with stub Sources fixture (replicating `test_adults_route` from Python).
7. `make go-lint` clean.
8. `make parity` still green — `/api/adults` JSON unchanged because `AssembleAdults` extraction is a pure refactor.
9. CHANGELOG entry per CLAUDE.md (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
10. Tick M6.2 in [progress tracker §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114) with the merge commit SHA.
## Branch & MR
Branch `feat/go-m6-2-adults-page` per CLAUDE.md branch-per-feature workflow. Commit, push with `-u`, then:
```bash
tea pr create \
--title "feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)" \
--description "..." \
--base main \
--head feat/go-m6-2-adults-page
```
Print the PR URL for the user. User merges in Gitea browser.
@@ -0,0 +1,142 @@
# Period selector missing older months — diagnosis
## Context
User reports the "From / To" period selector on the **adults** dashboard now
shows **Dec 2025** as the oldest available period. The older production
deployment shows Sep+Oct 2025, Nov 2025, Dec 2025+Jan 2026 (merged labels) —
i.e. data going back to September 2025. The user wants to know what went
sideways. Confirmed: the dropdown options on both Python and Go genuinely
start at Dec 2025, not just the default selection — Sep/Oct/Nov 2025 are not
in the list at all.
## Root cause — the live adults sheet header is missing those columns
The fresh cache files at `tmp/go/attendance_regular_cache.json` (raw rows from
Google Sheets, modifiedTime `2026-05-06T22:30:02Z`, cached `2026-05-08T00:26`)
contain the actual header row for the adults tab (gid=0):
```text
['FUJ tréninky úterý 20:30-22:00', '', '', '02.12.2025', '09.12.2025',
'16.12.2025', '06.01.2026', '13.01.2026', '20.01.2026', '27.01.2026',
'03.02.2026', '10.02.2026', '17.02.2026', '24.02.2026', '03.03.2026',
'10.03.2026', '17.03.2026', '23.03.2026', '31.03.2026', '13.04.2026',
'20.04.2026', '27.04.2026', '04.05.2026', '', '', '']
```
**The first date column in the live adults sheet is `02.12.2025` (Dec 2 2025).**
There are no September, October, or November 2025 columns in the header at
all. Both backends parse this faithfully (no slicing, no cutoff anywhere) and
correctly produce `sortedMonths = ["2025-12", "2026-01", …, "2026-05"]`.
The juniors sheet (different tab, `JUNIOR_SHEET_GID`) is **fine** — its header
still contains `['', 'tier', '', '15.09.2025', '13.10.2025', '20.10.2025',
'03.11.2025', '24.11.2025', '10.11.2025', '17.11.2025', '01.12.2025', …]`. So
the juniors page still shows Sep+Oct / Nov / Dec+Jan correctly.
So this is a **data issue in the adults attendance Google Sheet**: at some
point between when production's cache was last warmed (showing Sep–Nov) and
2026-05-06, somebody (or some action) removed the columns for September,
October, and November 2025 from the adults tab header.
The code is doing exactly what it should. There is no parser regression.
## What to do
### 1. Restore the missing date columns in the adults attendance sheet
The fix lives in Google Sheets, not in the codebase. Options, in order of
preference:
- **(a) Use Sheets version history.** File → Version history → See version
history; find a version from before the columns were dropped (anything
before about Mar 2026 should still have them). Copy the Sep/Oct/Nov 2025
date column headers and the `TRUE/FALSE` cells underneath them back into
the current sheet. Only restore the 11 missing date columns; do not
full-revert (you'd lose every change since then).
- **(b) Pull from the production server's cache.** The production deployment
evidently still has the older cache, since its dashboard renders those
months. SSH there, copy `tmp/attendance_regular_cache.json`, and you can
reconstruct the per-member Sep/Oct/Nov attendance counts from the
`data[*][2]` map (keys `"2025-09"`, `"2025-10"`, `"2025-11"`). Re-enter
those into the sheet manually as date columns + `TRUE` cells — tedious but
deterministic.
- **(c) Accept the loss.** If the older columns aren't recoverable, the
dashboard correctly reflects what the sheet contains; nothing more to do.
Which to pick depends on whether those months still need to be billed /
reconciled.
### 2. Restore `ADULT_MERGED_MONTHS` (user confirmed this was unintentional)
Independent of the sheet issue: commit `1257f0d` (Mar 9 2026) commented out
the adult merge mappings. Once the Sep/Oct/Nov columns are back in the sheet,
the dashboard would still show them as separate periods instead of the
production-style "Sep+Oct 2025" and "Dec 2025+Jan 2026" merged labels.
User confirmed this was unintentional. Two files to update:
- [scripts/attendance.py:32-35](scripts/attendance.py#L32-L35) — uncomment
the two mappings:
```python
ADULT_MERGED_MONTHS = {
"2025-12": "2026-01", # keys are merged into values
"2025-09": "2025-10",
}
```
- [go/internal/services/membership/sources.go:30](go/internal/services/membership/sources.go#L30) — mirror the same:
```go
var AdultMergedMonths = map[string]string{
"2025-12": "2026-01",
"2025-09": "2025-10",
}
```
After this change, hit `POST /flush-cache` on each backend so the in-process
post-processed adults cache is rebuilt with the new mapping.
### 3. (Optional, separate) Fix the JS auto-default that hides older months
This is **not** the cause of the user's current symptom (that's the sheet
issue), but it will become a UX issue once Sep/Oct/Nov columns are restored:
the Python frontend's `defaultFrom = Math.max(0, maxMonthIdx - 4)` will still
default the From-selector to ~5 months before the latest column on every page
load, hiding restored older months until the user manually picks them.
- [templates/adults.html:1047](templates/adults.html#L1047) — `var defaultFrom = Math.max(0, maxMonthIdx - 4);`
- [templates/juniors.html:1028](templates/juniors.html#L1028) — same line.
Drop those four lines (`defaultFrom`, `fromSelect.value = defaultFrom`,
`toSelect.value = maxMonthIdx`, `applyMonthFilter()`) so the page loads with
all non-future months visible — matching the Go side, which only calls
`hideFutureMonths()` and leaves From at its first option.
Recommend bundling this with step 2 since they touch related UI.
## Verification
1. **After step 1** — `POST /flush-cache` on Python and Go backends; reload
`/adults` on each. Confirm the dropdown now lists Sep/Oct/Nov 2025.
2. **After step 2** — reload `/adults`. Confirm the dropdown shows
"Sep+Oct 2025" as a single period and "Dec 2025+Jan 2026" as a single
period. (Still requires the sheet columns to exist.)
3. **After step 3** — reload `/adults` and `/juniors` on Python. Confirm the
table renders all non-future months on first load (Sep 2025 through the
current month) instead of starting at Dec 2025.
4. **Parity check** — `make parity` should report zero diffs between Python
and Go on `/api/adults` and `/api/juniors`.
## Critical files referenced
- `tmp/go/attendance_regular_cache.json` — current adults sheet rows
(evidence: header starts at `02.12.2025`).
- `tmp/go/attendance_juniors_cache.json` — current juniors sheet rows
(header still has `15.09.2025`).
- [scripts/attendance.py](scripts/attendance.py) — `ADULT_MERGED_MONTHS`
empty after `1257f0d`; `parse_dates` / `group_by_month` faithful.
- [go/internal/services/membership/sources.go](go/internal/services/membership/sources.go) — Go counterpart, same shape.
- [templates/adults.html](templates/adults.html), [templates/juniors.html](templates/juniors.html) — JS onload `defaultFrom = -4` issue (step 3).
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — Go filter UI (already correct, no changes).
@@ -0,0 +1,67 @@
# M6.3 — Go-native `/juniors` page
## Context
The Go rewrite is in milestone M6 (Go-native HTML frontend). M6.2 shipped the `/adults` page in commit `c85748b`; the [progress tracker](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) line 115 names M6.3 as:
> `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
The Go side already has `buildJuniorsResponse` (M5.2) that produces the full JSON view model, and the JuniorsResponse uses the same `MemberRow`/`MonthCell`/`TotalCell` types as `AdultsResponse`. Critically, `buildJuniorMemberRow` ([go/internal/web/api/build_juniors.go:142](../../srv/personal/fuj-management/go/internal/web/api/build_juniors.go#L142)) already bakes the J/A breakdown (e.g. `"3/500 CZK (4:2J,2A)"`) and the `"?"` sentinel into `MonthCell.Text` — no template-level junior-specific logic is required for the M6.3 table.
Today `juniors.tmpl` is a 4-line "Coming in M6.3" placeholder and `HTMLHandler.ServeJuniors` ([html_handler.go:35](../../srv/personal/fuj-management/go/internal/web/html_handler.go#L35)) just renders that placeholder. Goal of M6.3: produce a working `/juniors` page that mirrors the structure of `/adults` and reaches feature parity with the Python `/juniors` route.
## Approach
Mirror the M6.2 pattern exactly:
1. **Extract `AssembleJuniors(ctx)`** from the JSON handler so HTML + JSON share one view-model assembly path (the parity contract).
2. **Add `JuniorsPageData`** wrapper type next to `AdultsPageData` in `render.go`.
3. **Replace `juniors.tmpl`** with an adults-shaped layout. Because `JuniorsResponse` shares the same field names used by `adults.tmpl` (`Months`, `RawMonths`, `Results`, `Totals`, `Credits`, `Debts`, `AttendanceURL`, `PaymentsURL`, `BankAccount`, `CurrentMonth`), the template body is essentially copy/paste with one parity tweak (see "Decisions" below).
4. **Wire `HTMLHandler.ServeJuniors`** to call `AssembleJuniors` and render the new template, parallel to `ServeAdults`.
No new CSS, no new JS — `filters.js` and the `cell-*` classes (lifted in M6.1) already cover juniors.
## Files to modify
| File | Change |
| --- | --- |
| [go/internal/web/api/handler.go](../../srv/personal/fuj-management/go/internal/web/api/handler.go) | Add `AssembleJuniors(ctx) (JuniorsResponse, error)`; refactor existing `ServeJuniors` to call it (mirrors `AssembleAdults` at line 47). |
| [go/internal/web/render.go](../../srv/personal/fuj-management/go/internal/web/render.go) | Add `JuniorsPageData` struct (PageData + `Data api.JuniorsResponse` + `Error string`). |
| [go/internal/web/html_handler.go](../../srv/personal/fuj-management/go/internal/web/html_handler.go) | Replace stub `ServeJuniors` with the same shape as `ServeAdults` (lines 20–33). |
| [go/internal/web/templates/juniors.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/juniors.tmpl) | Replace placeholder with a structurally identical copy of [adults.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/adults.tmpl), with the title swapped to "Juniors Dashboard" and the Unmatched section removed (parity with Python). |
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) | Tick `M6.3` once merged, append merge SHA. |
| [CHANGELOG.md](../../srv/personal/fuj-management/CHANGELOG.md) | New top-most entry per CLAUDE.md format. |
## Decisions captured
- **No "Unmatched Transactions" section on `/juniors`.** Python's [templates/juniors.html](../../srv/personal/fuj-management/templates/juniors.html) renders only Credits + Debts (no Unmatched block). Although `JuniorsResponse.Unmatched` is populated, parity says we don't render it on juniors. The data stays available for the modal in M6.5.
- **Reuse `qrHref` / `qrHrefAll` template funcs** from [render.go:35–57](../../srv/personal/fuj-management/go/internal/web/render.go#L35-L57). They are name-only (not adult-specific) and already on `tmplFuncs`.
- **Reuse the existing `cell-empty / cell-unpaid / cell-unpaid-current / cell-ok / cell-overridden` cell-class branching** from `adults.tmpl` lines 60–67 verbatim. The "?" sentinel is rendered as plain `{{$cell.Text}}` text — no template branch needed because `buildJuniorMemberRow` produces `"?"` or `"? CZK"` already.
- **No template helper extraction** (e.g. shared `_table_section.tmpl`). The two templates will be near-duplicates, but extracting a shared partial now is premature — wait until M6.4 or later when patterns settle. M6.2 didn't extract one either.
- **Branch**: `feat/go-m6-3-juniors-page`, MR via `tea pr create` per CLAUDE.md.
## Verification
End-to-end smoke (parallel-running both backends as in M5/M6.2):
1. `cd go && make run` (or whatever target boots the Go server on :8080).
2. Browser-load `http://localhost:8080/juniors`. Confirm:
- Member rows render with cells containing fee + attendance breakdown like `0/500 CZK (3:2J,1A)`.
- At least one `"?"` cell visible (any junior with exactly one session in some month).
- Filter input narrows rows by name; From/To month selects hide columns via `data-month-idx`.
- `Pay` button visible on past-month unpaid cells; clicking links to `/qr?...`. (The QR endpoint itself lands in M6.6 — only the link target is in scope here.)
- `Pay All` visible on rows with `Balance < 0`.
- TOTAL row sums correctly per month.
- Credits + Debts sections render when present; no Unmatched section.
3. JSON parity: `make parity` against the M3 fixture corpus must still report zero non-allowlisted diffs for `/api/juniors` (the `AssembleJuniors` extraction must not change wire output).
4. Compare side-by-side against Python `/juniors` on :5001 for the same fixture; the table cells should match cell-for-cell modulo the known M5 allowlist.
## Out of scope
- Modal (`[i]` info button + `#memberModal`) — that is M6.5.
- QR endpoint, `/sync-bank`, `/flush-cache`, `/version` pages — M6.6.
- Embed.FS deploy verification — M6.7.
## Plan-file relocation
Per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) "Plans" section, plan files belong in `docs/plans/YYYY-MM-DD-HHMM-<slug>.md` inside the repo. Plan mode forced this draft into `~/.claude/plans/`; first step after ExitPlanMode is to copy this file to `docs/plans/2026-05-08-HHMM-go-m6-3-juniors-page.md` (resolving the timestamp at that moment) and commit it on the feature branch alongside the implementation.
@@ -0,0 +1,215 @@
# M6.4 — Go-native `/payments` page (grouped-by-person ledger)
> Plan-mode note: per project convention this plan should live at
> `docs/plans/2026-05-08-1220-go-m6-4-payments-page.md`. The first execution
> step after approval is to copy this file there.
## Context
The Go port of the Flask app is at milestone **M6.4**: replace the placeholder
`/payments` template ([go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl))
with a feature-equivalent grouped-by-person ledger view, matching the Python
[templates/payments.html](templates/payments.html) page.
Compared to M6.2 (`/adults`) and M6.3 (`/juniors`), this page is **structurally
much simpler**:
- No filters (no name input, no month-range selects).
- No totals / credits / debts / unmatched-as-extra-section.
- No JS (no `filters.js`, no modal — modal is M6.5 territory and Python's
payments page itself has none).
- No QR / Pay buttons.
- Single grouping dimension: **by person**, sorted alphabetically, with a
synthetic `"Unmatched / Unknown"` bucket appended.
- Each person's transactions sorted **newest-first**.
- Columns: Date, Amount (right-aligned, green), Purpose, Bank Message.
The JSON API side is already done (`/api/payments` at
[go/internal/web/api/handler.go:78-86](go/internal/web/api/handler.go#L78-L86)),
returning a `PaymentsResponse{ GroupedPayments, SortedPeople, AttendanceURL,
PaymentsURL }`. The HTML side just needs to consume it.
## Approach
Mirror the M6.2/M6.3 pattern: extract an `AssembleX(ctx)` method, add a typed
`PageData` wrapper, render a real template, lift the page-specific CSS into the
shared stylesheet.
### 1. Extract `AssemblePayments(ctx) (PaymentsResponse, error)`
In [go/internal/web/api/handler.go](go/internal/web/api/handler.go), refactor
`ServePayments` (currently inlines the load+build at lines 78-86) to mirror
`AssembleAdults` / `AssembleJuniors`:
```go
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssemblePayments(r.Context())
if err != nil { h.writeError(w, r, err); return }
writeJSON(w, resp)
}
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
txns, err := h.Sources.LoadTransactions(ctx)
if err != nil { return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) }
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
}
```
Pure refactor — `make parity` should report zero diffs on `/api/payments`.
### 2. Add `PaymentsPageData` wrapper
In [go/internal/web/render.go](go/internal/web/render.go), alongside
`AdultsPageData` / `JuniorsPageData`:
```go
type PaymentsPageData struct {
PageData
Data api.PaymentsResponse
Error string
}
```
No changes needed to `pageNames` — `"payments"` is already registered.
### 3. Rewire HTML handler
Replace [go/internal/web/html_handler.go:50-52](go/internal/web/html_handler.go#L50-L52):
```go
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
data := PaymentsPageData{ PageData: PageData{Active: "payments", Build: h.build} }
resp, err := h.apiHandler.AssemblePayments(r.Context())
if err != nil { data.Error = err.Error() } else { data.Data = resp }
h.renderer.Render(w, "payments", data)
}
```
### 4. Replace template stub
Rewrite [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl)
(currently a 5-line placeholder). Structure:
```gotmpl
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
<h1>Payments Ledger</h1>
<p class="description">
All bank transactions from the
<a href="{{.Data.PaymentsURL}}" target="_blank">payments sheet</a>,
grouped by member. Names are matched against the
<a href="{{.Data.AttendanceURL}}" target="_blank">attendance sheet</a>.
</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="ledger-container">
{{range $person := .Data.SortedPeople}}
<div class="member-block">
<h2>{{$person}}</h2>
<table class="txn-table">
<thead><tr>
<th class="txn-date">Date</th>
<th class="txn-amount">Amount</th>
<th class="txn-purpose">Purpose</th>
<th class="txn-message">Bank Message</th>
</tr></thead>
<tbody>
{{range $tx := index $.Data.GroupedPayments $person}}
<tr>
<td class="txn-date">{{$tx.Date}}</td>
<td class="txn-amount">{{printf "%.0f" $tx.Amount}} CZK</td>
<td class="txn-purpose">{{$tx.Purpose}}</td>
<td class="txn-message">{{$tx.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}
```
Notes:
- `printf "%.0f"` matches the visual style of integer CZK amounts (Python prints
the float as-is, but bank-row amounts are always whole CZK). If the user
prefers exact Python parity (`750.0 CZK`), drop the `printf` and use
`{{$tx.Amount}}`.
- Iterating a `map` requires using `.Data.SortedPeople` to drive order then
`index $.Data.GroupedPayments $person` — Go templates don't preserve map
insertion order.
### 5. Add CSS classes to `app.css`
Append the page-specific rules from [templates/payments.html:111-164](templates/payments.html#L111-L164)
to [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css):
`.ledger-container`, `.member-block`, `.txn-table`, `.txn-table th/td`,
`.txn-date`, `.txn-amount`, `.txn-purpose`, `.txn-message`, `tr:hover`. The
terminal-green palette already matches.
### 6. Unit test
Add a markup-level test to `go/internal/web/html_handler_test.go` (the file
created in M6.2/M6.3) following the same pattern: stub `Sources` returning a
fixed transaction list, assert response is HTML 200, assert key strings appear
(person names as `<h2>`, "Payments Ledger" `<h1>`, expected amount, "Unmatched
/ Unknown" bucket when an unattributed tx exists).
## Critical files
| Action | Path |
|---------|------|
| edit | [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — extract `AssemblePayments` |
| edit | [go/internal/web/render.go](go/internal/web/render.go) — add `PaymentsPageData` |
| edit | [go/internal/web/html_handler.go](go/internal/web/html_handler.go) — rewire `ServePayments` |
| rewrite | [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) — real template |
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — append ledger CSS |
| edit | `go/internal/web/html_handler_test.go` — payments markup test |
No changes to `server.go`, `assets.go`, `Sources` interface, `buildPaymentsResponse`,
or any domain code — these were completed in M5 / M6.1.
## Reused existing helpers
- [go/internal/web/api/build_payments.go](go/internal/web/api/build_payments.go) — `buildPaymentsResponse` (existing, unchanged)
- [go/internal/web/api/build_common.go:84-117](go/internal/web/api/build_common.go#L84-L117) — `groupRawPaymentsByPerson` handles canonicalization, comma-splitting, `[?]` stripping, newest-first sort
- [go/internal/web/api/handler.go:116-129](go/internal/web/api/handler.go#L116-L129) — `allMemberNames`
- [go/internal/web/templates/partials/nav.tmpl](go/internal/web/templates/partials/nav.tmpl) and `footer.tmpl` — already include the `[Payments Ledger]` nav link
## Verification
End-to-end:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make parity` — confirm the `AssemblePayments` extraction is byte-equal on
`/api/payments`.
3. `make web-go &` then:
- `curl -i localhost:8080/payments | head -30` — HTTP 200, `text/html`.
- Browser load `http://localhost:8080/payments`:
- "Payments Ledger" heading + description with two clickable sheet links.
- Alphabetical member blocks, each with a table.
- Amounts right-aligned, green, "CZK" suffix.
- Newest-first ordering inside each block.
- "Unmatched / Unknown" bucket sorts in alphabetical position (capital U →
near end).
- Hover on a row darkens the background.
- Nav highlights `[Payments Ledger]` as the active link.
- Cross-check with Python: `make web-py &` → `http://localhost:5001/payments`
should look visually identical except for nav styling.
4. `make web-py & make web-go &` then `tea pr create` once committed (per
project branch-per-feature workflow).
## Branching
Create `feat/go-m6-4-payments-page` off `main`, push, open MR via `tea`.
Follow project conventions: Co-Authored-By trailer, CHANGELOG entry once user
confirms it works, tick **M6.4** in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:116](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L116)
with the merge SHA after the user merges in Gitea.
## Out of scope (deferred)
- **Modal / `[i]` info button** — M6.5 (not present in Python `/payments` either).
- **Period selector** — `/payments` has none in Python; not adding one.
- **embed.FS deploy verification** — M6.7.
- **Shared partial extraction** — payments structurally diverges from
adults/juniors enough that there's no obvious common partial to lift; defer.
@@ -0,0 +1,288 @@
# M6.5 — Member-detail modal JS module (`/adults`, `/juniors`)
> Plan-mode note: per project convention this plan should live at
> `docs/plans/2026-05-08-HHMM-go-m6-5-modal-js.md` (use `date "+%Y-%m-%d-%H%M"`
> when copying). The first execution step after approval is to copy this file
> there. Branch: `feat/go-m6-5-modal-js` off `main`.
## Context
The Go port of the Flask app is at milestone **M6.5**: the `/adults` and
`/juniors` pages currently render their tables ([go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl),
[go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl))
but have no row-click member-detail modal — the feature that lets a user click
a member's row to inspect status per month, exception overrides, "other"
transactions, the matched payment list, and a debug raw-payments view.
The Python implementation lives inline in
[templates/adults.html:718-993](templates/adults.html#L718-L993)
(matching code in [templates/juniors.html](templates/juniors.html) — same
modal markup and JS). It uses globals injected via Jinja:
`memberData`, `sortedMonths`, `monthLabels`, `rawPaymentsByPerson`. M6.5
replaces this with a clean static JS module.
Per milestone wording — *"fetches `/api/adults` (or juniors), renders
status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)"* —
and per the user's design choice: the JS module fetches `/api/<page>` **once
on page load** and caches the response in a module-scoped variable. Subsequent
row clicks render synchronously from cache. This keeps HTML purely
server-rendered and reuses the parity-tested JSON contract already shipped in
M5.
All the building blocks are already in place:
- **JSON API**: `/api/adults` and `/api/juniors` already return
`member_data`, `month_labels`, `raw_payments` as nested objects (see
[go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42)
and the analogous juniors response). Junior `Expected` serialises as `"?"`
for the unknown sentinel via custom `MarshalJSON`
([go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29)) — JS sees a literal string `"?"`.
- **CSS**: `#memberModal`, `.modal-content`, `.modal-section`, `.modal-table`,
`.tx-list`, `.tx-item`, `.tx-meta`, `.tx-amount`, `.info-icon`, `.raw-toggle`
were lifted whole during M6.1 and live in
[go/internal/web/static/css/app.css](go/internal/web/static/css/app.css)
(lines 168-487). No CSS work needed.
- **Member-row hooks**: `tr.member-row[data-name="..."]` and `td.member-name`
are already rendered by both templates.
- **JS module convention**: `static/js/filters.js` is loaded with `<script src="/static/js/filters.js" defer>`;
M6.5 adds a sibling `static/js/member-detail.js` with the same loading
pattern.
## Approach
### 1. Add `[i]` info icon + `#memberModal` markup to both templates
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl):
a. Inside the `<td class="member-name">{{$row.Name}}</td>` cell (line 59 of
each template), append the icon button:
```gotmpl
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
```
Using a `data-name` attribute and a CSS class (rather than `onclick="..."`)
keeps the template free of inline JS — the module wires up the listener
itself.
b. After the closing `</div>` of the table-container / sections, before the
`<script>` tag at line 140 (adults) / 120 (juniors), inject the modal
markup. Mirror the Python structure verbatim
([templates/adults.html:644-707](templates/adults.html#L644-L707)):
```gotmpl
<div id="memberModal" class="modal" onclick="if (event.target === this) this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title" id="modalMemberName">—</span>
<span id="modalTier" class="modal-tier"></span>
<a href="#" class="modal-close" onclick="event.preventDefault(); document.getElementById('memberModal').classList.remove('active')">[x]</a>
</div>
<div class="modal-section">
<div class="modal-section-title">Status by Month</div>
<table class="modal-table">
<thead><tr><th>Month</th><th>Sessions</th><th>Expected</th><th>Paid</th><th>Status</th></tr></thead>
<tbody id="modalStatusBody"></tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display:none">
<div class="modal-section-title">Exception Overrides</div>
<div id="modalExceptionList" class="tx-list"></div>
</div>
<div class="modal-section" id="modalOtherSection" style="display:none">
<div class="modal-section-title">Other Payments</div>
<div id="modalOtherList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">Matched Transactions</div>
<div id="modalTxList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">
Raw Payments (debug)
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display:none"></div>
</div>
</div>
</div>
```
c. Add the script tag alongside `filters.js`. To tell the module which API
endpoint to fetch, use a `data-page` attribute on `body` (or on the
filter container — already exists). Simplest: add `data-page="adults"` /
`data-page="juniors"` to the existing `<div id="filterContainer">`.
The module reads it on load.
```gotmpl
<script src="/static/js/member-detail.js" defer></script>
```
### 2. Create [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js)
A single shared module — both pages use the same DOM contract, so the same
JS works for both. Structure (vanilla ES, no build step, matches `filters.js`
style — IIFE, `'use strict'`, no globals leaked):
```js
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const page = container.dataset.page; // "adults" | "juniors"
if (!page) return;
let apiData = null; // cached /api/<page> response
let currentMemberName = null;
// ── Data load ─────────────────────────────────────────────────────────
async function loadData() {
if (apiData) return apiData;
const r = await fetch('/api/' + page);
if (!r.ok) throw new Error('failed to fetch /api/' + page);
apiData = await r.json();
return apiData;
}
// Pre-warm immediately so first click is instant.
loadData().catch(err => console.error('[member-detail]', err));
// ── Modal render ──────────────────────────────────────────────────────
async function showMember(name) { /* port of Python showMemberDetails */ }
function toggleRawPayments(ev) { /* port */ }
function closeModal() { document.getElementById('memberModal').classList.remove('active'); }
function navigateMember(dir) { /* port — walk visible .member-row */ }
// ── Wiring ────────────────────────────────────────────────────────────
document.querySelectorAll('.info-icon[data-name]').forEach(el => {
el.addEventListener('click', ev => {
ev.stopPropagation();
showMember(el.dataset.name);
});
});
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
document.addEventListener('keydown', e => {
const modal = document.getElementById('memberModal');
if (e.key === 'Escape') { closeModal(); return; }
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
});
}());
```
The four `port` functions transcribe Python literally, with two adjustments:
- `data.tier` — read directly from the API response (already present as
`AdultsMemberData.Tier` / `JuniorsMemberData.Tier`).
- Junior `expected` may equal the literal string `"?"` (sentinel from
`Expected.MarshalJSON`). The status-row formatter must treat string
`expected` as "unknown / single attendance" and skip the numeric
comparisons — same logic Python uses when it sees `"?"`.
`navigateMember` walks `document.querySelectorAll('tr.member-row')` filtered
by `style.display !== 'none'`, finds the index whose `dataset.name`
matches `currentMemberName`, and calls `showMember` on the next/previous
visible row. Identical to Python lines 944-962 but uses `dataset.name`
instead of parsing `childNodes[0].textContent`.
### 3. Wire `data-page` attribute
Append `data-page="adults"` to the `filterContainer` div in `adults.tmpl`,
`data-page="juniors"` in `juniors.tmpl`. One-line edit per template.
### 4. Unit test
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
with assertions that the rendered HTML for `/adults` and `/juniors` contains:
- `class="info-icon"` with the expected `data-name` per fixture row,
- `id="memberModal"`,
- `<script src="/static/js/member-detail.js"`,
- `data-page="adults"` / `data-page="juniors"` on the filter container.
These are markup-level checks only — the JS module behaviour is not unit
tested in Go (would require headless-browser tooling that is out of scope
for this milestone). Manual browser verification covers it.
## Critical files
| Action | Path |
|--------|------|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — add `[i]` icon, modal markup, `data-page`, script tag |
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
| new | `go/internal/web/static/js/member-detail.js` — modal module |
| edit | `go/internal/web/html_handler_test.go` — markup assertions |
No changes to: `app.css` (already complete), `assets.go` (the `embed.FS`
glob `static/js/*` already picks up new files), `server.go`, `render.go`,
the API handlers, or any domain code.
## Reused existing infrastructure
- [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — `/api/adults`, `/api/juniors` already serve the modal's data
- [go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42), [go/internal/web/api/juniors.go](go/internal/web/api/juniors.go) — typed responses
- [go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29) — `Expected` "?" sentinel marshalling
- [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — modal CSS already lifted
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — IIFE / `'use strict'` / `data-*` attribute style to match
- [templates/adults.html:725-962](templates/adults.html#L725-L962) — Python reference implementation to port (logic, not literal copy)
## Verification
End-to-end:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make web-go &` — boot Go on `:8080`.
3. Browser: `http://localhost:8080/adults`
- Click `[i]` next to a member name → modal opens with that member's data.
- Status table populated; cells colour-coded (cell-ok / cell-unpaid).
- Exception section visible only if member has exceptions; exception
amount shown with "*" override style.
- "Other Payments" section visible only if member has other-purpose
transactions.
- Matched transactions list newest-first; raw-payments section starts
hidden, `[show]` toggles to `[hide]`.
- `Esc` closes the modal; `ArrowDown` / `ArrowUp` walk visible rows
(after applying name filter).
- Click outside the `.modal-content` closes the modal.
- Open DevTools Network tab → reload; confirm one request to `/api/adults`
fires on page load (data is pre-warmed), no further requests on row
click.
4. Browser: `http://localhost:8080/juniors`
- Same checks; verify `expected = "?"` rows render the question-mark
status without throwing JS errors.
5. Cross-check against Python: `make web-py &` → `http://localhost:5001/adults`
should look visually equivalent (modulo nav styling) for the same fixture.
6. After commit: `tea pr create --base main --head feat/go-m6-5-modal-js`.
## Branching
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-5-modal-js` off `main`,
commit with `Co-Authored-By` trailer, push with `-u`, open MR via
`tea pr create`. Do not merge from CLI. After the user merges in Gitea:
- Add CHANGELOG entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
- Tick **M6.5** in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:117](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L117)
with the merge SHA.
## Out of scope (deferred)
- **`/qr` modal** — separate `#qrModal` element, lives in M6.6 alongside
`/sync-bank`, `/flush-cache`, `/version`.
- **Pay buttons inside the modal** — Python's modal does not include
Pay-from-modal; current Go pages already render row-level Pay buttons
in M6.2/M6.3.
- **Modal on `/payments`** — Python `/payments` has no modal, neither does
Go (confirmed in M6.4 plan).
- **Deep-linking (`/adults#name=Foo`)** — not in Python, not adding.
- **Headless-browser JS tests** — out of scope for this milestone; manual
browser verification per §Verification.
@@ -0,0 +1,124 @@
# M6.6 — `/qr`, `/sync-bank`, `/flush-cache`, `/version` (Go parity)
> On approval, relocate this file to `docs/plans/YYYY-MM-DD-HHMM-go-m6-6-action-pages.md` per the repo's `CLAUDE.md` "Plans" convention before starting work.
## Context
The Go-rewrite progress tracker ([2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118)) lists M6.6 as:
> `/qr, /sync-bank, /flush-cache, /version pages`
After M6.4 (payments page) and M6.5 (member-detail modal JS), the only remaining pre-deployment HTML/action endpoints in Python that the Go binary doesn't yet implement are these four. The adults/juniors templates already produce `/qr?...` URLs via the `qrHref` / `qrHrefAll` helpers ([render.go:47-71](go/internal/web/render.go#L47-L71)) — the matching handler is the missing piece. `sync` and `flush_cache` templates exist as "Coming in M6.6" stubs and need real markup + working handlers. `/api/version` already serves build metadata; `/version` just needs to alias it (Python parity).
After M6.6 the M6 gate ("Browser smoke on :8080: all pages render … QR loads, sync/flush work end-to-end") is reachable.
## Confirmed design choices
- **`/sync-bank`**: Python-faithful — GET both renders and triggers the sync, captures stdout/stderr into a log buffer, surfaces success/failure via a flag. Matches existing nav `[Sync Bank Data]` link.
- **`/version`**: JSON alias of `/api/version`. No HTML page.
- **QR library**: `github.com/skip2/go-qrcode`. Default level `qrcode.Medium`, size 256.
## Scope per endpoint
### 1. `GET /qr` — PNG only
Match Python ([app.py:321-356](app.py#L321-L356)) verbatim semantics:
1. Read `account` (default `cfg.BankAccount`), `amount` (default `"0"`), `message` (default `""`).
2. Validate account against `^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`; on miss, silently fall back to `cfg.BankAccount`.
3. Parse amount as float64; clamp to `[0, 10_000_000]`; format `%.2f`; on parse error → `"0.00"`.
4. Truncate message to 60 runes; strip all `*`.
5. If account contains `/`, split into `{number}*BC:{bankcode}`; else use raw.
6. SPD payload: `SPD*1.0*ACC:{acc}*AM:{amount}*CC:CZK*MSG:{msg}` (no `BC:` key when IBAN — Python doesn't either).
7. `qrcode.Encode(payload, qrcode.Medium, 256)` → write bytes with `Content-Type: image/png`.
No template, no HTML.
### 2. `GET /sync-bank` — triggers + renders
Match Python ([app.py:124-151](app.py#L124-L151)):
1. Build a `*bytes.Buffer` to capture step output.
2. For each step write a header line then call:
- `banksync.SyncToSheets(ctx, cfg.PaymentsSheetID, fioClient, sheetsClient, banksync.SyncOpts{From: jan1ThisYear, To: dec31ThisYear, Sort: true})`
- `banksync.InferPayments(ctx, cfg.PaymentsSheetID, sheetsClient, sources, banksync.InferOpts{})`
- `cacheFlusher.Flush()` — append `"%d cache files deleted"`.
3. On any error: append `err.Error()` + a stack trace via `runtime/debug.Stack()`, set `success=false`, continue rendering.
4. Render `sync` template with `SyncPageData{ PageData; Output string; Success bool }`.
The `banksync` package's stdout-printing functions can be redirected by passing an `io.Writer` — confirm during implementation; if they only print to `os.Stdout`, capture by tee'ing through a `slog` handler or briefly swapping `os.Stdout` (or extend the package with a `*Writer` option). **Preferred**: add a `Logger io.Writer` field to `banksync.SyncOpts` / `InferOpts`. This is a minor extension to existing services, not a rewrite.
### 3. `/flush-cache` — GET form, POST action
Match Python ([app.py:117-122](app.py#L117-L122)):
- `GET` → render `flush_cache` with `Flushed=false`.
- `POST` → call `cacheFlusher.Flush()`, render with `Flushed=true, Deleted=n`.
### 4. `GET /version` — JSON
One-liner: register the existing `api.VersionHandler` (or its inline equivalent in [api/version.go](go/internal/web/api/version.go)) on the `/version` path as well as `/api/version`. Output: `{"tag", "commit", "build_date"}`.
## Files to create / modify
### New files
- **[go/internal/web/templates/qr.tmpl](go/internal/web/templates/qr.tmpl)** — *not actually needed*; remove from plan if `/qr` is PNG-only. (Decision: skip — no template.)
- Replace stub **[go/internal/web/templates/sync.tmpl](go/internal/web/templates/sync.tmpl)** with full markup: title, status banner colored by `Success`, `<pre>` of `Output`, "Run again" link.
- Replace stub **[go/internal/web/templates/flush_cache.tmpl](go/internal/web/templates/flush_cache.tmpl)** with: form (`POST /flush-cache` → `<button>[Flush Cache]</button>`), success banner when `Flushed`.
### Modified files
- **[go/go.mod](go/go.mod)** / `go.sum` — add `github.com/skip2/go-qrcode`.
- **[go/internal/web/server.go](go/internal/web/server.go)** — register `GET /qr`, `GET /sync-bank` (replace stub), `GET /flush-cache` (form), `POST /flush-cache` (action), `GET /version`. Widen `Run(...)` signature to accept `fio.Client`, `sheets.Client`, and a `cache.Flusher` — these flow into a new `Deps` struct on `HTMLHandler`.
- **[go/internal/web/html_handler.go](go/internal/web/html_handler.go)** — add `ServeQR`, real `ServeSync`, split `ServeFlushCacheGET` / `ServeFlushCachePOST`, add `ServeVersion` (delegates to api package).
- **[go/internal/web/render.go](go/internal/web/render.go)** — add `SyncPageData{ PageData; Output string; Success bool }` and `FlushPageData{ PageData; Flushed bool; Deleted int }` view-models. (`pageNames` already includes `"sync"` and `"flush_cache"`; no change.)
- **[go/internal/web/qr.go](go/internal/web/qr.go)** *(new file)* — pure helper: `BuildSPD(account, amount, message string, defaultAccount string) (payload string)` + `RenderQRCode(payload string) ([]byte, error)`. Easier to unit-test than a method on the handler.
- **[go/internal/services/membership/loader.go](go/internal/services/membership/loader.go)** — add a small `CacheFlusher` interface (`Flush() (int, error)`); have `Sources` aggregate include it. *Alternative considered*: pass `*cache.FileCache` straight into `web.Run`. Choose whichever needs fewer test-stub edits — likely the direct-pass approach, since stubs in tests don't need cache behavior. **Decision: pass `cache.Flusher` directly into `web.Run` as a separate dependency, not through `Sources`.** Simpler, fewer interface changes.
- **[go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)** + `infer.go` — accept an optional `io.Writer` (in `SyncOpts.Logger` / `InferOpts.Logger`) and route progress prints through it; default to `os.Stdout` when nil for CLI parity.
- **[go/cmd/fuj/main.go](go/cmd/fuj/main.go)** — in the `server` subcommand, build `fio.Client`, `sheets.Client`, and `cache.Flusher` (already constructed inside `membership.NewSources` — expose it or construct a sibling) and pass them into `web.Run`.
## Reuse (already in the codebase)
- **Bank sync**: `banksync.SyncToSheets` ([go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)) and `banksync.InferPayments` ([go/internal/services/banksync/infer.go](go/internal/services/banksync/infer.go)) — same calls the CLI `sync` / `infer` subcommands use ([go/cmd/fuj/main.go:184](go/cmd/fuj/main.go#L184), [:222](go/cmd/fuj/main.go#L222)).
- **Cache flush**: `(*cache.FileCache).Flush() (int, error)` ([go/internal/io/cache/filecache.go:92](go/internal/io/cache/filecache.go#L92)) — already returns the deleted count.
- **Build metadata**: `BuildInfo{Version, Commit, BuildDate}` ([go/internal/web/server.go:28-35](go/internal/web/server.go#L28-L35)) is already plumbed everywhere; `/api/version` JSON shape is already correct ([go/internal/web/api/version.go](go/internal/web/api/version.go)).
- **Template render pattern**: `Renderer.Render(w, name, data)` ([go/internal/web/render.go:98-108](go/internal/web/render.go#L98-L108)).
- **Test scaffolding**: `web.NewRenderer()` + `httptest.NewRequest/NewRecorder` ([go/internal/web/html_handler_test.go:52-94](go/internal/web/html_handler_test.go#L52-L94)).
## Tests
Add to `go/internal/web/html_handler_test.go` (and one new `qr_test.go` for the SPD builder):
- **`TestQRBuildSPD`** *(unit, in [go/internal/web/qr_test.go](go/internal/web/qr_test.go))* — table-driven: invalid account → fallback; `/`-account → `{num}*BC:{bc}`; IBAN → raw; amount `-1` → `0.00`; amount `99999999` → `9999999.00`; amount `"abc"` → `0.00`; message with `*` stripped; message length-clamped to 60.
- **`TestServeQR`** — request `/qr?account=…&amount=10&message=Hi`; assert 200, `Content-Type: image/png`, body starts with PNG magic `\x89PNG\r\n\x1a\n`.
- **`TestServeFlushCacheGet`** — assert form rendered, no banner.
- **`TestServeFlushCachePost`** — fixture flusher returns 3; assert banner says "3" and `Flushed`.
- **`TestServeSync`** — fixture banksync stubs return `(2, nil)` and `(1, nil)`, fixture flusher returns 4; assert page renders, status is success, output contains the section headers.
- **`TestServeVersion`** — assert `application/json`, body `{"tag":"v0","commit":"abc1234","build_date":"2026-01-01"}`.
- Extend the `TestHTMLHandlerSmoke` table to cover `/qr` and `/version`.
## Verification (end-to-end)
1. `cd go && go build ./... && go test ./...`
2. `cd go && go run ./cmd/fuj server` — open http://localhost:8080
3. Smoke:
- **/adults** → click `Pay` next to any unpaid month → modal shows QR image (this exercises `/qr`). Confirm a Czech banking app can scan it (visual check).
- Direct `/qr?account=2702008874/2010&amount=700&message=Test` → PNG renders.
- **/sync-bank** → click `[Sync Bank Data]` in nav → page shows "Syncing…", then sections + "Inferred Payments", "Cache Flushed", green status banner.
- **/flush-cache** → click `[Flush Cache]` → success banner with file count.
- **/version** in browser → JSON visible.
- Adults / juniors / payments still render (no regressions from `Run` signature change).
4. Run the parity diff: `go run ./cmd/parity` — `/api/version` must still match Python.
5. Append a CHANGELOG entry per `CLAUDE.md`; tick `M6.6` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118).
## Branch / MR
Per `CLAUDE.md` workflow: `feat/go-m6-6-action-pages` off `main`, push with `-u`, open with `tea pr create --base main --head feat/go-m6-6-action-pages`.
## Out of scope (deferred)
- Auth / CSRF protection on `/sync-bank` and `/flush-cache` — Python has none either; deferred to M8 hardening.
- M6.7 (single-binary embed verification) — separate ticket.
- Streaming progress for `/sync-bank` (currently buffered until completion, like Python).
- Configurable QR error-correction level / size.
@@ -0,0 +1,335 @@
# M6.6.1 — Pay-button QR popup modal (`/adults`, `/juniors`)
> Plan-mode note: per project convention this plan should live at
> `docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md`. The first
> execution step after approval is to copy this file there.
> Branch: `feat/go-m6-6-1-payment-qr-modal` off `main`.
## Context
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](docs/plans/2026-05-08-1334-go-m6-6-action-pages.md))
landed the backend `/qr` PNG endpoint plus `/sync-bank`, `/flush-cache`,
`/version`. While doing it, the **client-side** half of the Pay flow was
collapsed from a modal-with-details into a plain `<a href="/qr?...">` —
clicking Pay now navigates the whole tab to the raw PNG.
The Python app instead opens an in-page `#qrModal` showing the QR image
*plus* labelled details (account, amount, message, title), then closes
the modal on outside-click or `[close]`
([templates/adults.html:631-648](templates/adults.html#L631-L648),
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
This is the UX the user expects from the dashboard — staying on the
table while displaying the QR is significantly more practical than
losing context to a fullscreen image.
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
`#qrModal`, identical in structure to the Python original, driven by a
new vanilla-JS module `payment-qr.js` that mirrors the `member-detail.js`
convention from M6.5.
All the building blocks are already in place:
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
already produce the correct query string + Czech `MM/YYYY` format
for the `message=` param. They're useful for sanity (and for any
no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope.
- **Bank account** is already exposed as `$.Data.BankAccount` to every
template that has a Pay button.
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
inline `onclick`, no globals leaked.
## Approach
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl)
(both have identical Pay markup at lines 65 and 72):
```gotmpl
<button type="button" class="pay-btn"
data-name="{{$row.Name}}"
data-amount="{{$cell.Amount}}"
data-month="{{$cell.Month}}"
data-raw-month="{{$cell.RawMonth}}">Pay</button>
```
```gotmpl
<button type="button" class="pay-btn"
data-name="{{$row.Name}}"
data-amount="{{$row.PayableAmount}}"
data-month="{{$row.UnpaidPeriods}}"
data-raw-month="{{$row.RawUnpaidPeriods}}"
data-pay-all="1">Pay All</button>
```
Why `<button type="button">` and not `<a>`: the click no longer
navigates — the JS handles it. `type="button"` prevents accidental form
submission. A no-JS fallback could be added later by re-introducing
`href="{{qrHref ...}}"` and letting the JS call `e.preventDefault()` —
deferred until anyone actually asks (see §Out of scope).
`data-month` carries the human-readable period (e.g. `January 2026`,
or `January 2026 + February 2026` for Pay-All) which the modal title
displays. `data-raw-month` carries the machine format (`2026-01`,
`2026-01+2026-02`) which the JS converts to `MM/YYYY` for the QR
message — same conversion Python's `showPayQR` does at
[templates/adults.html:966-968](templates/adults.html#L966-L968).
`data-pay-all` is purely informational; the JS doesn't actually need
it (the same code path handles single + multi-period), but keeping it
helps debugging and any future analytics.
### 2. Add `#qrModal` markup
Append after the `#memberModal` block in both `adults.tmpl` and
`juniors.tmpl` (around line 185 of adults, line 165 of juniors), mirroring
[templates/adults.html:631-648](templates/adults.html#L631-L648) but
using `class="active"` toggling (the convention M6.5 picked) instead of
`style.display = 'block'`:
```gotmpl
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for —</div>
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
```
The CSS already targets `#qrModal .modal-content` directly. To make the
"hidden by default / visible when `.active`" toggle behave the same
way `#memberModal` does, the existing CSS rules for `#memberModal`
(`display:none`, `#memberModal.active { display: flex; }`) need a
sibling pair for `#qrModal`. Audit `app.css` during implementation:
- if `#qrModal` already has both rules, no change.
- if it has only `display:none`, add `#qrModal.active { display: flex; }`.
- if it has neither, add both.
This is a 2-line CSS adjustment at most; tracked under §Critical files.
### 3. Bank account on the page
The modal needs the bank account string for the `qrAccount` label and
for building the `/qr?account=…` URL. Two clean options:
**(a)** Read it from the `<button>`'s `data-bank-account` attribute.
Per-button duplication, but trivially done in templates and keeps the JS
module's surface tiny.
**(b)** Stamp it once on the page as `<body data-bank-account="…">`
(or on the existing `#filterContainer`).
**Decision: (b)** — single stamping site, follows the same pattern
M6.5 used for `data-page` on `#filterContainer`. Add
`data-bank-account="{{.Data.BankAccount}}"` to `#filterContainer` in
both templates (one-line edit each).
### 4. Create [go/internal/web/static/js/payment-qr.js](go/internal/web/static/js/payment-qr.js)
A new module loaded with `<script src="/static/js/payment-qr.js" defer></script>`
right after `member-detail.js` in both templates. Pure ES, no build
step, IIFE, `'use strict'`. Skeleton — port of Python's `showPayQR`
([templates/adults.html:963-986](templates/adults.html#L963-L986))
plus event wiring:
```js
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const bankAccount = container.dataset.bankAccount || '';
const modal = document.getElementById('qrModal');
const titleEl = document.getElementById('qrTitle');
const imgEl = document.getElementById('qrImg');
const accountEl = document.getElementById('qrAccount');
const amountEl = document.getElementById('qrAmount');
const messageEl = document.getElementById('qrMessage');
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
// Mirrors templates/adults.html:966-968.
function toCzechMonth(rawMonth) {
return rawMonth.split('+')
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
.join('+');
}
function showQR(name, amount, month, rawMonth) {
const numericMonth = toCzechMonth(rawMonth);
const message = `${name}: ${numericMonth}`;
titleEl.innerText = `Payment for ${month}`;
accountEl.innerText = bankAccount;
amountEl.innerText = amount;
messageEl.innerText = message;
const url = '/qr?'
+ 'account=' + encodeURIComponent(bankAccount)
+ '&amount=' + encodeURIComponent(amount)
+ '&message=' + encodeURIComponent(message);
imgEl.src = url;
modal.classList.add('active');
}
function closeQR() { modal.classList.remove('active'); }
// Event delegation: any .pay-btn click anywhere in the page.
document.addEventListener('click', ev => {
const btn = ev.target.closest('.pay-btn');
if (!btn) return;
ev.preventDefault();
showQR(
btn.dataset.name,
btn.dataset.amount,
btn.dataset.month,
btn.dataset.rawMonth,
);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
});
}());
```
Notes:
- **Event delegation** (single listener on `document`) handles both Pay
and Pay-All without enumerating buttons. It also survives any future
re-render of the table without needing rebinding.
- **Coexistence with `member-detail.js`**: `member-detail.js` listens
for `Escape` too, but only acts when `#memberModal` is active; the
new module similarly only acts when `#qrModal` is active. No collision.
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
and only fires when the user clicks.
- **Inline `onclick`** on `[close]` and outside-click in the markup are
carried over from the M6.5 modal style; consistent with `#memberModal`.
(Switching the project's modal close pattern to data attributes is a
separate cleanup, out of scope here.)
### 5. Tests
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
with markup-level checks. Headless-browser JS is out of scope (same
rationale as M6.5).
In `TestModalMarkup` (or a sibling test, e.g. `TestPaymentQRMarkup`):
- Body for `/adults` and `/juniors` contains
`<script src="/static/js/payment-qr.js"`.
- Body contains `id="qrModal"`, `id="qrImg"`, `id="qrAccount"`,
`id="qrAmount"`, `id="qrMessage"`, `id="qrTitle"`.
- The fixture row's Pay button is rendered as
`<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01"`.
- The fixture row's `.pay-btn` does **not** include `href=`.
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
(matching the test's `fixtureHandler` config).
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
Once Pay/Pay-All buttons are JS-driven, the helpers in
[render.go:63-85](go/internal/web/render.go#L63-L85) have no callers.
**Decision: delete them**, plus their entries in `tmplFuncs`. A
no-JS fallback is not on the roadmap; if it ever becomes one, the
helpers are easy to resurrect from git.
The unit-test file currently has no direct tests for these helpers —
nothing to remove from tests.
## Critical files
| Action | Path |
|--------|------|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — Pay/Pay-All → `<button data-…>`; add `#qrModal`; add `data-bank-account` on filter container; add `<script src="/static/js/payment-qr.js" defer>` |
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
| edit | [go/internal/web/render.go](go/internal/web/render.go) — drop `qrHref`, `qrHrefAll`, and their `tmplFuncs` entries (and the `net/url`, `strconv` imports if no longer needed) |
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — verify / add `#qrModal { display:none; }` + `#qrModal.active { display:flex; }` rules |
| edit | [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go) — markup assertions |
No changes to: `assets.go` (`embed.FS` glob picks up new JS automatically),
`server.go`, `html_handler.go`, the `/qr` handler, `cmd/fuj/main.go`, the
API handlers, or any domain code.
## Reused existing infrastructure
- [go/internal/web/qr.go](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
## Verification
End-to-end, after copying the plan into `docs/plans/`:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make web-go &` — boot Go on `:8080`.
3. Browser: `http://localhost:8080/adults`
- Click `Pay` on any unpaid month → `#qrModal` opens with
- title `Payment for <Month YYYY>`,
- `qrAccount` matching `BANK_ACCOUNT`,
- `qrAmount` matching the cell amount,
- `qrMessage` formatted as `<Name>: MM/YYYY`,
- QR image rendered from `/qr?…` (Network tab shows PNG response).
- Tab does **not** navigate; URL stays `/adults`.
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
- Click outside the modal-content → modal closes.
- Click `[close]` → modal closes.
- Press `Esc` → modal closes.
- Open `[i]` member-detail modal first, then `Esc` → only that closes
(verify both modals don't fight each other).
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
on a `"?"`-expected row doesn't appear (Pay button is conditional on
`unpaid`/`partial` cells).
5. Cross-check against Python: `make web-py &` →
`http://localhost:5001/adults` modal looks visually equivalent for
the same fixture (modulo any nav / theme differences already accepted
in M6).
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
payload starts with `SPD*1.0*ACC:` and the message decodes to
`<Name>: MM/YYYY`.
7. After the user merges in Gitea: append CHANGELOG entry (timestamp via
`date "+%Y-%m-%d %H:%M %Z"`); add a new tick line for **M6.6.1** in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
immediately under the M6.6 line, in the same format as the other
sub-bullets.
## Branching
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-6-1-payment-qr-modal` off
`main`, commit with `Co-Authored-By` trailer, push with `-u`, open MR via
`tea pr create`. Do not merge or delete from CLI.
## Out of scope (deferred)
- **Pay button no-JS fallback** — feasible to keep `qrHref` and have
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
helpers if asked.
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
inline `onclick` for `[close]` and outside-click. Consistent with
M6.5 but worth a follow-up to delegate-event everything.
- **Pay-from-modal inside `#memberModal`** — Python doesn't have it, and
no one's asked for it on the Go side.
- **Per-button QR error-correction / size** — `/qr` is fixed at
Medium/256, same as M6.6. Configurable via query param can be added if
printing larger physical posters becomes a use case.
- **`/payments` Pay buttons** — the payments page is a ledger view; it
doesn't render Pay buttons in either backend.
@@ -0,0 +1,184 @@
# M6.7 — Single-binary embed verification
## Context
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire
`embed.FS` into handlers; verify single-binary deployment includes all
assets."
The wiring is **already in place** from M6.1 onward:
- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares
`//go:embed templates` → `templateFS` and `//go:embed static` → `staticFS`.
- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66)
parses every page template via `template.New(...).ParseFS(templateFS, ...)`.
- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68)
serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled
binary into the `alpine:3` runtime image — no `templates/` or `static/`
directory ever lands beside it.
What is missing is **proof** the embed is complete and stays complete:
1. Nothing fails the build/test if a contributor adds a new file under
`internal/web/templates/` or `internal/web/static/` that isn't matched
by an `//go:embed` glob (or, more realistically, adds a sibling
directory like `static/img/` and the glob still picks it up — but a
typo'd directive would silently drop it).
2. No automated test exercises the `/static/*` route against the embedded
FS — current tests in
[html_handler_test.go](../../go/internal/web/html_handler_test.go)
render templates (which proves `templateFS` is good) but never hit a
static URL through the mux.
3. The "single binary, no working-dir assets" property is undocumented —
if it ever broke, no one would notice until the Docker image started
500'ing in prod.
The intended outcome: a small test file plus a documented manual
verification step, after which M6.7 can be ticked and M6 closed.
## Plan
### 1. Add `go/internal/web/assets_test.go`
One new file, two tests, no production code changes.
**Test A — embed completeness regression guard.** Walks the on-disk
`templates/` and `static/` directories and asserts every regular file is
also present in the corresponding embedded FS. Catches:
- A new template added without updating the `//go:embed` directive
(current globs are `templates` and `static` — recursive by default for
directories, so this is a low-probability regression, but the test
doubles as living documentation of the contract).
- A typo in the directive (e.g. someone renames `static` → `assets` in
one place but not the other).
Implementation sketch:
```go
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskFS fs.FS // os.DirFS("templates") / os.DirFS("static")
embed fs.FS // exported helper or via internal test in package web
root string
}{...}
for _, tc := range cases {
_ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { return err }
embPath := tc.root + "/" + path
if _, err := fs.Stat(tc.embed, embPath); err != nil {
t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err)
}
return nil
})
}
}
```
Because `templateFS` and `staticFS` are unexported package vars, this
test lives in `package web` (not `web_test`) — sibling to
[assets.go](../../go/internal/web/assets.go). All the existing handler
tests are in `package web_test`; that's fine, this one is internal.
**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux`
with the same wiring as
[server.go:68](../../go/internal/web/server.go#L68), fires httptest
requests, asserts:
- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`,
body contains a known string from app.css (e.g. a CSS selector).
- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with
`text/javascript` or `application/javascript`, body non-empty.
- `GET /static/js/payment-qr.js` → 200, body non-empty.
- `GET /static/css/missing.css` → 404 (sanity: the file server actually
rejects unknown paths instead of returning some default).
Rather than duplicate the mux assembly, factor a tiny helper (or test the
existing mux). The cleanest move: extract `staticHandler()` from
[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small
exported-from-package function or just `staticFS` / `fs.Sub` helper, and
have the test call it. Smallest delta: keep production code unchanged and
replicate the two-line wiring inside the test file (acceptable — it's
two lines and the test exists precisely to lock that contract).
### 2. Manual / one-shot verification (no code; documented in plan only)
Run once locally and tick M6.7. Command transcript:
```bash
make go-build # → ./bin/fuj
cp bin/fuj /tmp/fuj-standalone
cd /tmp # working dir has no templates/ or static/
./fuj-standalone server &
SERVER_PID=$!
sleep 1
curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard"
curl -sf http://localhost:8080/juniors | grep -q "Juniors"
curl -sf http://localhost:8080/payments | grep -q "Payments Ledger"
curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \
&& test -s /tmp/app.css
curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \
&& test -s /tmp/qr.js
kill $SERVER_PID
```
`fuj server` will fail to talk to Sheets without credentials, so the
`/adults` etc. pages will render with the `Error` field set — that's
fine; the assertion is that the **template + static asset pipeline** is
self-contained, not that data loads. Each curl above only checks for
markup present in every render path (header text and stylesheet body).
### 3. Tracker + changelog
- Tick `M6.7` in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120),
append the merge SHA on the line.
- Mark "Last updated" date and bump milestone status: M6 complete, next is
M7.
- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`).
## Files touched
| File | Change |
| --- | --- |
| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) |
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" |
| [CHANGELOG.md](../../CHANGELOG.md) | new top entry |
No production source files change. (If extracting the static handler
reads cleaner, a 4-line refactor in
[server.go](../../go/internal/web/server.go) is acceptable but optional.)
## Branch + MR
Per project convention this is a feature, so:
```bash
git checkout -b feat/go-m6-7-embed-verify
# … commits …
git push -u origin feat/go-m6-7-embed-verify
tea pr create --title "feat(go): M6.7 — single-binary embed verification" \
--description "<short body referencing M6.7>" --base main \
--head feat/go-m6-7-embed-verify
```
## Verification
After implementation:
1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass).
2. `make go-lint` → clean.
3. Run the manual transcript in §2 above — all curls succeed, no
"template not found" or 404 on static assets.
4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/` →
succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults`
with stylesheet attached (visual smoke test in browser).
## Out of scope
- Re-architecting how templates are parsed or served.
- Compressing / fingerprinting static assets (a separate concern).
- Live integration test with real Sheets data — covered later in M7.
@@ -0,0 +1,184 @@
# Fill-first multi-month payment allocation
## Context
Matyáš Thér paid in two transactions:
| # | Amount | Purpose |
|---|--------|---------------------|
| 1 | 200 | `2026-02` |
| 2 | 550 | `2026-02, 2026-03` |
Total 750 = his expected fee for the two months (likely 2026-02 = 500, 2026-03 = 250 — junior tier or exception-adjusted). The app currently shows 2026-02 = **566** paid, 2026-03 = **183** paid. The user wants:
> First use the second payment for the rest of 2026-02 (no more, no less), then put the remainder toward 2026-03.
Both Python ([scripts/match_payments.py](scripts/match_payments.py)) and Go ([go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go)) have the same bug: when a single transaction's `in_window_share` is less than the sum of in-window expected fees, both fall into a **proportional** branch that splits the new transaction across months in proportion to each month's *total* expected fee — never consulting what prior transactions already paid into earlier months.
Trace for txn 2:
- `in_window = [(2026-02, 500), (2026-03, 250)]`, `total_expected = 750`, `in_window_share = 550`.
- `550 < 750` → proportional:
- 02 alloc = `550 × 500 / 750 = 366.67`
- 03 alloc = `550 − 366.67 = 183.33`
- Combined with txn 1's 200 → 02 = 566.67, 03 = 183.33 → display `566 / 183`. ✓ matches reported numbers.
The fix: replace the greedy + proportional branches with a single **fill-first** loop that iterates `in_window` in user-supplied order (already chronological by convention from [scripts/infer_payments.py:151](scripts/infer_payments.py#L151)) and allocates `min(remaining, max(0, expected − paid_so_far))` to each month, with any final surplus going to the credit bucket. This collapses three cases (greedy / proportional / pure overflow) into one and naturally consults the ledger's `paid` field which is already updated by prior transactions in the same reconcile pass.
The even-split branch (`total_expected == 0`, prepayment before fees known) stays untouched — different semantic, folding it in would silently change behavior.
## Changes
### Python — [scripts/match_payments.py:471-498](scripts/match_payments.py#L471-L498)
Replace both `total_expected > 0` branches (current lines 471–498) with a single unified loop. Keep lines 466–469 above and the even-split fallback below (lines 499–510) as-is.
```python
if total_expected > 0:
# Fill-first: iterate in_window in matched_months order (chronological by
# convention), allocate min(remaining, deficit) to each month, where
# deficit accounts for prior transactions already credited to that month.
# Any surplus after all in-window deficits are covered → credit bucket.
remaining = in_window_share
for m, exp in in_window:
paid_so_far = ledger[member_name][m]["paid"]
deficit = max(0.0, float(exp) - paid_so_far)
alloc = min(remaining, deficit)
if alloc <= 0:
continue
ledger[member_name][m]["paid"] += alloc
ledger[member_name][m]["transactions"].append({
"amount": alloc,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
})
remaining -= alloc
if remaining > 0:
credits[member_name] = credits.get(member_name, 0) + int(remaining)
else:
# … existing even-split branch (lines 499–510) unchanged …
```
Note: skipping the `transactions.append` when `alloc <= 0` (e.g. month already fully paid by a prior txn) avoids zero-amount ghost rows in the per-month transaction list. This is a small UI-visible side effect; before committing, grep tests for assertions on `len(transactions)` per month to confirm nothing relies on the current "one row per (txn, month) regardless of alloc" behavior.
### Go — [go/internal/domain/reconcile/reconcile.go:320-357](go/internal/domain/reconcile/reconcile.go#L320-L357)
Same shape — replace both `totalExpected > 0` branches. Even-split branch (lines 358–372) stays.
```go
if totalExpected > 0 {
// Fill-first; see Python reconcile() for rationale.
remaining := inWindowShare
for _, mw := range inWindow {
md := ledger[memberName][mw.month]
deficit := float64(mw.expected) - md.Paid
if deficit < 0 {
deficit = 0
}
alloc := remaining
if deficit < alloc {
alloc = deficit
}
if alloc <= 0 {
continue
}
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
remaining -= alloc
}
if remaining > 0 {
credits[memberName] += int(remaining)
}
} else {
// … existing even-split branch (lines 358–372) unchanged …
}
```
### Tests
#### Python — [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py)
1. **Rewrite `test_proportional_underpayment`** ([line 96](tests/test_reconcile_exceptions.py#L96)) — its current assertions (`paid_02 < 750`, `paid_03 < 350`, `paid_04 < 750`, and 02/04 equal allocation) are incompatible with fill-first. Under fill-first with the same fixture (1250 across `02:750, 03:350, 04:750`):
- 02: `min(1250, 750) = 750` (full) → remaining 500
- 03: `min(500, 350) = 350` (full) → remaining 150
- 04: `min(150, 750) = 150` (partial) → remaining 0
Replace assertions with these exact expected values, rename to `test_underpayment_fills_earliest_first`.
2. **Add `test_fill_first_across_two_transactions`** — the Matyáš regression:
```python
def test_fill_first_across_two_transactions(self):
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
sorted_months = ['2026-02', '2026-03']
tx1 = _tx('Matyáš', '2026-02', 200)
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
result = reconcile(members, sorted_months, [tx1, tx2])
months = result['members']['Matyáš']['months']
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
```
3. `test_greedy_exact_match`, `test_greedy_overpayment_goes_to_credit`, `test_single_month_unchanged`, `test_two_members_multi_month` should pass unchanged — fill-first agrees with greedy when payment ≥ total expected.
#### Go — [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go)
- Add `TestUnderpaymentFillsEarliestFirst` mirroring the rewritten Python test.
- Add `TestFillFirstAcrossTwoTransactions` mirroring the Matyáš scenario.
#### Parity — [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go)
Add a fixture for the Matyáš two-transaction case. Since both implementations change together and Python remains canonical, existing parity fixtures should continue to pass; verify after edits.
### Changelog — [CHANGELOG.md](CHANGELOG.md)
Append top entry (use `date "+%Y-%m-%d %H:%M %Z"` at commit time):
```markdown
## 2026-05-11 23:55 CET — fill-first multi-month payment allocation
- Multi-month payment allocation now fills the earliest in-window deficit first
and spills the remainder to later months, accounting for prior transactions'
contributions. Previously a single transaction was split proportionally to
each month's total expected fee, ignoring earlier payments — surfaced by
Matyáš Thér's two-payment 200+550 case showing 566/183 instead of 500/250.
- scripts/match_payments.py, go/internal/domain/reconcile/reconcile.go, tests.
```
## Critical files
- [scripts/match_payments.py](scripts/match_payments.py) — Python reconcile (canonical)
- [go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go) — Go reconcile (mirrors Python)
- [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py) — rewrite `test_proportional_underpayment` + add Matyáš test
- [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go) — add Go tests
- [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go) — add parity fixture
- [CHANGELOG.md](CHANGELOG.md) — top entry
## Verification
1. `make test` — Python unit tests including the rewritten + new fill-first tests.
2. `cd go && go test ./internal/domain/reconcile/... ./tests/parity/reconcile/...` — Go unit + parity tests.
3. `make web` → load `/adults` or `/juniors` (whichever lists Matyáš Thér) → his 2026-02 row should be fully paid (no shortfall), 2026-03 fully paid, no leftover credit.
4. Spot-check one other multi-month-purpose member to make sure fully-covered cases still look right.
## Branch & MR (per CLAUDE.md)
1. `git checkout -b fix/fill-first-multi-month-allocation`
2. Apply edits + tests + CHANGELOG entry.
3. `make test && (cd go && go test ./...)` — both green.
4. Commit: `fix(reconcile): fill earliest month deficit first in multi-month allocations` with `Co-Authored-By` trailer.
5. `git push -u origin fix/fill-first-multi-month-allocation`
6. `tea pr create --title "fix(reconcile): fill earliest month deficit first" --description "<body>" --base main --head fix/fill-first-multi-month-allocation`
7. Print MR URL. Do not merge from CLI.
@@ -0,0 +1,269 @@
# Multi-account Fio sync + switch QR target to 2502035405/2010
## Context
The club is opening a second Fio transparent account, **`2502035405/2010`** (IBAN
`CZ??...0000002502035405`). Going forward:
- **Sync** must pull transactions from **both** accounts into the existing
payments sheet — the old `2800359168/2010` stays active so historical fees
paid to it still reconcile.
- **QR codes** generated by `/qr` should default to the **new** account
(`2502035405/2010`), so new payers send money there.
The payments sheet schema and `sync_id` hash stay **unchanged** — no
"Source Account" column, no migration, no risk of re-appending historical rows.
Bank-ID uniqueness from Fio plus the existing hash inputs are good enough to
dedupe across two transparent accounts in practice.
Implementation order: Go first, then port to Python. Both apps use the same
`PaymentsSheetID`, so once Go writes from two accounts the Python code only
needs to do the same when invoked.
### Decisions locked in with the user
- **Config style:** hardcoded list of accounts in `config.go` / `config.py`
(sheet IDs are already hardcoded; tokens stay env-driven).
- **Source tagging:** no schema change, `sync_id` formula unchanged.
- **Old account:** kept syncing in parallel; only QR target changes.
---
## Go implementation
### 1. Config — hardcoded `Accounts` list, derived `QRAccount`
[go/internal/config/config.go](../../go/internal/config/config.go)
- Replace scalar `BankAccount` + `FioAPIToken` with a hardcoded slice and a
primary pointer. Tokens stay env-driven via per-account env names.
```go
// Account describes one Fio bank account we sync from.
type Account struct {
IBAN string // e.g. "CZ0820100000002502035405"
AcctNum string // bare account number, e.g. "2502035405"
TokenEnv string // env var name holding the Fio API token (empty token => transparent scraper path)
Primary bool // true for the QR-code default account
}
var Accounts = []Account{
{IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true},
{IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"},
}
```
Compute the **real** IBAN check digit for `2502035405` once and bake it in
(replace the `85` placeholder above). The same `IBANAccountNum` helper at
[config.go:66](../../go/internal/config/config.go#L66) keeps producing
`AcctNum`, but having it on the struct avoids re-parsing at every call site.
- `Config` struct:
- Drop `BankAccount` and `FioAPIToken` fields.
- Add `QRAccount string` (the IBAN of `Accounts[i].Primary == true`).
- Add `LoadedAccounts []LoadedAccount` where each entry pairs an `Account`
with the token resolved from `os.Getenv(account.TokenEnv)`.
- `Load()` reads each `TokenEnv` and stores tokens with the account. Logs a
warning (info-level) if no token is set — that's the transparent-scraper
path, which is fine.
### 2. Sync loop — one `fio.Client` per account, single dedup pass
[go/internal/services/banksync/sync.go](../../go/internal/services/banksync/sync.go)
Change `SyncToSheets` to accept a slice of clients instead of one:
```go
func SyncToSheets(
ctx context.Context,
spreadsheetID string,
fioClients []fio.Client, // was: fioClient fio.Client
sh sheetsWriter,
opts SyncOpts,
) (int, error)
```
Inside:
- Keep steps 1 (read existing IDs) and 2 (date window) as-is.
- Step 3 (fetch) becomes a loop: call `FetchTransactions` on each client; tag
each `fio.Transaction` with the account it came from for logging only
(don't write it to the sheet). Concatenate into one slice `txns`.
- Steps 4a–4c (sync_id calc, debug table, row build, dedup) are unchanged —
the combined `txns` slice flows through the same code path. Existing
`sync_id` collisions across accounts are dropped silently, which is the
desired dedup behaviour.
- `printFioTable` (dry-run debug) should print an extra column showing the
source account so per-account fetches are visible — small change in
[fio_table.go](../../go/internal/services/banksync/fio_table.go).
Logger output: log fetched count per account and the total combined count.
### 3. Call sites — build clients from the config list
Both entry points construct `[]fio.Client`, one per `LoadedAccount`:
- [go/cmd/fuj/main.go:91](../../go/cmd/fuj/main.go#L91) — server `BankSync` action.
- [go/cmd/fuj/main.go:200](../../go/cmd/fuj/main.go#L200) — `fuj sync` CLI.
Helper to add somewhere in `cmd/fuj/main.go` or a new
`internal/services/banksync/clients.go`:
```go
func clientsFromConfig(cfg config.Config) []fio.Client {
out := make([]fio.Client, 0, len(cfg.LoadedAccounts))
for _, a := range cfg.LoadedAccounts {
out = append(out, fio.New(a.Token, a.AcctNum, nil))
}
return out
}
```
The existing factory at
[go/internal/io/fio/client.go:35](../../go/internal/io/fio/client.go#L35)
already routes per-account between the API path and the transparent scraper
based on whether the token is empty — no change there.
### 4. QR endpoint — default to `QRAccount`
[go/internal/web/html_handler.go:129](../../go/internal/web/html_handler.go#L129)
and [html_handler.go:135](../../go/internal/web/html_handler.go#L135):
swap `h.apiHandler.Config.BankAccount` → `h.apiHandler.Config.QRAccount`.
API DTOs that surface the payment account in JSON:
[build_adults.go:141](../../go/internal/web/api/build_adults.go#L141) and
[build_juniors.go:137](../../go/internal/web/api/build_juniors.go#L137) — same
swap.
The fields on the API structs at
[adults.go:40](../../go/internal/web/api/adults.go#L40) and
[juniors.go:39](../../go/internal/web/api/juniors.go#L39) keep their JSON
names so the Python parity binary doesn't see a schema diff.
### 5. Tests
- **New** [go/internal/services/banksync/sync_test.go](../../go/internal/services/banksync/sync_test.go):
add a test `TestSyncToSheets_MultiAccount` that wires up two fake
`fio.Client`s (via the existing
[fake.go](../../go/internal/io/fio/fake.go)), one returning a txn unique to
it and one returning a txn that duplicates an existing sync_id. Assert:
rows from both accounts get appended, the duplicate is dropped.
- **Update** [go/internal/web/html_handler_test.go:207](../../go/internal/web/html_handler_test.go#L207)
`TestServeQR` and [qr_test.go](../../go/internal/web/qr_test.go)
`TestQRBuildSPD` — expected SPD strings now contain the new IBAN by default.
- **Update** any config tests asserting `cfg.BankAccount` directly to use
`cfg.QRAccount` and the new `LoadedAccounts` slice. (Grep for `BankAccount`
under `go/` after the rename.)
- Existing fio parser tests
([fio_test.go](../../go/internal/io/fio/fio_test.go)) and sync_id test
([synch_test.go](../../go/internal/domain/synch/synch_test.go)) need no
changes — they don't know about the account list.
### 6. Verification (Go)
1. `cd go && go build ./... && go test ./...` — all green.
2. `FIO_API_TOKEN_NEW="" FIO_API_TOKEN_OLD="" ./build/fuj sync --dry-run --print-fio-table --days 7`
— see fetched transactions from **both** accounts, with NEW/DUP status,
without writing.
3. `./build/fuj server` then visit
`http://localhost:8080/qr?amount=700&message=test` — QR payload SPD must
contain the new IBAN `CZ??...2502035405`.
4. Real sync once dry-run looks right: `./build/fuj sync --days 30` — confirm
in the Google sheet that rows from the old account still appear and no
duplicates are written.
---
## Python port (after Go is merged)
The Python app uses the same payments sheet and the same column schema, so
the port mirrors the Go change one-for-one. Goal: one round-trip of
`make sync-2026` pulls from both accounts.
### 1. `scripts/config.py`
- Replace the scalar `BANK_ACCOUNT` constant at
[scripts/config.py:25](../../scripts/config.py#L25) with a hardcoded list
plus a derived primary:
```python
ACCOUNTS = [
{"iban": "CZ0820100000002502035405", "acct_num": "2502035405", "token_env": "FIO_API_TOKEN_NEW", "primary": True},
{"iban": "CZ8520100000002800359168", "acct_num": "2800359168", "token_env": "FIO_API_TOKEN_OLD", "primary": False},
]
BANK_ACCOUNT = next(a["iban"] for a in ACCOUNTS if a["primary"]) # QR / template default
```
Use the real check-digit IBAN for `2502035405` (the same one baked into Go).
- Resolve tokens at module load time with `os.environ.get(token_env, "")` and
stash them on a parallel `LOADED_ACCOUNTS` list (or attach to the dicts) so
other modules don't all re-read env.
### 2. `scripts/fio_utils.py`
- [fio_utils.py:106](../../scripts/fio_utils.py#L106) and
[fio_utils.py:219](../../scripts/fio_utils.py#L219): drop the hardcoded
`"2800359168"` default for `account_id`. Make it a required argument.
- `fetch_transactions(date_from, date_to, *, account)` — change signature to
take one account dict, return that account's transactions. Internally still
routes via `FIO_API_TOKEN` if the account dict has a token, otherwise the
transparent scraper at
[fio_utils.py:105](../../scripts/fio_utils.py#L105).
- Add `fetch_transactions_all(date_from, date_to, accounts=None)` that loops
over `accounts` (default `config.LOADED_ACCOUNTS`), calls the per-account
fetch, concatenates results. Logs per-account counts.
### 3. `scripts/sync_fio_to_sheets.py`
- Where it currently calls `fio_utils.fetch_transactions(...)`, switch to
`fio_utils.fetch_transactions_all(...)`. The dedup at
[sync_fio_to_sheets.py:62](../../scripts/sync_fio_to_sheets.py#L62) and
append at
[sync_fio_to_sheets.py:248](../../scripts/sync_fio_to_sheets.py#L248) need
no changes — same as in Go, the combined list flows through unchanged.
### 4. `app.py` (QR + template defaults)
- `/qr` at [app.py:321](../../app.py#L321) already reads `BANK_ACCOUNT` from
config; since the config default now points at the new IBAN, this is a
zero-line change in `app.py`.
- The `BANK_ACCOUNT` passed into templates at
[app.py:180](../../app.py#L180), [app.py:204](../../app.py#L204),
[app.py:255](../../app.py#L255), [app.py:291](../../app.py#L291) likewise
picks up the new default automatically.
### 5. Tests
The Python suite has **no** existing coverage of `fio_utils`,
`sync_fio_to_sheets`, or `/qr`. Don't add new tests — keep the port minimal
and rely on the Go test suite + manual verification for correctness.
### 6. Verification (Python)
1. `make test` — existing suite stays green (the config refactor must not
break [tests/test_app.py](../../tests/test_app.py)).
2. `make sync-2026` against the real sheet, dry-run first if the script
supports it, then real run — confirm rows from both accounts arrive.
3. `make web-debug` → open `/qr?amount=700&message=test` → QR payload SPD
contains the new IBAN.
---
## Branching
Per `CLAUDE.md`: this is a feature, so `feat/multi-account-bank-sync` off
`main`. Two MRs:
1. `feat/multi-account-bank-sync-go` — Go change end-to-end.
2. `feat/multi-account-bank-sync-py` — Python port, opened after (1) is
merged.
Each MR opened via `tea pr create`. CHANGELOG.md entry per MR.
## Out of scope
- No sheet schema migration.
- No change to `sync_id` hash inputs at
[domain/synch/synch.go:14](../../go/internal/domain/synch/synch.go#L14) or
[sync_fio_to_sheets.py:67](../../scripts/sync_fio_to_sheets.py#L67).
- No UI surface for switching the QR account per-request (the existing
`?account=` query param on `/qr` keeps working as an override).
- No backfill / dedup pass over historical rows.
@@ -0,0 +1,90 @@
# Fix `/juniors` 500: `int + str` in reconcile allocation
## Context
The `/juniors` route crashes with:
```
File ".../scripts/match_payments.py", line 469, in reconcile
total_expected = sum(e for _, e in in_window)
TypeError: unsupported operand type(s) for +: 'int' and 'str'
```
**Why it happens:** A junior with exactly **1 session** in a month gets an
expected fee of the string `"?"` (manual-review marker), set in
[attendance.py:107](scripts/attendance.py#L107). That value flows unchanged into
the ledger as `ledger[name][m]["expected"]`
([match_payments.py:370](scripts/match_payments.py#L370)).
When a bank payment is matched to such a junior+month, the new **fill-first
allocation** block builds `in_window` from those `expected` values and then sums
them ([match_payments.py:469](scripts/match_payments.py#L469)) and later does
`float(exp)` ([match_payments.py:480](scripts/match_payments.py#L480)) — both
blow up on the string `"?"`. Adults never hit this because adults never get
`"?"`, which is why only `/juniors` 500s.
Note the final-balance code at
[match_payments.py:511-512](scripts/match_payments.py#L511-L512) **already**
handles this defensively (`mdata["expected"] if isinstance(..., int) else 0`).
The allocation block added in the recent `fill-first` work simply missed the
same guard. The intended convention is clear: a `"?"` (unknown) expected counts
as **0** for arithmetic, so any payment landing on such a month becomes surplus
/ positive balance rather than filling a deficit.
## Approach
Add one small helper and apply the existing "non-numeric expected → 0"
convention consistently inside `reconcile()`.
### 1. Helper (near the top of `scripts/match_payments.py`)
```python
def _expected_amount(value):
"""Numeric value of an 'expected' fee; non-numeric markers like '?' → 0."""
return value if isinstance(value, (int, float)) else 0
```
### 2. Apply it in the allocation block
- [match_payments.py:469](scripts/match_payments.py#L469):
`total_expected = sum(_expected_amount(e) for _, e in in_window)`
- [match_payments.py:480](scripts/match_payments.py#L480):
`deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)`
With expected coerced to 0 for `"?"` months:
- A `"?"`-only payment falls into the existing `total_expected == 0` fallback
([match_payments.py:495-499](scripts/match_payments.py#L495-L499)) and is
recorded as paid (prepayment behaviour) → shows as positive balance.
- Mixed with real months, the `"?"` month gets deficit 0 (skipped), real months
fill first, surplus → credit. Consistent with line 512.
### 3. Reuse the helper at line 512 (optional consistency tidy)
Replace the inline `isinstance(mdata["expected"], int)` check at
[match_payments.py:512](scripts/match_payments.py#L512) with
`_expected_amount(mdata["paid"]/expected)` form, i.e. use `_expected_amount(...)`.
This also closes a latent gap where a **float** exception amount would be treated
as 0 (current check only accepts `int`).
`print_report` (line 552+) iterates adults only, so it's unaffected and needs no
change.
## Verification
1. `make web` and open `http://localhost:5001/juniors` — page renders 200 (was
500). Confirm a junior with a single-session `"?"` month who also has a
matched payment shows a sensible balance.
2. `/adults` still renders unchanged (regression check).
3. `make test` — existing reconcile tests still pass; if there is a
reconcile test fixture, add a case where a junior month has `expected == "?"`
plus a matched transaction and assert no exception + payment counted as
surplus.
## Notes
- Single-file change: [scripts/match_payments.py](scripts/match_payments.py).
- This is a bug fix; per CLAUDE.md a small fix may go straight to `main`, but
confirm with the user whether to branch (`fix/junior-expected-question-mark`).
- Add a CHANGELOG.md entry once confirmed working.
- On execution, copy this plan to `docs/plans/<ts>-junior-expected-fix.md` per
the repo plan convention.
@@ -0,0 +1,146 @@
# Plan: Limit /adults and /juniors to last N months (default 5)
## Context
The `/adults` and `/juniors` dashboard tables render one column per month of
attendance/fee history. As the season accumulates months, the tables grow wider
than the screen. The goal is to show only the **last N months** by default
(N = 5), with N configurable via an env var, so the tables fit on screen. This
must be implemented in **both** the Python/Flask version and the Go version,
keeping their behavior identical.
### Key correctness requirement
Member **balances, credits, and debts** must continue to reflect *all* history,
not just the visible window. Hiding older columns must not hide older debt.
This is naturally satisfied because in both codebases the balance math iterates
over the full per-member month map produced by reconcile, while only the
*column rendering* iterates over the passed-in month list:
- Python `scripts/views.py`: `settled_balance` / `_settled_balance` /
credits / debts loop over `data["months"].items()` (full), whereas columns,
totals, and per-row cells loop over `sorted_months`.
- Go `build_adults.go` / `build_juniors.go`: `settledBalance(mr, ...)` loops
over `mr.Months` (full); columns/totals/cells loop over `sortedMonths`.
Therefore the correct seam is: run `reconcile()` / `Reconcile()` on the **full**
month list, then trim the list to the last N **only for the view-model builder**.
The member-details modal also keeps full history because it reads the untrimmed
`member_data` / `MemberData`.
## Approach
Add a `MONTHS_TO_SHOW` tunable (default 5; `<= 0` means "show all" as an escape
hatch). Trim `sorted_months`/`sortedMonths` to the last N immediately before the
view-model builder, leaving reconcile on the full list.
### Python
1. **`scripts/config.py`** — add, next to the existing `CACHE_TTL_SECONDS`
pattern (`int(os.environ.get(...))`):
```python
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5))
```
2. **`app.py`** — add a small helper (module-level) and apply it in all four
routes that build the adults/juniors view models:
```python
from config import MONTHS_TO_SHOW # add to existing config import
def _last_n_months(months):
return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months
```
In each route, keep `reconcile(members, sorted_months, ...)` on the full
list, then pass the trimmed list to the builder:
```python
result = reconcile(members, sorted_months, transactions, exceptions)
display_months = _last_n_months(sorted_months)
vm = build_adults_view_model(members, display_months, result, ...)
```
Apply to: `adults_view()` (~`app.py:226`), `juniors_view()` (~`app.py:260`),
and the JSON twins `/api/adults` (~`app.py:161`) and `/api/juniors`
(~`app.py:184`) for parity.
No changes to `scripts/views.py` — it already derives `months`,
`raw_months`, `totals`, and per-row `row.months` from whatever month list it
receives, and balances/credits/debts from the full `result`.
### Go
1. **`go/internal/config/config.go`** — add `MonthsToShow int` to the `Config`
struct (~line 57-67), populate it in `Load()` (~line 80-90) with a new
integer helper modeled on `envDuration`:
```go
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
```
```go
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
```
(Note: unlike `envDuration`, accept `<= 0` so it can mean "show all".)
2. **`go/internal/web/api/handler.go`** — in `AssembleAdults`
(lines 50-57) and `AssembleJuniors` (lines 71-78), keep `Reconcile` on the
full `sortedMonths`, then trim before the builder:
```go
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
displayMonths := lastNMonths(sortedMonths, h.Config.MonthsToShow)
return buildAdultsResponse(members, displayMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
```
Add a small helper (e.g. in handler.go):
```go
func lastNMonths(months []string, n int) []string {
if n > 0 && len(months) > n {
return months[len(months)-n:]
}
return months
}
```
No changes to `build_adults.go` / `build_juniors.go` or the templates — they
already derive `Months`, `RawMonths`, `Totals`, and per-row cells from the
passed-in `sortedMonths`, and balances/credits/debts from the full
`result.Members[...]`.
## Critical files
- `scripts/config.py` — new `MONTHS_TO_SHOW` constant.
- `app.py` — trim helper + apply in 4 routes (HTML + JSON, adults + juniors).
- `go/internal/config/config.go` — `MonthsToShow` field + `envInt` helper.
- `go/internal/web/api/handler.go` — `lastNMonths` helper + apply in
`AssembleAdults` / `AssembleJuniors`.
No template or `views.py` / `build_*.go` changes required.
## Verification
Python:
- `make test` (and a targeted run, e.g.
`PYTHONPATH=scripts:. python -m unittest tests.test_app`).
- `make web`, open `/adults` and `/juniors`: confirm exactly 5 month columns by
default, and that the **Balance** column / credits / debts are unchanged from
before (compare against an untrimmed run, e.g. `MONTHS_TO_SHOW=0`).
- `MONTHS_TO_SHOW=3 make web` → 3 columns; `MONTHS_TO_SHOW=0` → all columns.
- Spot-check that the month-range filter dropdowns and a member-details modal
(full history) still work.
Go:
- `cd go && go build ./... && go test ./...`.
- Run the Go server, open `/adults` and `/juniors`: same checks as above
(default 5 columns, balances unchanged, `MONTHS_TO_SHOW` env override works).
- Confirm Python and Go render the same number of columns and identical
balances for the same data.
## Housekeeping
- Per `CLAUDE.md`, copy this plan to
`docs/plans/YYYY-MM-DD-HHMM-months-to-show.md` during implementation (create
`docs/plans/` if missing) and add a `CHANGELOG.md` entry once verified.
- Per `CLAUDE.md`, this is a feature → do it on a `feat/months-to-show` branch
and open a Gitea MR with `tea`; do not commit to `main`.
@@ -0,0 +1,112 @@
# Plan: Gitea Action to open a gitops image-update PR for fuj-management
## Context
The Go image of this app is built and pushed by the `build-go` job in
[.gitea/workflows/build.yaml](.gitea/workflows/build.yaml), tagged
`gitea.home.hrajfrisbee.cz/kacerr/fuj-management:<git-tag>-go` (e.g. `0.37-go`).
Kubernetes manifests live in a **separate** repo,
`gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes`. Today, bumping the image in
the `fuj-management` Deployment (namespace `fuj`) is a manual edit there.
We want CI to automate that bump: when a new Go image is built, open a PR against
`home-kubernetes` that swaps the image to the freshly built tag — using the
`uh-cli gitops deployment update` command. The user reviews/merges that PR in
Gitea (matching the existing branch-per-change, merge-in-browser workflow).
Decisions confirmed with the user:
- **Separate workflow file** (not a job inside build.yaml).
- **New `GITOPS_TOKEN` secret** for home-kubernetes write + PR access.
- **uh-cli version pinned with a default, overridable via env/var/input.**
## How uh-cli works (from `/Users/jan.novak/srv/go/uh-cli/docs/`)
- `uh-cli gitops deployment update` clones `--git-repo`, walks `--git-path`
recursively for a `kind: Deployment` whose `metadata.name`/`namespace` match,
edits the first container image surgically, commits on a new branch
`gitops/update-<name>-<timestamp>`, pushes, and **opens the PR itself** via
`tea pr create`. PR base is always `main`; title/body are hardcoded (no flags).
- Requires on PATH: `git` and `tea` (tea only for the PR flow; `--force` skips it).
- Auth: token embedded in the `--git-repo` URL (`https://user:TOKEN@host/...`);
`tea login add` for PR creation; git identity via `git config`/env vars.
- `--dry-run` prints the unified diff and makes no git changes. Global `-v`
(placed **before** the subcommand) enables debug logging on stderr.
- Release binaries are named `uh-cli-<version>-linux-amd64` (version includes the
`v`), attached to the Gitea release. Latest tag today is **`v0.1.0`**.
## Change: new workflow `.gitea/workflows/gitops-update.yaml`
Triggers:
- `workflow_run` on `workflows: ["Build and Push"]`, `types: [completed]`, gated
to `conclusion == 'success'` — auto-fires after the image build succeeds.
- `workflow_dispatch` with inputs: `tag` (git tag without the `-go` suffix, e.g.
`0.37`), `dry_run` (boolean, default false), `uh_cli_version` (optional override).
Single job `gitops-pr`, `runs-on: ubuntu-latest`, in a `container: ubuntu:latest`
for a hermetic install (matches the uh-cli CI doc pattern). Steps:
1. **Install git, curl, ca-certificates, tea** — apt-get + download tea
`0.9.2` from `gitea.com/gitea/tea/releases/...` to `/usr/local/bin/tea`.
2. **Install uh-cli** — download
`https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64`
to `/usr/local/bin/uh-cli`.
`UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}`.
3. **Resolve image tag** — if `workflow_dispatch`, use `inputs.tag`; else use
`github.event.workflow_run.head_branch` (the pushed tag name). Output
`gitea.home.hrajfrisbee.cz/${{ github.repository }}:<tag>-go`.
4. **Configure git identity** — `git config --global user.name/email` for the bot.
5. **Authenticate tea** — `tea login add --name ci --url https://gitea.home.hrajfrisbee.cz --token "$GITEA_TOKEN"`.
6. **Open image-update PR** — run, with `--dry-run` appended only when the
dispatch `dry_run` input is true:
```
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "<resolved image>" \
--git-repo "https://fuj-gitops-bot:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes
```
`GITEA_TOKEN` is sourced from `secrets.GITOPS_TOKEN` at job level.
Job-level guard: `if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}`.
## Prerequisites (user must set up in Gitea — call out in handoff)
1. **Create `GITOPS_TOKEN` secret** in the `fuj-management` repo: a Gitea token
for a user (`fuj-gitops-bot` or `kacerr`) that has **write + pull-request**
access to `kacerr/home-kubernetes`. The username in the `--git-repo` URL must
match that token's owner (adjust `fuj-gitops-bot` if using `kacerr`).
2. **uh-cli `v0.1.0` release assets must exist** (the `uh-cli-v0.1.0-linux-amd64`
binary attached to the release). If not yet published, cut that release in the
uh-cli repo first, or set `UH_CLI_VERSION` to a published tag.
3. **Confirm the manifest path**: `--git-path gitops/home-kubernetes` must contain
the `fuj-management` Deployment; `--deployment-namespace fuj` disambiguates.
Cannot verify from this repo — verify against home-kubernetes (narrow the path
if uh-cli reports an ambiguity error).
## Files
- **New**: `.gitea/workflows/gitops-update.yaml` (the workflow above).
- After it works: prepend a `CHANGELOG.md` entry; save this plan to
`docs/plans/<ts>-gitops-pr-action.md` per CLAUDE.md convention.
## Branching
Feature work → branch `feat/gitops-pr-action` off `main`, commit with the
`Co-Authored-By` trailer, push with `-u`, open the MR with
`tea pr create --base main --head feat/gitops-pr-action`. Do not merge from CLI.
## Verification
1. **Dry run (manual)**: trigger `gitops-update.yaml` via workflow_dispatch with
`tag=0.37`, `dry_run=true`. Confirm logs show the unified diff (image line
`…:0.37-go`) and `-v` debug milestones; **no PR** is created.
2. **Real run (manual)**: re-trigger with `dry_run=false`. Confirm a PR appears in
`home-kubernetes` against `main` with the image bump, and the PR URL is printed.
3. **Auto-trigger**: push a new git tag to fuj-management → `Build and Push`
completes → `gitops-update` fires via `workflow_run` and opens the PR.
(Note: `workflow_run`/`head_branch` behavior depends on this Gitea/act_runner
version; if it doesn't fire, manual `workflow_dispatch` is the fallback and the
plan still delivers the core capability.)
@@ -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.
+129 -3
View File
@@ -1,19 +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=..."
@@ -73,15 +79,125 @@ func serverCmd(args []string) {
cfg.ServerAddr = *addr
}
ctx := context.Background()
logger := logging.New(cfg.LogLevel)
sources, err := membership.NewSources(ctx, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err)
os.Exit(1)
}
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
os.Exit(1)
}
fioClients := buildFioClients(cfg)
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,
banksync.SyncOpts{From: from, To: to, Sort: true})
if err != nil {
return fmt.Errorf("sync: %w", err)
}
fmt.Fprintf(out, "Synced %d new transaction(s).\n\n", n)
fmt.Fprintln(out, "=== Infer Payments ===")
n, err = banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{})
if err != nil {
return fmt.Errorf("infer: %w", err)
}
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}
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// 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() {
@@ -157,7 +273,7 @@ func syncCmd(args []string) {
fmt.Fprintf(os.Stderr, "fuj sync: sheets client: %v\n", err)
os.Exit(1)
}
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
fioClients := buildFioClients(cfg)
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable}
if *fromStr != "" && *toStr != "" {
@@ -173,7 +289,7 @@ func syncCmd(args []string) {
}
}
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli, opts)
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
os.Exit(1)
@@ -234,3 +350,13 @@ Commands:
sync Sync Fio transactions to payments sheet
infer Infer payment details in payments sheet`)
}
// buildFioClients constructs one fio.Client per configured account.
// Each client uses the account's token if available, otherwise the transparent-scraper path.
func buildFioClients(cfg config.Config) []fio.Client {
clients := make([]fio.Client, 0, len(cfg.LoadedAccounts))
for _, a := range cfg.LoadedAccounts {
clients = append(clients, fio.New(a.Token, a.AcctNum, nil))
}
return clients
}
+35
View File
@@ -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)
}
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
// /api/version is intentionally excluded — it returns each binary's own build
// identity (tag/commit/build_date), which differs by design between independently
// built backends. Pass -route /api/version to inspect it manually.
var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
// defaultAllowlist holds dotted key paths to strip before diffing.
// render_time.total is forward-compatible insurance: today it lives in the Jinja
// template context only (legacy/app.py inject_render_time) and is logged via
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
// side ever surfaces it under a render_time envelope, the scrubber handles it.
var defaultAllowlist = []string{"render_time.total"}
func main() {
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
flag.Parse()
client := &http.Client{Timeout: *timeout}
targets := allRoutes
if *route != "" {
targets = []string{*route}
}
matched, diffs, errs := 0, 0, 0
for _, r := range targets {
pyMap, err1 := fetch(client, *pyURL+r)
goMap, err2 := fetch(client, *goURL+r)
if err1 != nil || err2 != nil {
fmt.Printf("=== %s ===\n", r)
if err1 != nil {
fmt.Printf("ERROR (py): %v\n", err1)
}
if err2 != nil {
fmt.Printf("ERROR (go): %v\n", err2)
}
fmt.Println()
errs++
continue
}
scrub(pyMap, defaultAllowlist)
scrub(goMap, defaultAllowlist)
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
if diff == "" {
fmt.Printf("=== %s ===\nOK\n\n", r)
matched++
} else {
fmt.Printf("=== %s ===\n%s\n", r, diff)
diffs++
}
}
total := len(targets)
fmt.Printf("parity: %d/%d routes match", matched, total)
if diffs > 0 {
fmt.Printf(", %d diff", diffs)
if diffs > 1 {
fmt.Print("s")
}
}
if errs > 0 {
fmt.Printf(", %d error", errs)
if errs > 1 {
fmt.Print("s")
}
}
fmt.Println()
if errs > 0 {
os.Exit(2)
}
if diffs > 0 {
os.Exit(1)
}
}
func fetch(client *http.Client, url string) (map[string]any, error) {
resp, err := client.Get(url) //nolint:noctx
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return nil, fmt.Errorf("decode JSON: %w", err)
}
return m, nil
}
// scrub removes keys from m whose dotted paths appear in paths.
// A bare segment (no dot) deletes a top-level key.
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
func scrub(m map[string]any, paths []string) {
for _, path := range paths {
parts := strings.SplitN(path, ".", 2)
if len(parts) == 1 {
delete(m, parts[0])
} else {
if child, ok := m[parts[0]].(map[string]any); ok {
delete(child, parts[1])
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package main
import "testing"
func TestScrubTopLevel(t *testing.T) {
m := map[string]any{
"build_meta": map[string]any{"tag": "v1"},
"other": "keep",
}
scrub(m, []string{"build_meta"})
if _, ok := m["build_meta"]; ok {
t.Error("expected build_meta to be removed")
}
if m["other"] != "keep" {
t.Error("expected other to be preserved")
}
}
func TestScrubNested(t *testing.T) {
m := map[string]any{
"render_time": map[string]any{
"total": "0.123",
"breakdown": "fetch:0.1s",
},
"other": "keep",
}
scrub(m, []string{"render_time.total"})
rt, ok := m["render_time"].(map[string]any)
if !ok {
t.Fatal("render_time should still be present")
}
if _, ok := rt["total"]; ok {
t.Error("expected render_time.total to be removed")
}
if rt["breakdown"] != "fetch:0.1s" {
t.Error("expected render_time.breakdown to be preserved")
}
if m["other"] != "keep" {
t.Error("expected other to be preserved")
}
}
func TestScrubMissingPath(t *testing.T) {
m := map[string]any{"foo": "bar"}
scrub(m, []string{"nonexistent", "render_time.total"})
if m["foo"] != "bar" {
t.Error("expected foo to be preserved")
}
}
func TestScrubNestedParentNotMap(t *testing.T) {
m := map[string]any{"render_time": "not-a-map"}
scrub(m, []string{"render_time.total"})
if m["render_time"] != "not-a-map" {
t.Error("expected render_time to be unchanged when it is not a map")
}
}
+7
View File
@@ -3,6 +3,9 @@ module fuj-management/go
go 1.26.1
require (
github.com/google/go-cmp v0.7.0
github.com/invopop/jsonschema v0.14.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
google.golang.org/api v0.278.0
@@ -12,6 +15,8 @@ require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -20,11 +25,13 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.43.0 // indirect
+12
View File
@@ -4,6 +4,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -27,8 +31,14 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5Ugt
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -45,6 +55,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+72 -7
View File
@@ -1,12 +1,36 @@
package config
import (
"fmt"
"fuj-management/go/internal/scheduler"
"os"
"strconv"
"strings"
"time"
)
// Account describes a Fio bank account.
type Account struct {
IBAN string // e.g. "CZ0820100000002502035405"
AcctNum string // bare account number, e.g. "2502035405"
TokenEnv string // env var name holding the optional Fio API token
Primary bool // true for the account QR codes default to
}
// LoadedAccount is an Account with its token resolved from the environment.
type LoadedAccount struct {
Account
Token string // value of os.Getenv(Account.TokenEnv); empty → transparent-scraper path
}
// Accounts is the hardcoded list of Fio bank accounts to sync from.
// The first entry with Primary=true is used for QR codes.
// Tokens are loaded at runtime from each account's TokenEnv.
var Accounts = []Account{
{IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true},
{IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"},
}
// Google Sheets IDs — change in code if sheets change (not from env).
const (
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
@@ -20,7 +44,7 @@ const (
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
)
// CacheSheetMap mirrors scripts/config.py CACHE_SHEET_MAP.
// CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP.
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
var CacheSheetMap = map[string]string{
@@ -31,35 +55,67 @@ var CacheSheetMap = map[string]string{
}
// Config holds all runtime configuration loaded from environment variables.
// Mirrors scripts/config.py.
// Mirrors legacy/scripts/config.py.
type Config struct {
CredentialsPath string
BankAccount string
QRAccount string // IBAN of the primary account used for QR codes
LoadedAccounts []LoadedAccount // all accounts to sync, tokens resolved from env
CacheDir string
CacheTTL time.Duration
CacheAPICheckTTL time.Duration
DriveTimeout time.Duration
LogLevel string
FioAPIToken string
ServerAddr string
MonthsToShow int // show last N month columns; 0 means show all
}
// Load reads configuration from the environment, applying defaults that
// match the Python side.
func Load() Config {
loaded := make([]LoadedAccount, len(Accounts))
var qrAccount string
for i, a := range Accounts {
loaded[i] = LoadedAccount{Account: a, Token: os.Getenv(a.TokenEnv)}
if a.Primary {
qrAccount = a.IBAN
}
}
return Config{
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
CacheDir: env("CACHE_DIR", "tmp"),
QRAccount: qrAccount,
LoadedAccounts: loaded,
CacheDir: env("CACHE_DIR", "tmp/go"),
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
LogLevel: env("LOG_LEVEL", "INFO"),
FioAPIToken: env("FIO_API_TOKEN", ""),
ServerAddr: env("SERVER_ADDR", ":8080"),
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
}
}
// Scheduled bank sync defaults (Go server only; the legacy Python app has no scheduler).
const (
DefaultSyncSchedule = "04:00" // SYNC_SCHEDULE: comma-separated HH:MM times, or "off"
DefaultSyncTimezone = "Europe/Prague" // SYNC_TZ: IANA zone the schedule is read in
)
// LoadSyncSchedule reads SYNC_SCHEDULE and SYNC_TZ. An empty schedule means
// the scheduled sync is disabled. Resolving the zone needs a zone database:
// the server binary embeds one via time/tzdata.
func LoadSyncSchedule() ([]scheduler.TimeOfDay, *time.Location, error) {
times, err := scheduler.ParseTimes(env("SYNC_SCHEDULE", DefaultSyncSchedule))
if err != nil {
return nil, nil, fmt.Errorf("SYNC_SCHEDULE: %w", err)
}
tz := env("SYNC_TZ", DefaultSyncTimezone)
loc, err := time.LoadLocation(tz)
if err != nil {
return nil, nil, fmt.Errorf("SYNC_TZ: unknown time zone %q", tz)
}
return times, loc, nil
}
// IBANAccountNum extracts the bare account number from a Czech IBAN.
// "CZ8520100000002800359168" → "2800359168"
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
@@ -91,3 +147,12 @@ func envDuration(key string, defaultSeconds int) time.Duration {
}
return time.Duration(defaultSeconds) * time.Second
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
+45
View File
@@ -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)
}
}
@@ -9,7 +9,7 @@ func TestParseMonthReferences(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-05:
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
// PYTHONPATH=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
tests := []struct {
name string
input string
+13 -10
View File
@@ -1,34 +1,37 @@
// Package fees ports fee calculation from scripts/attendance.py.
// Package fees ports fee calculation from legacy/scripts/attendance.py.
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
)
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to AdultFeeDefault.
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": 700,
"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
return rate
}
+28 -10
View File
@@ -5,24 +5,42 @@ import "testing"
func TestCalculateFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
// mustRate returns the configured rate for a month that must be in the map.
// It panics immediately if the month is absent — so if a rate entry is ever
// removed, the test fails loudly rather than silently comparing against
// Go's zero value.
mustRate := func(month string) int {
r, ok := AdultFeeMonthlyRate[month]
if !ok {
panic("test month not in AdultFeeMonthlyRate: " + month)
}
return r
}
tests := []struct {
name string
count int
month string
want int
}{
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", 0},
{"zero empty month", 0, "", 0},
{"single practice", 1, "2026-05", 200},
{"single ignores monthKey", 1, "unknown", 200},
{"two practices configured month", 2, "2026-05", 700},
{"two practices reduced march", 2, "2026-03", 350},
{"two practices early season", 2, "2025-09", 750},
{"high count same as two", 5, "2026-05", 700},
{"unknown future month falls back", 2, "2027-01", 700},
{"empty month falls back", 2, "", 700},
// Single practice returns AdultFeeSingle, capped at the month's rate.
{"single practice", 1, "2026-05", 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.
{"two practices configured month", 2, "2026-05", mustRate("2026-05")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"two practices early season", 2, "2025-09", mustRate("2025-09")},
{"high count same as two", 5, "2026-05", mustRate("2026-05")},
// Two+ practices for an unknown/future month: must fall back to AdultFeeDefault.
{"unknown future month falls back", 2, "2027-01", AdultFeeDefault},
{"empty month falls back", 2, "", AdultFeeDefault},
}
for _, tc := range tests {
+10 -7
View File
@@ -2,11 +2,12 @@ package fees
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to JuniorFeeDefault.
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}
return Expected{Value: rate}
}
+24 -8
View File
@@ -5,24 +5,40 @@ import "testing"
func TestCalculateJuniorFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
// mustRate returns the configured rate for a month that must be in the map.
// Panics immediately if the month is absent so a removed entry causes a loud
// failure rather than a silent comparison against Go's zero value.
mustRate := func(month string) Expected {
r, ok := JuniorFeeMonthlyRate[month]
if !ok {
panic("test month not in JuniorFeeMonthlyRate: " + month)
}
return Expected{Value: r}
}
tests := []struct {
name string
count int
month string
want Expected
}{
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
{"zero empty month", 0, "", Expected{Value: 0}},
// Single practice returns the Unknown sentinel regardless of month.
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
{"empty month falls back", 2, "", Expected{Value: 500}},
// Two+ practices for a configured month: must use the map value, not the default.
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}},
{"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}},
}
for _, tc := range tests {
+1 -1
View File
@@ -1,2 +1,2 @@
// Package matching ports name/member matching from scripts/match_payments.py.
// Package matching ports name/member matching from legacy/scripts/match_payments.py.
package matching
+1 -1
View File
@@ -14,7 +14,7 @@ var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
//
// Ports scripts/match_payments.py format_date.
// Ports legacy/scripts/match_payments.py format_date.
func FormatDate(val any) string {
if val == nil {
return ""
@@ -1,8 +1,8 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import format_date
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
// print(repr(format_date(v)))
+26 -3
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,20 +41,32 @@ 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).
//
// Ports scripts/match_payments.py infer_transaction_details.
// Ports legacy/scripts/match_payments.py infer_transaction_details.
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
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}
+70 -2
View File
@@ -1,8 +1,8 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 << 'EOF'
// PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
// from match_payments import infer_transaction_details
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
// cases = [
@@ -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()
+1 -1
View File
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
// MatchMembers finds members mentioned in text and returns them with a
// confidence level of "auto" (reliable) or "review" (needs human verification).
//
// Algorithm (ported verbatim from scripts/match_payments.py match_members):
// Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
// 1. Exact short-circuit: if any member's full normalized name appears as whole
// words in normalize(text), return ONLY those matches as auto. This prevents
// nickname "tov" from matching inside surname "ottova".
@@ -1,9 +1,9 @@
package matching
// Expected values verified against scripts/match_payments.py and
// Expected values verified against legacy/scripts/match_payments.py and
// tests/test_match_members.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import match_members
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
// cases = [
+1 -1
View File
@@ -19,7 +19,7 @@ var (
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
// dropped.
//
// Ports scripts/match_payments.py _build_name_variants.
// Ports legacy/scripts/match_payments.py _build_name_variants.
func BuildNameVariants(name string) []string {
var nickname string
if m := nicknameRe.FindStringSubmatch(name); m != nil {
@@ -1,8 +1,8 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import _build_name_variants
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
// print(repr(n), "->", _build_name_variants(n))
+2 -2
View File
@@ -1,4 +1,4 @@
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
// Package money ports Czech-locale currency parsing from legacy/scripts/infer_payments.py.
package money
import (
@@ -11,7 +11,7 @@ import (
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
// as a float64. Mirrors legacy/scripts/infer_payments.py parse_czk_amount:
//
// - empty input → (0, nil)
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
+1 -1
View File
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from infer_payments import parse_czk_amount
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
// "1 500", "1500.00", "1 500.00",
+52 -37
View File
@@ -1,4 +1,4 @@
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
// Package reconcile ports the three-phase payment reconciliation from legacy/scripts/match_payments.py.
package reconcile
import (
@@ -20,10 +20,13 @@ type Exception struct {
Note string
}
// FeeData holds the expected fee and attendance count for one member in one month.
// FeeData holds the expected fee and attendance data for one member in one month.
type FeeData struct {
Expected int
Attendance int
Expected int
IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?")
Attendance int
JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view
AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown
}
// Member is one row from the attendance sheet.
@@ -39,11 +42,15 @@ type Member struct {
type Transaction struct {
Date string
Amount float64
ManualFix string // "manual fix" column; non-empty disables re-inference
Person string // comma-separated canonical names (empty → use inference)
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
InferredAmount *float64 // nil → fall back to Amount
Sender string
VS string // Variabilní symbol (Czech variable payment symbol)
Message string
BankID string
SyncID string
UserID string
}
@@ -69,8 +76,11 @@ type OtherEntry struct {
// MonthData is the ledger state for one member in one month.
type MonthData struct {
Expected int
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
OriginalExpected int
AttendanceCount int
JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view
AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown
Exception *Exception
Paid float64
Transactions []TxEntry
@@ -95,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
// used to resolve Person-column values that drift from canonical attendance-sheet names.
// Ports scripts/match_payments.py canonical_member_key.
// Ports legacy/scripts/match_payments.py canonical_member_key.
func canonicalMemberKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
}
@@ -105,15 +115,16 @@ type monthExpected struct {
expected int
}
// Reconcile matches transactions to members and months using three allocation phases:
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
// Reconcile matches transactions to members and months using two allocation phases:
// 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
// deficit) to each month where deficit = expected − already-paid. Surplus → credit.
// Handles both the "greedy" (payment covers all) and "partial" cases in one pass.
// 2. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
//
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
// Pass time.Now().Year() in production; pass a fixed year in tests.
//
// Ports scripts/match_payments.py reconcile.
// Ports legacy/scripts/match_payments.py reconcile.
func Reconcile(
members []Member,
sortedMonths []string,
@@ -173,8 +184,11 @@ func Reconcile(
ledger[name][m] = MonthData{
Expected: expected,
IsUnknown: fd.IsUnknown,
OriginalExpected: originalExpected,
AttendanceCount: attendanceCount,
JuniorAttendance: fd.JuniorAttendance,
AdultAttendance: fd.AdultAttendance,
Exception: exInfo,
Paid: 0,
Transactions: []TxEntry{},
@@ -189,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
@@ -235,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 {
@@ -304,34 +323,26 @@ func Reconcile(
totalExpected += mw.expected
}
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
// Greedy: payment covers all expected fees; overflow → credit
credits[memberName] += int(inWindowShare - float64(totalExpected))
for _, mw := range inWindow {
alloc := float64(mw.expected)
md := ledger[memberName][mw.month]
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
}
} else if totalExpected > 0 {
// Proportional: distribute by each month's share; last month absorbs float remainder
if totalExpected > 0 {
// Fill-first: iterate inWindow in matched-months order (chronological by
// convention), allocating min(remaining, deficit) to each month. Deficit
// is net of what prior transactions already paid, so a second payment on
// the same months correctly fills only what remains due. Any surplus after
// all deficits are covered goes to the credit bucket.
remaining := inWindowShare
for i, mw := range inWindow {
var alloc float64
if i == len(inWindow)-1 {
alloc = remaining
} else {
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
}
remaining -= alloc
for _, mw := range inWindow {
md := ledger[memberName][mw.month]
deficit := float64(mw.expected) - md.Paid
if deficit < 0 {
deficit = 0
}
alloc := remaining
if deficit < alloc {
alloc = deficit
}
if alloc <= 0 {
continue
}
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
@@ -341,6 +352,10 @@ func Reconcile(
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
remaining -= alloc
}
if remaining > 0 {
credits[memberName] += int(remaining)
}
} else {
// Even-split fallback: prepayment before attendance recorded
+106 -40
View File
@@ -1,8 +1,8 @@
package reconcile
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
// PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
//
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
@@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction {
func TestReconcileExceptionOverride(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
exceptions := map[ExceptionKey]Exception{
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
}
@@ -54,7 +54,7 @@ func TestReconcileExceptionOverride(t *testing.T) {
func TestReconcileFallbackToAttendance(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
@@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) {
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{
"2026-02": {750, 3},
"2026-03": {350, 3},
"2026-04": {150, 2},
"2026-02": {Expected: 750, Attendance: 3},
"2026-03": {Expected: 350, Attendance: 3},
"2026-04": {Expected: 150, Attendance: 2},
},
}}
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
@@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
t.Parallel()
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}},
Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}},
}}
sortedMonths := []string{"2026-01", "2026-02"}
@@ -111,42 +111,32 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
}
}
func TestReconcileProportionalUnderpayment(t *testing.T) {
func TestReconcileUnderpaymentFillsEarliestFirst(t *testing.T) {
t.Parallel()
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}},
Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
}}
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
amount := 1250.0
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear)
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear)
months := result.Members["Alice"].Months
paid02 := months["2026-02"].Paid
paid03 := months["2026-03"].Paid
paid04 := months["2026-04"].Paid
if paid02 >= 750 {
t.Errorf("2026-02 should be underpaid, got %f", paid02)
// 02 filled first (750), then 03 (350), then remainder 150 to 04
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
}
if paid03 >= 350 {
t.Errorf("2026-03 should be underpaid, got %f", paid03)
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
}
if paid04 >= 750 {
t.Errorf("2026-04 should be underpaid, got %f", paid04)
}
if math.Abs(paid02+paid03+paid04-amount) > 0.01 {
t.Errorf("sum of paid want %f, got %f", amount, paid02+paid03+paid04)
}
if math.Abs(paid02-paid04) > 0.01 {
t.Errorf("02 and 04 have equal expected, want equal paid: %f vs %f", paid02, paid04)
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
}
}
func TestReconcileSingleMonthUnchanged(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
@@ -158,8 +148,8 @@ func TestReconcileSingleMonthUnchanged(t *testing.T) {
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
t.Parallel()
members := []Member{
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
}
sortedMonths := []string{"2026-01", "2026-02"}
@@ -180,7 +170,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
t.Parallel()
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}},
Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}},
}}
sortedMonths := []string{"2026-01", "2026-02"}
@@ -197,7 +187,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
txFn := func(person string) Transaction {
return Transaction{
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
@@ -232,7 +222,7 @@ func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
txs := []Transaction{{
Date: "2026-04-15", Amount: 750,
Person: "Někdo Neznámý", Purpose: "2026-04",
@@ -252,7 +242,7 @@ func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
// [Go] Test that [?] markers are stripped from the Person field before lookup.
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 750,
Person: "[?] Alice", Purpose: "2026-01",
@@ -267,9 +257,36 @@ 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": {750, 3}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 300,
Person: "Alice", Purpose: "other:shirt",
@@ -297,7 +314,7 @@ func TestReconcileOtherPurpose(t *testing.T) {
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
t.Parallel()
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 1200,
Person: "Alice", Purpose: "2026-01, 2026-02",
@@ -322,7 +339,7 @@ func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
// [Go] No person/purpose → inference fallback resolves sender name and date month.
func TestReconcileInferenceFallback(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}}
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-04-15", Amount: 750,
// Person and Purpose are empty → inference path
@@ -340,7 +357,7 @@ func TestReconcileInferenceFallback(t *testing.T) {
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 500,
// empty person+purpose and sender name not matching any member
@@ -360,7 +377,7 @@ func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
@@ -374,3 +391,52 @@ func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
}
}
// Payment < total expected → fill earliest months first, spill remainder to later.
func TestUnderpaymentFillsEarliestFirst(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{
"2026-02": {Expected: 750, Attendance: 3},
"2026-03": {Expected: 350, Attendance: 3},
"2026-04": {Expected: 750, Attendance: 3},
}}}
txs := []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}
result := Reconcile(members, []string{"2026-02", "2026-03", "2026-04"}, txs, nil, defaultYear)
months := result.Members["Alice"].Months
// 02 filled first (750), then 03 (350), then remainder 150 to 04
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
}
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
}
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
}
}
// Prior txn fills 02 partially; later txn finishes 02 then spills to 03.
func TestFillFirstAcrossTwoTransactions(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Matyáš", Tier: "A", Fees: map[string]FeeData{
"2026-02": {Expected: 500, Attendance: 2},
"2026-03": {Expected: 250, Attendance: 1},
}}}
sortedMonths := []string{"2026-02", "2026-03"}
txs := []Transaction{
tx("Matyáš", "2026-02", 200),
tx("Matyáš", "2026-02, 2026-03", 550),
}
result := Reconcile(members, sortedMonths, txs, nil, defaultYear)
months := result.Members["Matyáš"].Months
if math.Abs(months["2026-02"].Paid-500) > 0.01 {
t.Errorf("02: want 500, got %f", months["2026-02"].Paid)
}
if math.Abs(months["2026-03"].Paid-250) > 0.01 {
t.Errorf("03: want 250, got %f", months["2026-03"].Paid)
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
// Package synch ports the bank-sync deduplication helper from
// scripts/sync_fio_to_sheets.py.
// legacy/scripts/sync_fio_to_sheets.py.
package synch
import (
@@ -30,7 +30,7 @@ type Transaction struct {
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
// as the dedup key in column K of the payments sheet.
//
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
// Byte-stable with legacy/scripts/sync_fio_to_sheets.py generate_sync_id.
func GenerateSyncID(tx Transaction) string {
currency := tx.Currency
if currency == "" {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
// All expected digests verified against the live Python implementation on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from sync_fio_to_sheets import generate_sync_id
// cases = [
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
@@ -88,7 +88,7 @@ func TestGenerateSyncID(t *testing.T) {
// All expected strings verified against the live Python implementation on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
// print(repr(v), "->", repr(str(v)))
// '
+2 -2
View File
@@ -1,5 +1,5 @@
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
// mirroring scripts/cache_utils.py.
// mirroring legacy/scripts/cache_utils.py.
package cache
import (
@@ -26,7 +26,7 @@ type cacheFile struct {
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
//
// Two TTL knobs mirror scripts/cache_utils.py:
// Two TTL knobs mirror legacy/scripts/cache_utils.py:
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
//
+1 -1
View File
@@ -17,7 +17,7 @@ type httpDoer interface {
}
// apiClient fetches transactions from the Fio REST API (JSON).
// Ports scripts/fio_utils.py fetch_transactions_api.
// Ports legacy/scripts/fio_utils.py fetch_transactions_api.
type apiClient struct {
token string
hc httpDoer
+2 -2
View File
@@ -98,8 +98,8 @@ func TestParseCzechDate(t *testing.T) {
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
{"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month
{"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
{"07/05/26", "2026-05-07"}, // slash variant
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
{"07/05/26", "2026-05-07"}, // slash variant
{"", ""},
{"invalid", ""},
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
// transparentClient fetches transactions from the Fio transparent account page (HTML).
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
// Ports legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
type transparentClient struct {
accountNum string
hc httpDoer
+33
View File
@@ -0,0 +1,33 @@
package sheets
import (
"fmt"
"strconv"
)
// CellString renders a cell read with UNFORMATTED_VALUE as text.
// Numeric cells arrive as float64; fmt.Sprint would print large ones in
// exponent form ("2.609767878e+09"), which corrupts identifiers such as VS
// or Bank ID. Mirrors get_str in legacy/scripts/match_payments.py.
func CellString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
}
return fmt.Sprint(v)
}
// TextCell marks s to be stored as text when written with USER_ENTERED:
// a leading apostrophe stops Sheets from parsing digits as a number (which
// would drop leading zeros). The apostrophe is not part of the stored value.
// Empty stays empty. Mirrors as_text_cell in legacy/scripts/sync_fio_to_sheets.py.
func TextCell(s string) string {
if s == "" {
return ""
}
return "'" + s
}
+36
View File
@@ -0,0 +1,36 @@
package sheets
import "testing"
func TestTextCell(t *testing.T) {
if got := TextCell("0308"); got != "'0308" {
t.Errorf(`TextCell("0308"): got %q`, got)
}
if got := TextCell(""); got != "" {
t.Errorf(`TextCell(""): got %q`, got)
}
}
func TestCellString(t *testing.T) {
cases := []struct {
name string
in any
want string
}{
{"nil", nil, ""},
{"string", "fee", "fee"},
{"string with leading zeros", "0308", "0308"},
{"small integer float", 123.0, "123"},
{"large integer float", 2609767878.0, "2609767878"},
{"eleven digit float", 26123456789.0, "26123456789"},
{"fractional float", 12.5, "12.5"},
{"bool", true, "true"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := CellString(tc.in); got != tc.want {
t.Errorf("CellString(%v): want %q, got %q", tc.in, tc.want, got)
}
})
}
}
+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 -2
View File
@@ -2,10 +2,9 @@ package banksync
import (
"fmt"
"fuj-management/go/internal/io/fio"
"io"
"text/tabwriter"
"fuj-management/go/internal/io/fio"
)
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
+2 -2
View File
@@ -30,7 +30,7 @@ type sheetReadWriter interface {
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
// sheet using name and month matching against the member list.
// Returns the number of rows updated (or that would be updated on dry-run).
// Ports scripts/infer_payments.py infer_payments.
// Ports legacy/scripts/infer_payments.py infer_payments.
func InferPayments(
ctx context.Context,
spreadsheetID string,
@@ -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)
}
}
+28 -11
View File
@@ -6,13 +6,14 @@ import (
"fmt"
"fuj-management/go/internal/domain/synch"
"fuj-management/go/internal/io/fio"
"fuj-management/go/internal/io/sheets"
"os"
"strings"
"time"
)
// columnLabels is the canonical header for the payments sheet.
// Mirrors COLUMN_LABELS in scripts/sync_fio_to_sheets.py.
// Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py.
var columnLabels = []string{
"Date", "Amount", "manual fix", "Person", "Purpose",
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
@@ -35,13 +36,25 @@ type SyncOpts struct {
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
}
// SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
// Returns the number of rows appended.
// Ports scripts/sync_fio_to_sheets.py sync_to_sheets.
// 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.
func SyncToSheets(
ctx context.Context,
spreadsheetID string,
fioClient fio.Client,
fioClients []fio.Client,
sh sheetsWriter,
opts SyncOpts,
) (int, error) {
@@ -84,10 +97,14 @@ func SyncToSheets(
from = to.AddDate(0, 0, -days)
}
// 3. Fetch Fio transactions.
txns, err := fioClient.FetchTransactions(ctx, from, to)
if err != nil {
return 0, fmt.Errorf("sync: fetch fio: %w", err)
// 3. Fetch Fio transactions from each account and combine.
var txns []fio.Transaction
for _, client := range fioClients {
got, err := client.FetchTransactions(ctx, from, to)
if err != nil {
return 0, fmt.Errorf("sync: fetch fio: %w", err)
}
txns = append(txns, got...)
}
if opts.DryRun {
fmt.Printf("Dry run: window %s to %s, fetched %d transaction(s) from Fio\n",
@@ -126,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],
})
}
@@ -137,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")
+65 -7
View File
@@ -20,7 +20,7 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -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) {
@@ -51,7 +55,7 @@ func TestSyncToSheets_Dedup(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -72,7 +76,7 @@ func TestSyncToSheets_NoNewTxns(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -92,7 +96,7 @@ func TestSyncToSheets_MissingHeader(t *testing.T) {
}}
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30})
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
@@ -105,7 +109,7 @@ func TestSyncToSheets_Sort(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
fioFake := &fio.Fake{Transactions: testFioTxns[:1]}
_, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{Days: 30, Sort: true})
_, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{Days: 30, Sort: true})
if err != nil {
t.Fatal(err)
}
@@ -118,7 +122,7 @@ func TestSyncToSheets_ExplicitDateWindow(t *testing.T) {
from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, SyncOpts{From: from, To: to})
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh, SyncOpts{From: from, To: to})
if err != nil {
t.Fatal(err)
}
@@ -131,7 +135,7 @@ func TestSyncToSheets_DryRun(t *testing.T) {
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
fioFake := &fio.Fake{Transactions: testFioTxns}
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh,
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fioFake}, sh,
SyncOpts{Days: 30, Sort: true, DryRun: true})
if err != nil {
t.Fatal(err)
@@ -144,6 +148,40 @@ func TestSyncToSheets_DryRun(t *testing.T) {
}
}
func TestSyncToSheets_MultiAccount(t *testing.T) {
txnsA := []fio.Transaction{
{Date: "2026-04-10", Amount: 700, Sender: "Alice", Message: "april", VS: "1", BankID: "A1"},
}
txnsB := []fio.Transaction{
{Date: "2026-04-11", Amount: 500, Sender: "Bob", Message: "duben", VS: "2", BankID: "B1"},
}
// One transaction that duplicates the first one from account A (same sync_id).
dupID := syncIDFor(txnsA[0])
sh := &sheets.Fake{Values: map[string][][]any{
"SHEETID/A1:K": {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-10", 700.0, "", "", "", "", "Alice", "1", "april", "A1", dupID},
},
}}
fakeA := &fio.Fake{Transactions: txnsA}
fakeB := &fio.Fake{Transactions: txnsB}
n, err := SyncToSheets(context.Background(), "SHEETID", []fio.Client{fakeA, fakeB}, sh, SyncOpts{Days: 30})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("want 1 new row (B1 from account B; A1 is duplicate), got %d", n)
}
if len(sh.Appended) != 1 || len(sh.Appended[0].Rows) != 1 {
t.Fatalf("want exactly 1 row appended, got %v", sh.Appended)
}
row := sh.Appended[0].Rows[0]
if row[6] != "Bob" {
t.Errorf("expected Bob's row, got sender=%v", row[6])
}
}
// syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction.
func syncIDFor(tx fio.Transaction) string {
currency := tx.Currency
@@ -155,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)
}
}
}
@@ -9,11 +9,11 @@ import (
)
// printFeesTable writes a fixed-width adult-fees table to w.
// Mirrors scripts/calculate_fees.py main().
// Mirrors legacy/scripts/calculate_fees.py main().
//
// Verify with:
//
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
type row struct {
name string
@@ -1,8 +1,8 @@
package membership
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
// Golden strings verified against legacy/scripts/calculate_fees.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
//
// (feed equivalent fixture data via attendance sheet or local CSV)
@@ -10,11 +10,11 @@ import (
)
// printReconcileReport writes the full balance report to w.
// Mirrors scripts/match_payments.py print_report().
// Mirrors legacy/scripts/match_payments.py print_report().
//
// Verify with:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
// ...'
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
@@ -1,8 +1,8 @@
package membership
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
// Golden strings verified against legacy/scripts/match_payments.py print_report() on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report
// result = {
// "members": {
@@ -25,11 +25,17 @@ type ExceptionLoader interface {
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
}
// CacheFlusher can invalidate all cached data.
type CacheFlusher interface {
FlushCache() (int, error)
}
// Sources is the aggregate interface required by ReconcileReport.
type Sources interface {
AttendanceLoader
TransactionLoader
ExceptionLoader
CacheFlusher
}
// NewStubSources returns a Sources whose every method returns ErrIOPending.
@@ -52,3 +58,5 @@ func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction,
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
return nil, ErrIOPending
}
func (stubSources) FlushCache() (int, error) { return 0, nil }
@@ -31,6 +31,8 @@ func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionK
return f.exceptions, nil
}
func (fakeSources) FlushCache() (int, error) { return 0, nil }
func TestReconcileReport(t *testing.T) {
t.Parallel()
s := fakeSources{
+54 -19
View File
@@ -18,19 +18,21 @@ import (
"time"
)
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
// Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
const (
colName = 0
colTier = 1
firstDateCol = 3
)
// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py.
// Source month → target month (source attendance accumulated into target).
var adultMergedMonths = map[string]string{}
var AdultMergedMonths = map[string]string{
"2025-09": "2025-10",
}
// juniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
var juniorMergedMonths = map[string]string{
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in legacy/scripts/attendance.py.
var JuniorMergedMonths = map[string]string{
"2025-12": "2026-01",
"2025-09": "2025-10",
}
@@ -109,6 +111,9 @@ func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transac
return parseTransactionRows(rows)
}
// FlushCache deletes all cached data files and resets in-memory cache state.
func (s *realSources) FlushCache() (int, error) { return s.cache.Flush() }
// LoadExceptions fetches the exceptions tab (cached).
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
@@ -122,11 +127,11 @@ func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.Excepti
}
// ---------------------------------------------------------------------------
// Attendance CSV parsing (ports scripts/attendance.py)
// Attendance CSV parsing (ports legacy/scripts/attendance.py)
// ---------------------------------------------------------------------------
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m").
// Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m").
func parseDates(header []string) []struct {
col int
month string
@@ -142,7 +147,13 @@ func parseDates(header []string) []struct {
}
var dt time.Time
var err error
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} {
// Use the unpadded reference forms ("2.1" and "1/2"): Go's time.Parse
// accepts both single-digit and zero-padded inputs against them, so
// "1.6.2026", "01.06.2026", "23.3.2026" all parse. Czech sheet authors
// drop the leading zero on dates ≤ 9 — Python's strptime is lenient
// the same way; the previous "02.01.2006" form silently dropped those
// columns and undercounted attendance.
for _, fmt_ := range []string{"2.1.2006", "1/2/2006"} {
dt, err = time.Parse(fmt_, raw)
if err == nil {
break
@@ -189,13 +200,13 @@ func countTrue(row []string, cols []int) int {
// parseAdultRows converts raw CSV rows to []reconcile.Member.
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
// Ports scripts/attendance.py get_members_with_fees.
// Ports legacy/scripts/attendance.py get_members_with_fees.
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
if len(rows) < 2 {
return nil, nil, nil
}
dates := parseDates(rows[0])
months := groupByMonth(dates, adultMergedMonths)
months := groupByMonth(dates, AdultMergedMonths)
sortedMonths := sortedKeys(months)
var members []reconcile.Member
@@ -235,7 +246,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
// with the junior sheet, then calling CalculateJuniorFee.
// Ports scripts/attendance.py get_junior_members_with_fees.
// Ports legacy/scripts/attendance.py get_junior_members_with_fees.
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
if len(adultRows) < 2 || len(juniorRows) < 2 {
return nil, nil, nil
@@ -243,8 +254,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
mainDates := parseDates(adultRows[0])
juniorDates := parseDates(juniorRows[0])
mainMonths := groupByMonth(mainDates, juniorMergedMonths)
jrMonths := groupByMonth(juniorDates, juniorMergedMonths)
mainMonths := groupByMonth(mainDates, JuniorMergedMonths)
jrMonths := groupByMonth(juniorDates, JuniorMergedMonths)
allMonths := make(map[string]bool)
for m := range mainMonths {
@@ -337,7 +348,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
if !exp.Unknown {
fee = exp.Value
}
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total}
feeMap[m] = reconcile.FeeData{
Expected: fee,
IsUnknown: exp.Unknown,
Attendance: total,
JuniorAttendance: c.junior,
AdultAttendance: c.adult,
}
}
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
}
@@ -345,7 +362,7 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
}
// ---------------------------------------------------------------------------
// Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data)
// Payments sheet row parsing (ports legacy/scripts/match_payments.py fetch_sheet_data)
// ---------------------------------------------------------------------------
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
@@ -365,11 +382,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
}
idxDate := idx("date")
idxAmount := idx("amount")
idxManualFix := idx("manual fix")
idxPerson := idx("person")
idxPurpose := idx("purpose")
idxInferred := idx("inferred amount")
idxSender := idx("sender")
idxVS := idx("vs")
idxMessage := idx("message")
idxBankID := idx("bank id")
idxSyncID := idx("sync id")
for _, label := range []string{"date", "amount", "person", "purpose"} {
if idx(label) == -1 {
@@ -381,12 +402,22 @@ 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
// dispatch on the underlying numeric type (Sheets returns serial-day
// numbers as float64). Stringifying first defeats that dispatch.
getRaw := func(row []any, i int) any {
if i < 0 || i >= len(row) {
return nil
}
return row[i]
}
var txns []reconcile.Transaction
for _, row := range rows[1:] {
dateStr := matching.FormatDate(getVal(row, idxDate))
dateStr := matching.FormatDate(getRaw(row, idxDate))
amountRaw := row[idxAmount]
if idxAmount < 0 || idxAmount >= len(row) {
amountRaw = ""
@@ -394,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
}
@@ -403,11 +434,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
txns = append(txns, reconcile.Transaction{
Date: dateStr,
Amount: amount,
ManualFix: getVal(row, idxManualFix),
Person: getVal(row, idxPerson),
Purpose: getVal(row, idxPurpose),
InferredAmount: inferredAmount,
Sender: getVal(row, idxSender),
VS: getVal(row, idxVS),
Message: getVal(row, idxMessage),
BankID: getVal(row, idxBankID),
SyncID: getVal(row, idxSyncID),
})
}
return txns, nil
@@ -431,7 +466,7 @@ func parseFloat(v any) float64 {
}
// ---------------------------------------------------------------------------
// Exceptions tab parsing (ports scripts/match_payments.py fetch_exceptions)
// Exceptions tab parsing (ports legacy/scripts/match_payments.py fetch_exceptions)
// ---------------------------------------------------------------------------
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
@@ -46,8 +46,8 @@ func TestLoadAdults(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// adultMergedMonths is empty so 2025-09 stays as-is
if len(months) != 1 || months[0] != "2025-09" {
// AdultMergedMonths sends 2025-09 → 2025-10
if len(months) != 1 || months[0] != "2025-10" {
t.Errorf("unexpected months: %v", months)
}
if len(members) != 2 {
@@ -55,7 +55,7 @@ func TestLoadAdults(t *testing.T) {
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Attendance
byName[m.Name] = m.Fees["2025-10"].Attendance
}
if byName["Alice"] != 2 {
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
@@ -73,9 +73,9 @@ func TestLoadAdults_Fee(t *testing.T) {
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Expected
byName[m.Name] = m.Fees["2025-10"].Expected
}
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750
// 2 sessions land in merged 2025-10 → AdultFeeMonthlyRate["2025-10"] = 750
if byName["Alice"] != 750 {
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
}
@@ -114,12 +114,17 @@ func TestLoadJuniors(t *testing.T) {
func TestLoadTransactions(t *testing.T) {
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
// serial-day form (float64) — the API returns either depending on cell
// formatting, and FormatDate must handle both.
paymentsKey := config.PaymentsSheetID + "/A1:Z"
sh := &sheets.Fake{Values: map[string][][]any{
paymentsKey: {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
{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)
@@ -128,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)
@@ -137,6 +142,18 @@ func TestLoadTransactions(t *testing.T) {
if txns[0].Amount != 700 {
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
}
if txns[0].Date != "2026-04-01" {
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
}
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) {
@@ -165,6 +182,28 @@ func TestLoadExceptions(t *testing.T) {
}
}
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
// "02.01.2006" format dropped header cells written without leading zeros
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
// months on the /api/juniors response. Czech sheet authors drop the zero
// pad freely; Python's strptime tolerates it, so the parsers must match.
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
// strptime branch — month-first, day-second.
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
got := parseDates(header)
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
if len(got) != len(want) {
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
}
for i, e := range got {
if e.month != want[i] {
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
}
}
}
// TTL smoke test: second call within TTL must not call fetch again.
func TestLoadAdults_CacheHit(t *testing.T) {
dir := t.TempDir()
+43
View File
@@ -0,0 +1,43 @@
package api
// AdultsMonthData is the reconciled ledger for one adult member in one month.
// Keys match Python's result["members"][name]["months"][YYYY-MM].
type AdultsMonthData struct {
Expected int `json:"expected"`
OriginalExpected int `json:"original_expected"`
AttendanceCount int `json:"attendance_count"`
Exception *ExceptionData `json:"exception"`
Paid float64 `json:"paid"` // float: proportional allocator may produce fractional CZK
Transactions []MemberTxEntry `json:"transactions"`
}
// AdultsMemberData is the reconciled ledger for one adult member.
// Keys match Python's result["members"][name].
type AdultsMemberData struct {
Tier string `json:"tier"`
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM → month data
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
TotalBalance int `json:"total_balance"`
}
// AdultsResponse is the JSON contract for GET /api/adults.
// MemberData, MonthLabels, and RawPayments correspond to the Python view-model
// fields member_data, month_labels_json, and raw_payments_json respectively,
// but as nested objects rather than pre-serialised JSON strings.
type AdultsResponse struct {
Months []string `json:"months"` // display labels
RawMonths []string `json:"raw_months"` // "YYYY-MM"
Results []MemberRow `json:"results"`
Totals []TotalCell `json:"totals"`
MemberData map[string]AdultsMemberData `json:"member_data"` // name → ledger
MonthLabels map[string]string `json:"month_labels"` // YYYY-MM → display label
RawPayments map[string][]RawTransaction `json:"raw_payments"` // name → raw sheet rows
Credits []Credit `json:"credits"`
Debts []Credit `json:"debts"`
Unmatched []RawTransaction `json:"unmatched"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}
+264
View File
@@ -0,0 +1,264 @@
package api
import (
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"sort"
"time"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
type monthSums struct{ expected, paid int }
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
// Mirrors legacy/scripts/views.py:build_adults_view_model.
func buildAdultsResponse(
members []domreconcile.Member,
sortedMonths []string,
result domreconcile.Result,
txns []domreconcile.Transaction,
cfg config.Config,
currentMonth string,
) AdultsResponse {
monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths)
// Collect tier-A names, sorted.
var adultNames []string
allNames := make([]string, 0, len(members))
for _, m := range members {
allNames = append(allNames, m.Name)
if m.Tier == "A" {
adultNames = append(adultNames, m.Name)
}
}
sort.Strings(adultNames)
// Per-month aggregate totals (expected and paid integers).
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
for _, m := range sortedMonths {
monthlyTotals[m] = &monthSums{}
}
var results []MemberRow
for _, name := range adultNames {
mr := result.Members[name]
row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
row.UnpaidPeriods = joinComma(unpaidMonths)
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
row.Balance = settledBalance(mr, currentMonth)
row.PayableAmount = max(0, -row.Balance)
results = append(results, row)
}
// Totals row.
totalsCells := make([]TotalCell, len(sortedMonths))
for i, m := range sortedMonths {
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
status := "empty"
if t.expected > 0 || t.paid > 0 {
switch {
case t.paid == t.expected:
status = "ok"
case t.paid < t.expected:
status = "unpaid"
default:
status = "surplus"
}
}
totalsCells[i] = TotalCell{
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
Status: status,
}
}
// Credits and debts (settled balance, past months only).
var credits, debts []Credit
for _, name := range adultNames {
bal := settledBalance(result.Members[name], currentMonth)
if bal > 0 {
credits = append(credits, Credit{Name: name, Amount: bal})
} else if bal < 0 {
debts = append(debts, Credit{Name: name, Amount: -bal})
}
}
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
// member_data: full reconcile output for all members (not just adults).
memberData := make(map[string]AdultsMemberData, len(result.Members))
for name, mr := range result.Members {
months := make(map[string]AdultsMonthData, len(mr.Months))
for m, md := range mr.Months {
var exc *ExceptionData
if md.Exception != nil {
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
}
txEntries := make([]MemberTxEntry, len(md.Transactions))
for i, te := range md.Transactions {
txEntries[i] = memberTxFromDomain(te)
}
months[m] = AdultsMonthData{
Expected: md.Expected,
OriginalExpected: md.OriginalExpected,
AttendanceCount: md.AttendanceCount,
Exception: exc,
Paid: md.Paid,
Transactions: txEntries,
}
}
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
for i, oe := range mr.OtherTransactions {
otherTxs[i] = memberOtherFromDomain(oe)
}
memberData[name] = AdultsMemberData{
Tier: mr.Tier,
Months: months,
OtherTransactions: otherTxs,
TotalBalance: mr.TotalBalance,
}
}
unmatched := make([]RawTransaction, len(result.Unmatched))
for i, tx := range result.Unmatched {
unmatched[i] = rawTxFromDomain(tx)
}
return AdultsResponse{
Months: labelsForMonths(sortedMonths, monthLabels),
RawMonths: sortedMonths,
Results: ensureSlice(results),
Totals: totalsCells,
MemberData: memberData,
MonthLabels: monthLabels,
RawPayments: groupRawPaymentsByPerson(txns, allNames),
Credits: ensureSlice(credits),
Debts: ensureSlice(debts),
Unmatched: unmatched,
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}
func buildAdultMemberRow(
name string,
mr domreconcile.MemberResult,
sortedMonths []string,
monthLabels map[string]string,
currentMonth string,
monthlyTotals map[string]*monthSums,
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
row = MemberRow{Name: name}
for _, m := range sortedMonths {
md, ok := mr.Months[m]
if !ok {
md = domreconcile.MonthData{}
}
paid := int(md.Paid)
expected := md.Expected
if t := monthlyTotals[m]; t != nil {
t.expected += expected
t.paid += paid
}
var feeDisplay string
var isOverridden bool
if md.Exception != nil && md.Exception.Amount != md.OriginalExpected {
isOverridden = true
if md.AttendanceCount > 0 {
feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount)
} else {
feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected)
}
} else {
if md.AttendanceCount > 0 {
feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount)
} else {
feeDisplay = fmt.Sprintf("%d CZK", expected)
}
}
status := "empty"
cellText := "-"
amountToPay := 0
switch {
case expected > 0:
amountToPay = max(0, expected-paid)
switch {
case paid >= expected:
status = "ok"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
case paid > 0:
status = "partial"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
default:
status = "unpaid"
cellText = fmt.Sprintf("0/%s", feeDisplay)
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
}
case paid > 0:
status = "surplus"
cellText = fmt.Sprintf("PAID %d", paid)
}
tooltip := ""
if expected > 0 || paid > 0 {
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected)
}
row.Months = append(row.Months, MonthCell{
Text: cellText,
Overridden: isOverridden,
Status: status,
Amount: amountToPay,
Month: monthLabels[m],
RawMonth: m,
Tooltip: tooltip,
})
}
return row, unpaidMonths, rawUnpaidMonths
}
// rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y").
func rawMonthLabel(m string) string {
dt, err := time.Parse("2006-01", m)
if err != nil {
return m
}
return dt.Format("01/2006")
}
func joinComma(parts []string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result += ", " + p
}
return result
}
func joinPlus(parts []string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result += "+" + p
}
return result
}
+184
View File
@@ -0,0 +1,184 @@
package api
import (
"fuj-management/go/internal/domain/czech"
"regexp"
"sort"
"strings"
"time"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// getMonthLabels builds display labels for sortedMonths, merging month names
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
// Mirrors legacy/scripts/views.py:get_month_labels.
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
labels := make(map[string]string, len(sortedMonths))
for _, m := range sortedMonths {
dt, err := time.Parse("2006-01", m)
if err != nil {
labels[m] = m
continue
}
var mergedIn []string
for src, dst := range mergedMonths {
if dst == m {
mergedIn = append(mergedIn, src)
}
}
sort.Strings(mergedIn)
if len(mergedIn) == 0 {
labels[m] = dt.Format("Jan 2006")
continue
}
allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned
sort.Strings(allMonths)
years := map[int]bool{}
for _, x := range allMonths {
if d, err2 := time.Parse("2006-01", x); err2 == nil {
years[d.Year()] = true
}
}
parts := make([]string, 0, len(allMonths))
if len(years) > 1 {
for _, x := range allMonths {
if d, err2 := time.Parse("2006-01", x); err2 == nil {
parts = append(parts, d.Format("Jan 2006"))
}
}
labels[m] = strings.Join(parts, "+")
} else {
for _, x := range allMonths {
if d, err2 := time.Parse("2006-01", x); err2 == nil {
parts = append(parts, d.Format("Jan"))
}
}
labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006")
}
}
return labels
}
// labelsForMonths returns the display labels for sortedMonths in slice order.
func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
out := make([]string, len(sortedMonths))
for i, m := range sortedMonths {
out[i] = labels[m]
}
return out
}
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalKey returns a normalized form of a person name used for deduplication.
// Mirrors legacy/scripts/match_payments.py:canonical_member_key.
func canonicalKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
}
// groupRawPaymentsByPerson groups transactions by the "person" column,
// canonicalizing names against memberNames where possible.
// Mirrors legacy/scripts/views.py:group_payments_by_person (without the
// "Unmatched / Unknown" bucket that is payments-view-specific).
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
canonicalByKey := make(map[string]string, len(memberNames))
for _, n := range memberNames {
k := canonicalKey(n)
if _, exists := canonicalByKey[k]; !exists {
canonicalByKey[k] = n
}
}
grouped := make(map[string][]RawTransaction)
for _, tx := range txns {
person := strings.TrimSpace(tx.Person)
if person == "" {
continue
}
for _, p := range strings.Split(person, ",") {
p = questionMarkRe.ReplaceAllString(p, "")
p = strings.TrimSpace(p)
if p == "" {
continue
}
key := p
if canonical, ok := canonicalByKey[canonicalKey(p)]; ok {
key = canonical
}
grouped[key] = append(grouped[key], rawTxFromDomain(tx))
}
}
for k := range grouped {
sort.Slice(grouped[k], func(i, j int) bool {
return grouped[k][i].Date > grouped[k][j].Date
})
}
return grouped
}
// rawTxFromDomain converts a domain Transaction to the wire RawTransaction.
func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction {
inferredAmount := 0.0
if tx.InferredAmount != nil {
inferredAmount = *tx.InferredAmount
}
return RawTransaction{
Date: tx.Date,
Amount: tx.Amount,
ManualFix: tx.ManualFix,
Person: tx.Person,
Purpose: tx.Purpose,
InferredAmount: inferredAmount,
Sender: tx.Sender,
VS: tx.VS,
Message: tx.Message,
BankID: tx.BankID,
SyncID: tx.SyncID,
}
}
// memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry.
func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry {
return MemberTxEntry{
Amount: te.Amount,
Date: te.Date,
Sender: te.Sender,
Message: te.Message,
Confidence: te.Confidence,
}
}
// memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry.
func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry {
return MemberOtherEntry{
Amount: oe.Amount,
Date: oe.Date,
Sender: oe.Sender,
Message: oe.Message,
Purpose: oe.Purpose,
Confidence: oe.Confidence,
}
}
// settledBalance computes the settled balance: sum of (paid − expected) for months
// strictly before currentMonth. Months with IsUnknown=true are excluded to match
// Python's isinstance(exp, int) guard (skips "?" months).
func settledBalance(mr domreconcile.MemberResult, currentMonth string) int {
total := 0
for m, md := range mr.Months {
if m >= currentMonth || md.IsUnknown {
continue
}
total += int(md.Paid) - md.Expected
}
return total
}
// ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so
// json.Marshal emits [] instead of null.
func ensureSlice[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
+282
View File
@@ -0,0 +1,282 @@
package api
import (
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"sort"
"strconv"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
// Mirrors legacy/scripts/views.py:build_juniors_view_model.
func buildJuniorsResponse(
members []domreconcile.Member,
sortedMonths []string,
result domreconcile.Result,
txns []domreconcile.Transaction,
cfg config.Config,
currentMonth string,
) JuniorsResponse {
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
allNames := make([]string, 0, len(members))
juniorNames := make([]string, 0, len(members))
for _, m := range members {
allNames = append(allNames, m.Name)
juniorNames = append(juniorNames, m.Name)
}
sort.Strings(juniorNames)
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
for _, m := range sortedMonths {
monthlyTotals[m] = &monthSums{}
}
var results []MemberRow
for _, name := range juniorNames {
mr := result.Members[name]
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
row.UnpaidPeriods = joinComma(unpaidMonths)
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
row.Balance = settledBalance(mr, currentMonth)
row.PayableAmount = max(0, -row.Balance)
results = append(results, row)
}
// Totals row.
totalsCells := make([]TotalCell, len(sortedMonths))
for i, m := range sortedMonths {
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
status := "empty"
if t.expected > 0 || t.paid > 0 {
switch {
case t.paid == t.expected:
status = "ok"
case t.paid < t.expected:
status = "unpaid"
default:
status = "surplus"
}
}
totalsCells[i] = TotalCell{
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
Status: status,
}
}
var credits, debts []Credit
for _, name := range juniorNames {
bal := settledBalance(result.Members[name], currentMonth)
if bal > 0 {
credits = append(credits, Credit{Name: name, Amount: bal})
} else if bal < 0 {
debts = append(debts, Credit{Name: name, Amount: -bal})
}
}
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
// member_data: full reconcile output for all junior members.
memberData := make(map[string]JuniorsMemberData, len(result.Members))
for name, mr := range result.Members {
months := make(map[string]JuniorsMonthData, len(mr.Months))
for m, md := range mr.Months {
var exc *ExceptionData
if md.Exception != nil {
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
}
txEntries := make([]MemberTxEntry, len(md.Transactions))
for i, te := range md.Transactions {
txEntries[i] = memberTxFromDomain(te)
}
months[m] = JuniorsMonthData{
Expected: juniorExpected(md),
OriginalExpected: juniorOriginalExpected(md),
AttendanceCount: md.AttendanceCount,
Exception: exc,
Paid: md.Paid,
Transactions: txEntries,
}
}
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
for i, oe := range mr.OtherTransactions {
otherTxs[i] = memberOtherFromDomain(oe)
}
memberData[name] = JuniorsMemberData{
Tier: mr.Tier,
Months: months,
OtherTransactions: otherTxs,
TotalBalance: mr.TotalBalance,
}
}
unmatched := make([]RawTransaction, len(result.Unmatched))
for i, tx := range result.Unmatched {
unmatched[i] = rawTxFromDomain(tx)
}
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
"/edit#gid=" + config.JuniorSheetGID
return JuniorsResponse{
Months: labelsForMonths(sortedMonths, monthLabels),
RawMonths: sortedMonths,
Results: ensureSlice(results),
Totals: totalsCells,
MemberData: memberData,
MonthLabels: monthLabels,
RawPayments: groupRawPaymentsByPerson(txns, allNames),
Credits: ensureSlice(credits),
Debts: ensureSlice(debts),
Unmatched: unmatched,
AttendanceURL: juniorURL,
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}
func buildJuniorMemberRow(
name string,
mr domreconcile.MemberResult,
sortedMonths []string,
monthLabels map[string]string,
currentMonth string,
monthlyTotals map[string]*monthSums,
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
row = MemberRow{Name: name}
for _, m := range sortedMonths {
md, ok := mr.Months[m]
if !ok {
md = domreconcile.MonthData{}
}
paid := int(md.Paid)
// Update monthly totals (skip "?" months for expected).
if t := monthlyTotals[m]; t != nil {
if !md.IsUnknown {
t.expected += md.Expected
}
t.paid += paid
}
// Attendance breakdown string e.g. ":3J,2A".
var breakdown string
jc, ac := md.JuniorAttendance, md.AdultAttendance
switch {
case jc > 0 && ac > 0:
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
case jc > 0:
breakdown = fmt.Sprintf(":%dJ", jc)
case ac > 0:
breakdown = fmt.Sprintf(":%dA", ac)
}
countStr := ""
if md.AttendanceCount > 0 {
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
}
// Fee display string.
var feeDisplay string
var isOverridden bool
if md.Exception != nil {
overrideAmount := md.Exception.Amount
var origStr string
if md.IsUnknown {
origStr = "?"
isOverridden = true
} else {
origStr = strconv.Itoa(md.OriginalExpected)
isOverridden = overrideAmount != md.OriginalExpected
}
if isOverridden {
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
} else {
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
}
} else {
if md.IsUnknown {
feeDisplay = "? CZK" + countStr
} else {
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
}
}
status := "empty"
cellText := "-"
amountToPay := 0
switch {
case md.IsUnknown:
cellText = "?" + countStr
case md.Expected > 0:
switch {
case paid >= md.Expected:
status = "ok"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
case paid > 0:
status = "partial"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
amountToPay = md.Expected - paid
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
default:
status = "unpaid"
cellText = fmt.Sprintf("0/%s", feeDisplay)
amountToPay = md.Expected
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
}
case paid > 0:
status = "surplus"
cellText = fmt.Sprintf("PAID %d", paid)
}
tooltip := ""
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
// 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{
Text: cellText,
Overridden: isOverridden,
Status: status,
Amount: amountToPay,
Month: monthLabels[m],
RawMonth: m,
Tooltip: tooltip,
})
}
return row, unpaidMonths, rawUnpaidMonths
}
// juniorExpected converts domain MonthData to the Expected wire type.
// When an exception exists it always produces a concrete int; otherwise
// the "?" sentinel is used when IsUnknown=true.
func juniorExpected(md domreconcile.MonthData) Expected {
if md.Exception == nil && md.IsUnknown {
return Expected{Unknown: true}
}
return Expected{Value: md.Expected}
}
// juniorOriginalExpected converts the original (pre-exception) expected fee.
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
if md.IsUnknown {
return Expected{Unknown: true}
}
return Expected{Value: md.OriginalExpected}
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"fuj-management/go/internal/config"
"sort"
"strings"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// buildPaymentsResponse constructs the PaymentsResponse wire type.
// Mirrors legacy/scripts/views.py:build_payments_view_model.
func buildPaymentsResponse(
txns []domreconcile.Transaction,
memberNames []string,
) PaymentsResponse {
grouped := groupRawPaymentsByPerson(txns, memberNames)
// Add unmatched/unknown bucket for transactions with no person set.
const unknownKey = "Unmatched / Unknown"
for _, tx := range txns {
if strings.TrimSpace(tx.Person) == "" {
grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx))
}
}
// Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson).
if rows, ok := grouped[unknownKey]; ok {
sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date })
grouped[unknownKey] = rows
}
sortedPeople := make([]string, 0, len(grouped))
for p := range grouped {
sortedPeople = append(sortedPeople, p)
}
sort.Strings(sortedPeople)
return PaymentsResponse{
GroupedPayments: grouped,
SortedPeople: sortedPeople,
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
}
}
+155
View File
@@ -0,0 +1,155 @@
package api
import (
"context"
"encoding/json"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"log/slog"
"net/http"
"time"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// Handler holds the shared dependencies for all /api/* routes.
type Handler struct {
BuildVersion string
BuildCommit string
BuildDate string
Sources membership.Sources
Config config.Config
Logger *slog.Logger
}
// FlushCache invalidates all cached data via the underlying Sources.
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
// ServeVersion handles GET /api/version.
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, VersionResponse{
Tag: h.BuildVersion,
Commit: h.BuildCommit,
BuildDate: h.BuildDate,
})
}
// ServeAdults handles GET /api/adults.
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssembleAdults(r.Context())
if err != nil {
h.writeError(w, r, err)
return
}
writeJSON(w, resp)
}
// AssembleAdults loads all data and builds the adults view model.
// Shared between the JSON API route and the HTML handler.
func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
if err != nil {
return AdultsResponse{}, err
}
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
}
// ServeJuniors handles GET /api/juniors.
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssembleJuniors(r.Context())
if err != nil {
h.writeError(w, r, err)
return
}
writeJSON(w, resp)
}
// AssembleJuniors loads all data and builds the juniors view model.
// Shared between the JSON API route and the HTML handler.
func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) {
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
if err != nil {
return JuniorsResponse{}, err
}
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
}
// ServePayments handles GET /api/payments.
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssemblePayments(r.Context())
if err != nil {
h.writeError(w, r, err)
return
}
writeJSON(w, resp)
}
// AssemblePayments loads transactions and builds the payments view model.
// Shared between the JSON API route and the HTML handler.
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
txns, err := h.Sources.LoadTransactions(ctx)
if err != nil {
return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err)
}
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
}
func (h *Handler) loadAll(ctx context.Context, adults bool) (
members []domreconcile.Member,
sortedMonths []string,
txns []domreconcile.Transaction,
exceptions map[domreconcile.ExceptionKey]domreconcile.Exception,
err error,
) {
if adults {
members, sortedMonths, err = h.Sources.LoadAdults(ctx)
} else {
members, sortedMonths, err = h.Sources.LoadJuniors(ctx)
}
if err != nil {
err = fmt.Errorf("load members: %w", err)
return
}
txns, err = h.Sources.LoadTransactions(ctx)
if err != nil {
err = fmt.Errorf("load transactions: %w", err)
return
}
exceptions, err = h.Sources.LoadExceptions(ctx)
if err != nil {
err = fmt.Errorf("load exceptions: %w", err)
}
return
}
func (h *Handler) allMemberNames(ctx context.Context) []string {
var names []string
if adults, _, err := h.Sources.LoadAdults(ctx); err == nil {
for _, m := range adults {
names = append(names, m.Name)
}
}
if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil {
for _, m := range juniors {
names = append(names, m.Name)
}
}
return names
}
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
if h.Logger != nil {
h.Logger.Error("api error", "path", r.URL.Path, "err", err)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}
+42
View File
@@ -0,0 +1,42 @@
package api
// JuniorsMonthData is the reconciled ledger for one junior member in one month.
// expected and original_expected may be the "?" sentinel (single-attendance month
// requiring manual review); they are carried via the Expected type.
type JuniorsMonthData struct {
Expected Expected `json:"expected"`
OriginalExpected Expected `json:"original_expected"`
AttendanceCount int `json:"attendance_count"`
Exception *ExceptionData `json:"exception"`
Paid float64 `json:"paid"`
Transactions []MemberTxEntry `json:"transactions"`
}
// JuniorsMemberData is the reconciled ledger for one junior member.
type JuniorsMemberData struct {
Tier string `json:"tier"`
Months map[string]JuniorsMonthData `json:"months"`
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
TotalBalance int `json:"total_balance"`
}
// JuniorsResponse is the JSON contract for GET /api/juniors.
// Same outer shape as AdultsResponse; differs in that member_data carries
// Expected (int or "?") for expected/original_expected fields.
type JuniorsResponse struct {
Months []string `json:"months"`
RawMonths []string `json:"raw_months"`
Results []MemberRow `json:"results"`
Totals []TotalCell `json:"totals"`
MemberData map[string]JuniorsMemberData `json:"member_data"`
MonthLabels map[string]string `json:"month_labels"`
RawPayments map[string][]RawTransaction `json:"raw_payments"`
Credits []Credit `json:"credits"`
Debts []Credit `json:"debts"`
Unmatched []RawTransaction `json:"unmatched"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}
+9
View File
@@ -0,0 +1,9 @@
package api
// PaymentsResponse is the JSON contract for GET /api/payments.
type PaymentsResponse struct {
GroupedPayments map[string][]RawTransaction `json:"grouped_payments"` // person name → rows
SortedPeople []string `json:"sorted_people"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
}
+81
View File
@@ -0,0 +1,81 @@
package api
// schemagen_test.go generates and golden-compares JSON Schema files for every
// /api/X response type.
//
// Normal run (CI): go test ./internal/web/api/... — asserts schemas match committed files.
// Regenerate: go test -run TestGenerateSchemas -update ./internal/web/api/...
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"testing"
"github.com/invopop/jsonschema"
)
var updateFlag = flag.Bool("update", false, "overwrite api-schema fixture files with freshly generated schemas")
// JSONSchema makes Expected self-describing for the reflector at test time.
// The method is in a test file and is not compiled into production binaries.
// It emits oneOf [integer, "?"] to match the custom MarshalJSON behaviour.
func (Expected) JSONSchema() *jsonschema.Schema {
return &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
{Type: "integer"},
{Enum: []any{"?"}},
},
}
}
func TestGenerateSchemas(t *testing.T) {
r := &jsonschema.Reflector{
AllowAdditionalProperties: false,
}
cases := []struct {
name string
val any
}{
{"adults", &AdultsResponse{}},
{"juniors", &JuniorsResponse{}},
{"payments", &PaymentsResponse{}},
{"version", &VersionResponse{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
schema := r.Reflect(tc.val)
got, err := json.MarshalIndent(schema, "", " ")
if err != nil {
t.Fatalf("marshal schema: %v", err)
}
got = append(got, '\n')
// Path: go/internal/web/api/ → ../../.. → go/ → tests/fixtures/api-schema/
path := filepath.Join("..", "..", "..", "tests", "fixtures", "api-schema", tc.name+".schema.json")
if *updateFlag {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatalf("write schema: %v", err)
}
t.Logf("wrote %s", path)
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v (re-run with -update to generate)", path, err)
}
if string(got) != string(want) {
t.Errorf("schema mismatch for %s; re-run with -update to regenerate", tc.name)
}
})
}
}
+122
View File
@@ -0,0 +1,122 @@
// Package api defines wire types for the JSON API contract (/api/...).
// These structs have explicit json: tags matching the Python view-model dict
// keys so that M5 parity tests can do byte-equal comparison between backends.
//
// The three Python template-only JSON-string fields (member_data,
// month_labels_json, raw_payments_json) are represented here as nested objects;
// the Python /api/X shadow endpoint strips the json.dumps wrappers before
// serialising.
package api
import (
"encoding/json"
"fmt"
)
// Expected holds a junior fee expectation: either a concrete integer or the
// "?" sentinel (single-attendance month requiring manual review).
// MarshalJSON emits the integer or the JSON string "?".
type Expected struct {
Value int
Unknown bool
}
func (e Expected) MarshalJSON() ([]byte, error) {
if e.Unknown {
return []byte(`"?"`), nil
}
return json.Marshal(e.Value)
}
func (e *Expected) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] == '"' {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
if s == "?" {
e.Unknown = true
return nil
}
return fmt.Errorf("api.Expected: unexpected string %q", s)
}
e.Unknown = false
return json.Unmarshal(data, &e.Value)
}
// ExceptionData is a manual fee override for one member in one month.
type ExceptionData struct {
Amount int `json:"amount"`
Note string `json:"note"`
}
// MemberTxEntry is one payment allocation to a member+month, as stored in
// member_data.months[YYYY-MM].transactions.
type MemberTxEntry struct {
Amount float64 `json:"amount"`
Date string `json:"date"`
Sender string `json:"sender"`
Message string `json:"message"`
Confidence string `json:"confidence"`
}
// MemberOtherEntry is an "other:…" purpose payment allocated to a member.
type MemberOtherEntry struct {
Amount float64 `json:"amount"`
Date string `json:"date"`
Sender string `json:"sender"`
Message string `json:"message"`
Purpose string `json:"purpose"`
Confidence string `json:"confidence"`
}
// RawTransaction is a full payments-sheet row.
// Used for unmatched transactions and raw_payments groupings.
// Columns match the sheet layout: Date|Amount|manual fix|Person|Purpose|
// Inferred Amount|Sender|VS|Message|Bank ID|Sync ID.
type RawTransaction struct {
Date string `json:"date"`
Amount float64 `json:"amount"`
ManualFix string `json:"manual_fix"`
Person string `json:"person"`
Purpose string `json:"purpose"`
InferredAmount float64 `json:"inferred_amount"`
Sender string `json:"sender"`
VS string `json:"vs"`
Message string `json:"message"`
BankID string `json:"bank_id"`
SyncID string `json:"sync_id"`
}
// MonthCell is one cell in a member's month column on the dashboard.
type MonthCell struct {
Text string `json:"text"`
Overridden bool `json:"overridden"`
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
Amount int `json:"amount"`
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
RawMonth string `json:"raw_month"` // "YYYY-MM"
Tooltip string `json:"tooltip"`
}
// TotalCell is one cell in the monthly totals row.
type TotalCell struct {
Text string `json:"text"`
Status string `json:"status"`
}
// MemberRow is one member's summary row in the dashboard results table.
type MemberRow struct {
Name string `json:"name"`
Months []MonthCell `json:"months"`
Balance int `json:"balance"`
UnpaidPeriods string `json:"unpaid_periods"`
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
PayableAmount int `json:"payable_amount"`
}
// Credit is one entry in the credits or debts lists.
type Credit struct {
Name string `json:"name"`
Amount int `json:"amount"`
}
+9
View File
@@ -0,0 +1,9 @@
package api
// VersionResponse is the JSON contract for GET /api/version.
// Keys match Python's BUILD_META dict (see legacy/app.py).
type VersionResponse struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
}
+9
View File
@@ -0,0 +1,9 @@
package web
import "embed"
//go:embed templates
var templateFS embed.FS
//go:embed static
var staticFS embed.FS

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