Compare commits

...

26 Commits

Author SHA1 Message Date
1df1863725 Merge pull request 'feat(ci): gitops image-update PR workflow for home-kubernetes' (#39) from feat/gitops-pr-action into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #39
2026-06-12 19:31:51 +02:00
995abfacb2 feat(ci): add gitops-update workflow to open image-bump PR in home-kubernetes
All checks were successful
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
f047150004 fix(tests): derive fee test expectations from constants, not hardcoded values
All checks were successful
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
6f2994b8ad Merge pull request 'feat(display): limit /adults and /juniors to last N months by default' (#38) from feat/months-to-show into main
All checks were successful
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
c2a381bb63 fix(display): default from-selector to last N months; keep all months selectable
All checks were successful
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
c0487e3af0 feat(display): limit /adults and /juniors to last N months by default
All checks were successful
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
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
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
72e29b1882 chore(changelog): add entry for fee rate update 2026-05 through 2026-08
All checks were successful
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
241fecfb2c Merge pull request 'feat: multi-account Fio sync + switch QR default to 2502035405/2010' (#37) from feat/multi-account-bank-sync into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 22s
Reviewed-on: #37
2026-05-24 19:57:52 +00:00
723591cbce feat(fees): update adult monthly rates for 2026-05 through 2026-08
All checks were successful
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
69af4c1e3b feat: multi-account Fio sync + switch QR default to 2502035405/2010
All checks were successful
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
152908fec6 Merge pull request 'gitignore go/parity' (#36) from fix/fill-first-multi-month-allocation into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 34s
Reviewed-on: #36
2026-05-24 19:16:27 +00:00
fbc5a41d12 gitignore go/parity
All checks were successful
Deploy to K8s / deploy (push) Successful in 22s
2026-05-24 21:15:38 +02:00
7f801d27f5 Merge branch 'feat/go-m6-7-embed-verify'
All checks were successful
Deploy to K8s / deploy (push) Successful in 15s
2026-05-24 21:13:15 +02:00
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
All checks were successful
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
8734089223 fix(reconcile): fill earliest month deficit first in multi-month allocations
All checks were successful
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
aaa876e593 fix(python): parse Fio 2-digit-year dates + add make sync-debug
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Successful in 59s
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
d981392593 feat(go): M6.7 — single-binary embed verification
All checks were successful
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
f25552eef2 chore: CHANGELOG + tick M6.6 (f6ba85b) and M6.6.1 (4276d7b) in progress tracker
All checks were successful
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
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
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #33
2026-05-08 12:54:23 +00:00
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
Some checks failed
Deploy to K8s / deploy (push) Has been cancelled
Reviewed-on: #32
2026-05-08 12:54:16 +00:00
919845518c feat(go): M6.6.1 — QR payment popup modal on /adults and /juniors
All checks were successful
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
fe935235e8 feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages
All checks were successful
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
e22ab8cc49 chore: CHANGELOG + tick M6.5 in progress tracker — SHA e53e238
All checks were successful
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
e53e238ca6 Merge pull request 'feat(go): M6.5 — member-detail modal JS module' (#31) from feat/go-m6-5-modal-js into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #31
2026-05-08 11:18:46 +00:00
60 changed files with 2867 additions and 259 deletions

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"
]
}
}

View File

@@ -0,0 +1,94 @@
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
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git curl ca-certificates
- 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
GIT_TAG="${{ inputs.tag }}"
else
# workflow_run: use the ref name of the triggering workflow (the pushed git tag).
GIT_TAG="${{ github.event.workflow_run.head_branch }}"
fi
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "Resolved image: ${IMAGE}"
- name: Configure git identity
run: |
git config --global user.name "uh-cli bot"
git config --global user.email "bot@hrajfrisbee.cz"
- 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: |
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "${{ steps.resolve.outputs.image }}" \
--git-repo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ tmp/
# go build output
bin/
go/parity

View File

@@ -1,5 +1,69 @@
# Changelog
## 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.

View File

@@ -35,6 +35,7 @@ help:
@echo " make sync - Sync Fio transactions to Google Sheets"
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
@echo " make sync-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"
@@ -125,6 +126,9 @@ sync-2025: $(PYTHON)
sync-2026: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
infer: $(PYTHON)
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)

6
app.py
View File

@@ -19,7 +19,7 @@ sys.path.append(str(scripts_dir))
from config import (
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
BANK_ACCOUNT, CREDENTIALS_PATH,
BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW,
)
from attendance import get_members_with_fees, get_junior_members_with_fees
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
@@ -255,7 +255,7 @@ def adults_view():
bank_account=BANK_ACCOUNT,
)
record_step("process_data")
return render_template("adults.html", **vm)
return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **vm)
@app.route("/juniors")
def juniors_view():
@@ -291,7 +291,7 @@ def juniors_view():
bank_account=BANK_ACCOUNT,
)
record_step("process_data")
return render_template("juniors.html", **vm)
return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **vm)
@app.route("/payments")
def payments():

View File

@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
**Current milestone:** M6 — Go-native HTML frontend
**Started:** 2026-05-04
**Last updated:** 2026-05-08 (M6.4 merged)
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
## How to use
@@ -114,9 +114,10 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
- [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`
- [ ] **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.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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 471498) with a single unified loop. Keep lines 466469 above and the even-split fallback below (lines 499510) 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 499510) 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 358372) 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 358372) 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.

View File

@@ -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 4a4c (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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.)

View File

@@ -11,6 +11,7 @@ import (
"fuj-management/go/internal/services/banksync"
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web"
"io"
"log/slog"
"os"
"time"
@@ -82,9 +83,40 @@ func serverCmd(args []string) {
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)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
yr := time.Now().Year()
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
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
},
}
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
@@ -165,7 +197,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 != "" {
@@ -181,7 +213,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)
@@ -242,3 +274,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
}

View File

@@ -5,6 +5,7 @@ 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

View File

@@ -37,6 +37,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP
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=

View File

@@ -7,6 +7,28 @@ import (
"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"
@@ -34,29 +56,39 @@ var CacheSheetMap = map[string]string{
// Mirrors 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"),
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),
}
}
@@ -91,3 +123,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
}

View File

@@ -11,7 +11,7 @@ const (
var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
"2026-01": 750, "2026-02": 750, "2026-03": 350,
"2026-04": 700, "2026-05": 700,
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 600, "2026-08": 600,
}
// CalculateFee returns the adult fee in CZK for attendanceCount practices in

View File

@@ -5,24 +5,40 @@ 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 regardless of month.
{"single practice", 1, "2026-05", AdultFeeSingle},
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
// 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 {

View File

@@ -5,24 +5,37 @@ 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")},
// 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 {

View File

@@ -115,10 +115,11 @@ 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.
@@ -317,34 +318,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,
@@ -354,6 +347,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

View File

@@ -111,36 +111,26 @@ 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": {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)
}
}
@@ -374,3 +364,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)
}
}

View File

@@ -35,13 +35,13 @@ 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.
// SyncToSheets fetches Fio transactions from all provided clients and appends
// new ones to the payments sheet. Returns the number of rows appended.
// Ports 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 +84,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",

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)
}
@@ -51,7 +51,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 +72,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 +92,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 +105,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 +118,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 +131,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 +144,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

View File

@@ -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 }

View File

@@ -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{

View File

@@ -111,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",

View File

@@ -39,4 +39,5 @@ type AdultsResponse struct {
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}

View File

@@ -138,8 +138,9 @@ func buildAdultsResponse(
Unmatched: unmatched,
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.BankAccount,
BankAccount: cfg.QRAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}

View File

@@ -134,8 +134,9 @@ func buildJuniorsResponse(
Unmatched: unmatched,
AttendanceURL: juniorURL,
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.BankAccount,
BankAccount: cfg.QRAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}

View File

@@ -23,6 +23,9 @@ type Handler struct {
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{

View File

@@ -38,4 +38,5 @@ type JuniorsResponse struct {
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}

View File

@@ -0,0 +1,93 @@
package web
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// TestEmbedCompleteness guards against a new template or static file being
// added to disk but missing from the embedded FS (e.g. a new directory that
// the //go:embed glob does not match).
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskDir string
embedFS fs.FS
embedRoot string
}{
{"templates", "templates", templateFS, "templates"},
{"static", "static", staticFS, "static"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diskFS := os.DirFS(tc.diskDir)
_ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
embPath := tc.embedRoot + "/" + path
if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil {
t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr)
}
return nil
})
})
}
}
// TestStaticAssetsServed verifies that /static/* is served from the embedded
// FS through the same mux wiring used in server.go, so a standalone binary
// with no adjacent static/ directory still delivers assets.
func TestStaticAssetsServed(t *testing.T) {
subFS, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatalf("fs.Sub static: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS)))
cases := []struct {
path string
wantCT string
wantSnippet string
}{
{"/static/css/app.css", "text/css", "body {"},
{"/static/js/member-detail.js", "javascript", "Member-detail modal"},
{"/static/js/filters.js", "javascript", ""},
{"/static/js/payment-qr.js", "javascript", ""},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, tc.wantCT) {
t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT)
}
if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) {
t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet)
}
})
}
// Sanity: unknown path → 404 (file server doesn't fall through silently)
t.Run("missing-file", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("unknown static path: status %d, want 404", w.Code)
}
})
}

View File

@@ -1,8 +1,11 @@
package web
import (
"bytes"
"fmt"
"fuj-management/go/internal/web/api"
"net/http"
"runtime/debug"
)
// HTMLHandler serves the Go-native HTML frontend.
@@ -10,11 +13,12 @@ type HTMLHandler struct {
renderer *Renderer
build BuildInfo
apiHandler *api.Handler
actions ActionHandlers
}
// NewHTMLHandler constructs an HTMLHandler.
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
}
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
@@ -62,10 +66,78 @@ func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
})
}
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
pd := PageData{Active: "sync", Build: h.build}
if h.actions.BankSync == nil {
h.renderer.Render(w, "sync", SyncPageData{
PageData: pd,
Output: "Bank sync is not configured.",
Success: false,
})
return
}
var buf bytes.Buffer
success := true
if err := h.actions.BankSync(r.Context(), &buf); err != nil {
fmt.Fprintf(&buf, "\nError: %s\n\nStack trace:\n%s", err.Error(), debug.Stack())
success = false
}
fmt.Fprintln(&buf, "\n=== Flush Cache ===")
n, err := h.apiHandler.FlushCache()
if err != nil {
fmt.Fprintf(&buf, "flush error: %s\n", err.Error())
success = false
} else {
fmt.Fprintf(&buf, "%d cache file(s) deleted.\n", n)
}
h.renderer.Render(w, "sync", SyncPageData{
PageData: pd,
Output: buf.String(),
Success: success,
})
}
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
})
}
// ServeFlushCachePOST handles POST /flush-cache: flushes and re-renders with count.
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
n, _ := h.apiHandler.FlushCache()
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
Flushed: true,
Deleted: n,
})
}
// ServeQR handles GET /qr: generates and returns a Czech QR Platba PNG.
func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
account := q.Get("account")
amount := q.Get("amount")
message := q.Get("message")
if account == "" {
account = h.apiHandler.Config.QRAccount
}
if amount == "" {
amount = "0"
}
payload := BuildSPD(account, amount, message, h.apiHandler.Config.QRAccount)
png, err := RenderQRCode(payload)
if err != nil {
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(png)
}

View File

@@ -1,12 +1,15 @@
package web_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/domain/reconcile"
"fuj-management/go/internal/web"
"fuj-management/go/internal/web/api"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -41,11 +44,13 @@ func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.Exception
return nil, nil
}
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
func fixtureHandler(t *testing.T) *api.Handler {
t.Helper()
return &api.Handler{
Sources: fixtureSources{},
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
Config: config.Config{QRAccount: "CZ0000000000000000000000"},
}
}
@@ -55,7 +60,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
cases := []struct {
path string
@@ -65,7 +70,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
{"/juniors", h.ServeJuniors},
{"/payments", h.ServePayments},
{"/sync-bank", h.ServeSync},
{"/flush-cache", h.ServeFlushCache},
{"/flush-cache", h.ServeFlushCacheGET},
}
for _, tc := range cases {
@@ -99,7 +104,7 @@ func TestAdultsPage(t *testing.T) {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
w := httptest.NewRecorder()
@@ -133,7 +138,7 @@ func TestModalMarkup(t *testing.T) {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
cases := []struct {
path string
@@ -174,7 +179,7 @@ func TestPaymentsPage(t *testing.T) {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
w := httptest.NewRecorder()
@@ -198,3 +203,181 @@ func TestPaymentsPage(t *testing.T) {
}
}
}
func TestServeQR(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
w := httptest.NewRecorder()
h.ServeQR(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png", ct)
}
// PNG magic bytes: \x89PNG\r\n\x1a\n
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
body := w.Body.Bytes()
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
t.Error("response body does not start with PNG magic bytes")
}
}
func TestServeFlushCacheGET(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
w := httptest.NewRecorder()
h.ServeFlushCacheGET(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Flush Cache") {
t.Error("body missing Flush Cache heading")
}
if strings.Contains(body, "file(s) deleted") {
t.Error("GET should not show deleted count")
}
}
func TestServeFlushCachePOST(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
w := httptest.NewRecorder()
h.ServeFlushCachePOST(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// fixtureSources.FlushCache returns 0
if !strings.Contains(body, "file(s) deleted") {
t.Error("POST should show deleted count")
}
}
func TestServeSync(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
actions := web.ActionHandlers{
BankSync: func(_ context.Context, out io.Writer) error {
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
fmt.Fprintln(out, "Synced 3 new transaction(s).")
fmt.Fprintln(out, "=== Infer Payments ===")
fmt.Fprintln(out, "Inferred 2 row(s).")
return nil
},
}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
w := httptest.NewRecorder()
h.ServeSync(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Sync Bank Data",
"Synced 3 new transaction(s).",
"Inferred 2 row(s).",
"Flush Cache",
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestPaymentQRMarkup(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
cases := []struct {
path string
handler http.HandlerFunc
}{
{"/adults", h.ServeAdults},
{"/juniors", h.ServeJuniors},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
tc.handler(w, req)
body := w.Body.String()
for _, want := range []string{
`id="qrModal"`,
`id="qrImg"`,
`id="qrTitle"`,
`id="qrAccount"`,
`id="qrAmount"`,
`id="qrMessage"`,
`/static/js/payment-qr.js`,
`data-bank-account="CZ0000000000000000000000"`,
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
// Pay buttons must use <button>, never a bare href to /qr.
if strings.Contains(body, `href="/qr`) {
t.Error("body must not contain href=/qr (Pay buttons should be <button>, not <a>)")
}
})
}
}
func TestServeVersion(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()
fixtureHandler(t).ServeVersion(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
t.Fatalf("decode JSON: %v", err)
}
for _, key := range []string{"tag", "commit", "build_date"} {
if _, ok := raw[key]; !ok {
t.Errorf("JSON response missing key %q", key)
}
}
}

50
go/internal/web/qr.go Normal file
View File

@@ -0,0 +1,50 @@
package web
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
qrcode "github.com/skip2/go-qrcode"
)
var validAccount = regexp.MustCompile(`^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`)
// BuildSPD builds a Czech QR Platba SPD string, matching Python's qr_code handler.
// Invalid account falls back to defaultAccount.
// Amount is clamped to [0, 10_000_000]; non-numeric input becomes "0.00".
// Message is truncated to 60 runes and stripped of '*' characters.
func BuildSPD(account, amount, message, defaultAccount string) string {
if !validAccount.MatchString(account) {
account = defaultAccount
}
var amtStr string
var f float64
if _, err := fmt.Sscanf(amount, "%f", &f); err != nil || f < 0 || f > 10_000_000 {
amtStr = "0.00"
} else {
amtStr = fmt.Sprintf("%.2f", f)
}
if utf8.RuneCountInString(message) > 60 {
runes := []rune(message)
message = string(runes[:60])
}
message = strings.ReplaceAll(message, "*", "")
var accStr string
if parts := strings.SplitN(account, "/", 2); len(parts) == 2 {
accStr = parts[0] + "*BC:" + parts[1]
} else {
accStr = account
}
return fmt.Sprintf("SPD*1.0*ACC:%s*AM:%s*CC:CZK*MSG:%s", accStr, amtStr, message)
}
// RenderQRCode encodes payload as a PNG QR code (256×256, error correction Medium).
func RenderQRCode(payload string) ([]byte, error) {
return qrcode.Encode(payload, qrcode.Medium, 256)
}

View File

@@ -0,0 +1,91 @@
package web
import (
"strings"
"testing"
)
func TestQRBuildSPD(t *testing.T) {
const def = "2702008874/2010"
cases := []struct {
name string
account string
amount string
message string
want string
}{
{
name: "czech account",
account: "2702008874/2010",
amount: "700",
message: "Test Member: 01/2026",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:700.00*CC:CZK*MSG:Test Member: 01/2026",
},
{
name: "IBAN account",
account: "CZ6508000000192000145399",
amount: "500",
message: "hi",
want: "SPD*1.0*ACC:CZ6508000000192000145399*AM:500.00*CC:CZK*MSG:hi",
},
{
name: "invalid account falls back to default",
account: "NOTANACCOUNT",
amount: "100",
message: "x",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:x",
},
{
name: "empty account falls back to default",
account: "",
amount: "0",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "negative amount clamped to 0.00",
account: def,
amount: "-1",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "amount over 10M clamped to 0.00",
account: def,
amount: "99999999",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "non-numeric amount becomes 0.00",
account: def,
amount: "abc",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "asterisks stripped from message",
account: def,
amount: "100",
message: "pay*now",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:paynow",
},
{
name: "message truncated to 60 runes",
account: def,
amount: "0",
message: strings.Repeat("á", 65),
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:" + strings.Repeat("á", 60),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := BuildSPD(tc.account, tc.amount, tc.message, def)
if got != tc.want {
t.Errorf("\ngot: %s\nwant: %s", got, tc.want)
}
})
}
}

View File

@@ -6,8 +6,6 @@ import (
"html/template"
"log/slog"
"net/http"
"net/url"
"strconv"
)
// PageData is the view model passed to every HTML template.
@@ -37,6 +35,20 @@ type PaymentsPageData struct {
Error string
}
// SyncPageData is the view model for the /sync-bank HTML page.
type SyncPageData struct {
PageData
Output string
Success bool
}
// FlushPageData is the view model for the /flush-cache HTML page.
type FlushPageData struct {
PageData
Flushed bool
Deleted int
}
// Renderer parses and executes HTML templates from the embedded FS.
type Renderer struct {
tmpls map[string]*template.Template
@@ -44,36 +56,7 @@ type Renderer struct {
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
// qrHref builds the /qr query URL for a single-month Pay button.
// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message.
func qrHref(account string, amount int, name, rawMonth string) string {
// Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS.
if len(rawMonth) == 7 && rawMonth[4] == '-' {
rawMonth = rawMonth[5:] + "/" + rawMonth[:4]
}
msg := name + ": " + rawMonth
return "/qr?" + url.Values{
"account": {account},
"amount": {strconv.Itoa(amount)},
"message": {msg},
}.Encode()
}
// qrHrefAll builds the /qr query URL for a Pay-All button.
// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods.
func qrHrefAll(account string, amount int, name, rawPeriods string) string {
msg := name + ": " + rawPeriods
return "/qr?" + url.Values{
"account": {account},
"amount": {strconv.Itoa(amount)},
"message": {msg},
}.Encode()
}
var tmplFuncs = template.FuncMap{
"qrHref": qrHref,
"qrHrefAll": qrHrefAll,
}
var tmplFuncs = template.FuncMap{}
// NewRenderer parses all templates from the embedded FS.
// A parse failure should be treated as a startup-time fatal error.

View File

@@ -1,11 +1,13 @@
package web
import (
"context"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web/api"
"fuj-management/go/internal/web/middleware"
"io"
"io/fs"
"log/slog"
"net/http"
@@ -18,8 +20,16 @@ type BuildInfo struct {
BuildDate string
}
// ActionHandlers holds function closures for side-effectful operations that
// require dependencies (fio, sheets) not present on the core API handler.
type ActionHandlers struct {
// BankSync runs sync+infer and writes a human-readable log to out.
// nil disables the /sync-bank action (renders an error instead).
BankSync func(ctx context.Context, out io.Writer) error
}
// Run registers routes and starts the HTTP server on addr.
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config, actions ActionHandlers) error {
renderer, err := NewRenderer()
if err != nil {
return fmt.Errorf("init templates: %w", err)
@@ -33,7 +43,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
Config: cfg,
Logger: logger,
}
hh := NewHTMLHandler(renderer, build, ah)
hh := NewHTMLHandler(renderer, build, ah, actions)
staticSubFS, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -50,13 +60,16 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
mux.HandleFunc("GET /payments", hh.ServePayments)
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCacheGET)
mux.HandleFunc("POST /flush-cache", hh.ServeFlushCachePOST)
mux.HandleFunc("GET /qr", hh.ServeQR)
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// JSON API routes
mux.HandleFunc("GET /api/version", ah.ServeVersion)
mux.HandleFunc("GET /version", ah.ServeVersion)
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
mux.HandleFunc("GET /api/payments", ah.ServePayments)

View File

@@ -442,6 +442,23 @@ tr:hover {
}
/* QR Modal styles */
#qrModal {
display: none !important;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
}
#qrModal.active {
display: flex !important;
}
#qrModal .modal-content {
max-width: 400px;
text-align: center;

View File

@@ -12,7 +12,8 @@
const container = document.getElementById('filterContainer');
if (!container) return;
const currentMonth = container.dataset.currentMonth || '';
const currentMonth = container.dataset.currentMonth || '';
const monthsToShow = parseInt(container.dataset.monthsToShow || '0', 10);
const nameInput = document.getElementById('nameFilter');
const fromSelect = document.getElementById('fromMonth');
@@ -88,4 +89,10 @@
// ── Initialise ────────────────────────────────────────────────────────────
hideFutureMonths();
// Default the from-select to show only the last N months.
if (monthsToShow > 0 && toSelect.value !== '') {
const defaultFrom = Math.max(0, parseInt(toSelect.value, 10) - monthsToShow + 1);
fromSelect.value = String(defaultFrom);
applyMonthFilter();
}
}());

View File

@@ -0,0 +1,60 @@
(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 converted piece-wise.
// Mirrors templates/adults.html showPayQR logic.
function toCzechMonth(rawMonth) {
return rawMonth.split('+')
.map(function (p) { return p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'); })
.join('+');
}
function showQR(name, amount, month, rawMonth) {
var numericMonth = toCzechMonth(rawMonth);
var message = name + ': ' + numericMonth;
titleEl.innerText = 'Payment for ' + month;
accountEl.innerText = bankAccount;
amountEl.innerText = amount;
messageEl.innerText = message;
imgEl.src = '/qr?'
+ 'account=' + encodeURIComponent(bankAccount)
+ '&amount=' + encodeURIComponent(amount)
+ '&message=' + encodeURIComponent(message);
modal.classList.add('active');
}
function closeQR() {
modal.classList.remove('active');
}
document.addEventListener('click', function (ev) {
var 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', function (e) {
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
});
}());

View File

@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults">
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
@@ -62,14 +62,14 @@
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
{{$cell.Text}}
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
<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>
{{end}}
</td>
{{end}}
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
{{$row.Balance}}
{{if gt $row.PayableAmount 0}}
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
<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>
{{end}}
</td>
</tr>
@@ -184,6 +184,24 @@
</div>
</div>
<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>
<script src="/static/js/filters.js" defer></script>
<script src="/static/js/member-detail.js" defer></script>
<script src="/static/js/payment-qr.js" defer></script>
{{end}}

View File

@@ -1,5 +1,11 @@
{{define "title"}}Flush Cache{{end}}
{{define "content"}}
<h1>Flush Cache</h1>
<p class="description">Coming in M6.6</p>
{{if .Flushed}}
<p class="status-ok">Cache flushed: {{.Deleted}} file(s) deleted.</p>
{{end}}
<p class="description">Deletes all cached data files so the next request fetches fresh data from Google Sheets.</p>
<form method="POST" action="/flush-cache">
<button type="submit" class="btn">Flush Cache</button>
</form>
{{end}}

View File

@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors">
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
@@ -62,14 +62,14 @@
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
{{$cell.Text}}
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
<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>
{{end}}
</td>
{{end}}
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
{{$row.Balance}}
{{if gt $row.PayableAmount 0}}
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
<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>
{{end}}
</td>
</tr>
@@ -164,6 +164,24 @@
</div>
</div>
<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>
<script src="/static/js/filters.js" defer></script>
<script src="/static/js/member-detail.js" defer></script>
<script src="/static/js/payment-qr.js" defer></script>
{{end}}

View File

@@ -1,5 +1,15 @@
{{define "title"}}Sync Bank Data{{end}}
{{define "content"}}
<h1>Sync Bank Data</h1>
<p class="description">Coming in M6.6</p>
{{if .Output}}
{{if .Success}}
<p class="status-ok">Sync completed successfully.</p>
{{else}}
<p class="status-error">Sync failed — see log below.</p>
{{end}}
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
{{else}}
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
{{end}}
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
{{end}}

View File

@@ -143,6 +143,9 @@
},
"current_month": {
"type": "string"
},
"months_to_show": {
"type": "integer"
}
},
"additionalProperties": false,
@@ -161,7 +164,8 @@
"attendance_url",
"payments_url",
"bank_account",
"current_month"
"current_month",
"months_to_show"
]
},
"Credit": {

View File

@@ -187,6 +187,9 @@
},
"current_month": {
"type": "string"
},
"months_to_show": {
"type": "integer"
}
},
"additionalProperties": false,
@@ -205,7 +208,8 @@
"attendance_url",
"payments_url",
"bank_account",
"current_month"
"current_month",
"months_to_show"
]
},
"MemberOtherEntry": {

View File

@@ -1,7 +1,7 @@
{
"case": "03_proportional_remainder",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"captured_at": "2026-05-11",
"input": {
"members": [
{
@@ -54,10 +54,10 @@
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 324.3243243243243,
"paid": 750.0,
"transactions": [
{
"amount": 324.3243243243243,
"amount": 750.0,
"date": "2026-03-10",
"sender": "Member_d035d9f9",
"message": "",
@@ -70,10 +70,10 @@
"original_expected": 750,
"attendance_count": 2,
"exception": null,
"paid": 324.3243243243243,
"paid": 50.0,
"transactions": [
{
"amount": 324.3243243243243,
"amount": 50.0,
"date": "2026-03-10",
"sender": "Member_d035d9f9",
"message": "",
@@ -86,25 +86,17 @@
"original_expected": 350,
"attendance_count": 2,
"exception": null,
"paid": 151.35135135135135,
"transactions": [
{
"amount": 151.35135135135135,
"date": "2026-03-10",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
"paid": 0,
"transactions": []
}
},
"other_transactions": [],
"total_balance": -1051
"total_balance": -1050
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": -1051
"Member_d035d9f9": -1050
}
}
}

View File

@@ -1,7 +1,7 @@
{
"case": "09_multiperson_multimonth",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"captured_at": "2026-05-11",
"input": {
"members": [
{
@@ -63,10 +63,10 @@
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 500.0,
"paid": 750.0,
"transactions": [
{
"amount": 500.0,
"amount": 750.0,
"date": "2026-02-15",
"sender": "Member_d035d9f9",
"message": "",
@@ -79,10 +79,10 @@
"original_expected": 750,
"attendance_count": 2,
"exception": null,
"paid": 500.0,
"paid": 250.0,
"transactions": [
{
"amount": 500.0,
"amount": 250.0,
"date": "2026-02-15",
"sender": "Member_d035d9f9",
"message": "",
@@ -102,10 +102,10 @@
"original_expected": 750,
"attendance_count": 2,
"exception": null,
"paid": 681.8181818181819,
"paid": 750.0,
"transactions": [
{
"amount": 681.8181818181819,
"amount": 750.0,
"date": "2026-02-15",
"sender": "Member_d035d9f9",
"message": "",
@@ -118,10 +118,10 @@
"original_expected": 350,
"attendance_count": 2,
"exception": null,
"paid": 318.18181818181813,
"paid": 250.0,
"transactions": [
{
"amount": 318.18181818181813,
"amount": 250.0,
"date": "2026-02-15",
"sender": "Member_d035d9f9",
"message": "",
@@ -131,13 +131,13 @@
}
},
"other_transactions": [],
"total_balance": -101
"total_balance": -100
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": -500,
"Member_f4a93e46": -101
"Member_f4a93e46": -100
}
}
}

View File

@@ -21,7 +21,10 @@ ADULT_FEE_MONTHLY_RATE = {
"2026-02": 750,
"2026-03": 350,
"2026-04": 700,
"2026-05": 700,
"2026-05": 450,
"2026-06": 600,
"2026-07": 600,
"2026-08": 600,
}
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices

View File

@@ -21,8 +21,18 @@ PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
# Attendance sheet tab GIDs
JUNIOR_SHEET_GID = "1213318614"
# Bank
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
# Bank accounts — hardcoded list mirroring go/internal/config/config.go.
# The entry with primary=True is used for QR codes and as the BANK_ACCOUNT default.
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},
]
# Resolve API tokens from the environment once at import time.
LOADED_ACCOUNTS = [
{**a, "token": os.environ.get(a["token_env"], "")}
for a in ACCOUNTS
]
BANK_ACCOUNT = next(a["iban"] for a in ACCOUNTS if a["primary"])
# Cache settings
CACHE_DIR = PROJECT_ROOT / "tmp"
@@ -30,6 +40,9 @@ DRIVE_TIMEOUT = 10 # seconds
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
# Display settings
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5)) # show last N months; 0 = show all
# Maps cache keys to their source sheet IDs (used by cache_utils)
CACHE_SHEET_MAP = {
"attendance_regular": ATTENDANCE_SHEET_ID,

View File

@@ -4,6 +4,7 @@
import json
import os
import re
import sys
import urllib.request
from datetime import datetime
from html.parser import HTMLParser
@@ -89,9 +90,11 @@ def parse_czech_amount(s: str) -> float | None:
def parse_czech_date(s: str) -> str | None:
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
"""Parse a Czech date to 'YYYY-MM-DD'. Accepts 4-digit and 2-digit years
with dot or slash separators; Fio's transparent page mixes 'DD.MM.YYYY'
and 'DD.MM.YY' in the same response."""
s = s.strip()
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d.%m.%y", "%d/%m/%y"):
try:
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError:
@@ -100,7 +103,7 @@ def parse_czech_date(s: str) -> str | None:
def fetch_transactions_transparent(
date_from: str, date_to: str, account_id: str = "2800359168"
date_from: str, date_to: str, account_id: str
) -> list[dict]:
"""Fetch transactions from Fio transparent account HTML page.
@@ -146,6 +149,7 @@ def fetch_transactions_transparent(
"bank_id": "", # HTML scraping doesn't give stable ID
})
print(f"fio: transparent fetched {len(rows)} raw rows, {len(transactions)} transaction(s) after filtering", file=sys.stderr)
return transactions
@@ -169,7 +173,8 @@ def fetch_transactions_api(
transactions = []
tx_list = data.get("accountStatement", {}).get("transactionList", {})
for tx in (tx_list.get("transaction") or []):
raw_list = tx_list.get("transaction") or []
for tx in raw_list:
# Each field is {"value": ..., "name": ..., "id": ...} or null
def val(col_id):
col = tx.get(f"column{col_id}")
@@ -197,19 +202,51 @@ def fetch_transactions_api(
"currency": str(val(14) or "CZK"), # column14 = Currency
})
print(f"fio: api fetched {len(raw_list)} raw transaction(s), {len(transactions)} after filtering", file=sys.stderr)
return transactions
def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
"""Fetch transactions, using API if token available, else transparent page."""
token = os.environ.get("FIO_API_TOKEN", "").strip()
def fetch_transactions_for(account: dict, date_from: str, date_to: str) -> list[dict]:
"""Fetch transactions for a single loaded account dict (from config.LOADED_ACCOUNTS).
Uses the API path if the account has a token, otherwise the transparent scraper.
date_from/date_to: YYYY-MM-DD.
"""
token = (account.get("token") or "").strip()
acct_num = account["acct_num"]
if token:
print(f"fio: account {acct_num}: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
return fetch_transactions_api(token, date_from, date_to)
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
print(
f"fio: account {acct_num}: using transparent page (no token — expect publishing lag), "
f"window {date_from}..{date_to}",
file=sys.stderr,
)
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
return fetch_transactions_transparent(
from_dt.strftime("%d.%m.%Y"),
to_dt.strftime("%d.%m.%Y"),
account_id=acct_num,
)
def fetch_transactions_all(
date_from: str, date_to: str, accounts: list[dict] | None = None
) -> list[dict]:
"""Fetch and combine transactions from all configured accounts.
accounts: list of loaded account dicts (defaults to config.LOADED_ACCOUNTS).
Returns a flat list of all transactions across all accounts.
"""
if accounts is None:
from config import LOADED_ACCOUNTS
accounts = LOADED_ACCOUNTS
all_txns: list[dict] = []
for account in accounts:
txns = fetch_transactions_for(account, date_from, date_to)
print(f"fio: account {account['acct_num']}: {len(txns)} transaction(s)", file=sys.stderr)
all_txns.extend(txns)
print(f"fio: total {len(all_txns)} transaction(s) across {len(accounts)} account(s)", file=sys.stderr)
return all_txns

View File

@@ -26,6 +26,15 @@ def canonical_member_key(name: str) -> str:
return re.sub(r"\s+", " ", normalize(name)).strip()
def _expected_amount(value) -> float:
"""Numeric value of an expected fee; non-numeric markers like '?' → 0.
Juniors with exactly 1 session get expected='?' (manual-review marker).
Treat those as 0 for arithmetic so payments become surplus/credit.
"""
return value if isinstance(value, (int, float)) else 0
# ---------------------------------------------------------------------------
# Name matching
# ---------------------------------------------------------------------------
@@ -466,28 +475,21 @@ def reconcile(
if not in_window:
continue
total_expected = sum(e for _, e in in_window)
total_expected = sum(_expected_amount(e) for _, e in in_window)
if total_expected > 0 and in_window_share >= total_expected:
# Greedy phase: payment covers all in-window fees; overflow → credit.
credits[member_name] = credits.get(member_name, 0) + int(in_window_share - total_expected)
for m, exp in in_window:
alloc = float(exp)
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,
})
elif total_expected > 0:
# Proportional phase: distribute in_window_share by each month's expected fee.
# Last month absorbs any float remainder so the sum equals in_window_share exactly.
if total_expected > 0:
# Fill-first: iterate in_window in matched_months order (chronological by
# convention from infer_payments.py), 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 = in_window_share
for i, (m, exp) in enumerate(in_window):
alloc = remaining if i == len(in_window) - 1 else in_window_share * exp / total_expected
remaining -= alloc
for m, exp in in_window:
paid_so_far = ledger[member_name][m]["paid"]
deficit = max(0.0, float(_expected_amount(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,
@@ -496,6 +498,9 @@ def reconcile(
"message": tx["message"],
"confidence": confidence,
})
remaining -= alloc
if remaining > 0:
credits[member_name] = credits.get(member_name, 0) + int(remaining)
else:
# Fallback: no expected fees (prepayment before attendance recorded); even split.
per_month = in_window_share / len(in_window)
@@ -513,7 +518,7 @@ def reconcile(
final_balances: dict[str, int] = {}
for name in member_names:
window_balance = sum(
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
int(mdata["paid"]) - _expected_amount(mdata["expected"])
for mdata in ledger[name].values()
)
final_balances[name] = window_balance + credits.get(name, 0)

View File

@@ -12,7 +12,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2 import service_account
from googleapiclient.discovery import build
from fio_utils import fetch_transactions
from fio_utils import fetch_transactions_all
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
@@ -77,6 +77,35 @@ def generate_sync_id(tx: dict) -> str:
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
def _trunc(s: str, n: int = 40) -> str:
s = str(s)
return s if len(s) <= n else s[: n - 1] + ""
def _print_fio_table(transactions: list[dict], statuses: list[str]) -> None:
headers = ["DATE", "AMOUNT", "SENDER", "VS", "MESSAGE", "BANKID", "STATUS"]
rows = [
[
str(tx.get("date", "")),
f"{float(tx.get('amount', 0)):.2f}",
str(tx.get("sender", "")),
str(tx.get("vs", "")),
_trunc(str(tx.get("message", ""))),
str(tx.get("bank_id", "")),
status,
]
for tx, status in zip(transactions, statuses)
]
widths = [
max(len(headers[i]), max((len(r[i]) for r in rows), default=0))
for i in range(len(headers))
]
sep = " "
print(sep.join(h.ljust(w) for h, w in zip(headers, widths)))
for row in rows:
print(sep.join(cell.ljust(w) for cell, w in zip(row, widths)))
def sort_sheet_by_date(service, spreadsheet_id):
"""Sort the sheet by the Date column (Column B)."""
# Get the sheet ID (gid) of the first sheet
@@ -104,12 +133,21 @@ def sort_sheet_by_date(service, spreadsheet_id):
print("Sheet sorted by date.")
def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None, date_from_str: str = None, date_to_str: str = None, sort_by_date: bool = False):
def sync_to_sheets(
spreadsheet_id: str,
credentials_path: str,
days: int = None,
date_from_str: str = None,
date_to_str: str = None,
sort_by_date: bool = False,
dry_run: bool = False,
print_fio_table: bool = False,
):
print(f"Connecting to Google Sheets using {credentials_path}...")
service = get_sheets_service(credentials_path)
sheet = service.spreadsheets()
# 1. Fetch existing IDs from Column G (last column in A-G range)
# 1. Read existing sync IDs from Column K
print(f"Reading existing sync IDs from sheet...")
try:
result = sheet.values().get(
@@ -120,16 +158,19 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
# Check and insert labels if missing
if not values or values[0] != COLUMN_LABELS:
print("Inserting column labels...")
sheet.values().update(
spreadsheetId=spreadsheet_id,
range="A1",
valueInputOption="USER_ENTERED",
body={"values": [COLUMN_LABELS]}
).execute()
if dry_run:
print("Dry run: would write header row")
else:
print("Inserting column labels...")
sheet.values().update(
spreadsheetId=spreadsheet_id,
range="A1",
valueInputOption="USER_ENTERED",
body={"values": [COLUMN_LABELS]}
).execute()
existing_ids = set()
else:
# Sync ID is now the last column (index 10)
# Sync ID is the last column (index 10)
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
except Exception as e:
print(f"Error reading sheet (maybe empty?): {e}")
@@ -147,11 +188,15 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
dt_str = date_to.strftime("%Y-%m-%d")
print(f"Fetching Fio transactions from {df_str} to {dt_str}...")
transactions = fetch_transactions(df_str, dt_str)
transactions = fetch_transactions_all(df_str, dt_str)
print(f"Found {len(transactions)} transactions.")
# 3. Filter for new transactions
if dry_run:
print(f"Dry run: window {df_str} to {dt_str}, fetched {len(transactions)} transaction(s) from Fio")
# 3. Determine NEW/DUP for each transaction
new_rows = []
tx_statuses = []
for tx in transactions:
sync_id = generate_sync_id(tx)
if sync_id not in existing_ids:
@@ -169,24 +214,48 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
tx.get("bank_id", ""),
sync_id,
])
tx_statuses.append("NEW")
else:
tx_statuses.append("DUP")
# 4. Print table (before early-return so all transactions are shown including DUPs)
if print_fio_table and transactions:
_print_fio_table(transactions, tx_statuses)
if not new_rows:
print("No new transactions to sync.")
if dry_run:
print("Dry run: would sync 0 new transaction(s).")
else:
print("No new transactions to sync.")
return
# 4. Append to sheet
print(f"Appending {len(new_rows)} new transactions to the sheet...")
body = {"values": new_rows}
sheet.values().append(
spreadsheetId=spreadsheet_id,
range="A2", # Appends to the end of the sheet
valueInputOption="USER_ENTERED",
body=body
).execute()
print("Sync completed successfully.")
if sort_by_date:
sort_sheet_by_date(service, spreadsheet_id)
# 5. Append to sheet or print dry-run would-write lines
if dry_run:
for tx, status in zip(transactions, tx_statuses):
if status == "NEW":
print(
f"Dry run: would append"
f" date={tx.get('date', '')}"
f" amount={tx.get('amount', '')}"
f" sender={tx.get('sender', '')}"
f" vs={tx.get('vs', '')}"
f" message={tx.get('message', '')}"
)
if sort_by_date:
print("Dry run: would sort by date")
print(f"Dry run: would sync {len(new_rows)} new transaction(s).")
else:
print(f"Appending {len(new_rows)} new transactions to the sheet...")
body = {"values": new_rows}
sheet.values().append(
spreadsheetId=spreadsheet_id,
range="A2", # Appends to the end of the sheet
valueInputOption="USER_ENTERED",
body=body
).execute()
print("Sync completed successfully.")
if sort_by_date:
sort_sheet_by_date(service, spreadsheet_id)
def main():
@@ -197,6 +266,8 @@ def main():
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
parser.add_argument("--dry-run", action="store_true", help="Fetch and dedup without writing to the sheet")
parser.add_argument("--print-fio-table", action="store_true", help="Print aligned table of all fetched transactions with NEW/DUP status (use with --dry-run)")
args = parser.parse_args()
try:
@@ -206,7 +277,9 @@ def main():
days=args.days,
date_from_str=args.date_from,
date_to_str=args.date_to,
sort_by_date=args.sort_by_date
sort_by_date=args.sort_by_date,
dry_run=args.dry_run,
print_fio_table=args.print_fio_table,
)
except Exception as e:
print(f"Sync failed: {e}")

View File

@@ -1045,6 +1045,7 @@
});
toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter();
})();
</script>

View File

@@ -1026,6 +1026,7 @@
});
toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter();
})();
</script>

View File

@@ -93,8 +93,8 @@ class TestMultiMonthAllocation(unittest.TestCase):
self.assertEqual(int(months['2026-02']['paid']), 750)
self.assertEqual(result['credits'].get('Alice', 0), 500)
def test_proportional_underpayment(self):
"""Payment < total expected → proportional split; sum of paid == payment amount."""
def test_underpayment_fills_earliest_first(self):
"""Payment < total expected → fill earliest months first, spill remainder to later."""
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
sorted_months = ['2026-02', '2026-03', '2026-04']
amount = 1250
@@ -103,18 +103,28 @@ class TestMultiMonthAllocation(unittest.TestCase):
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
paid_02 = months['2026-02']['paid']
paid_03 = months['2026-03']['paid']
paid_04 = months['2026-04']['paid']
# 02 filled first (750), then 03 (350), then remainder 150 to 04
self.assertAlmostEqual(months['2026-02']['paid'], 750, places=2)
self.assertAlmostEqual(months['2026-03']['paid'], 350, places=2)
self.assertAlmostEqual(months['2026-04']['paid'], 150, places=2)
# No CZK lost
self.assertAlmostEqual(
months['2026-02']['paid'] + months['2026-03']['paid'] + months['2026-04']['paid'],
amount, places=2,
)
# All months should be partial (underpaid)
self.assertLess(paid_02, 750)
self.assertLess(paid_03, 350)
self.assertLess(paid_04, 750)
# Sum must equal the original payment (no CZK lost)
self.assertAlmostEqual(paid_02 + paid_03 + paid_04, amount, places=2)
# 02 and 04 have equal expected → equal allocation
self.assertAlmostEqual(paid_02, paid_04, places=2)
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)
def test_single_month_unchanged(self):
"""Single-month payment: full amount goes to that month (regression guard)."""