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>
125 lines
11 KiB
Markdown
125 lines
11 KiB
Markdown
# 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.
|