feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
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>
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
|||||||
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||||
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
||||||
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
|
- [x] **M6.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`
|
||||||
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
||||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|||||||
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal file
124
docs/plans/2026-05-08-1334-go-m6-6-action-pages.md
Normal 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.
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fuj-management/go/internal/services/banksync"
|
"fuj-management/go/internal/services/banksync"
|
||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -82,9 +83,40 @@ func serverCmd(args []string) {
|
|||||||
os.Exit(1)
|
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)
|
||||||
|
}
|
||||||
|
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
|
||||||
|
|
||||||
|
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, fioCli, 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}
|
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)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.26.1
|
|||||||
require (
|
require (
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/invopop/jsonschema v0.14.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/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
google.golang.org/api v0.278.0
|
google.golang.org/api v0.278.0
|
||||||
|
|||||||
@@ -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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ type ExceptionLoader interface {
|
|||||||
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
|
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.
|
// Sources is the aggregate interface required by ReconcileReport.
|
||||||
type Sources interface {
|
type Sources interface {
|
||||||
AttendanceLoader
|
AttendanceLoader
|
||||||
TransactionLoader
|
TransactionLoader
|
||||||
ExceptionLoader
|
ExceptionLoader
|
||||||
|
CacheFlusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStubSources returns a Sources whose every method returns ErrIOPending.
|
// 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) {
|
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
return nil, ErrIOPending
|
return nil, ErrIOPending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (stubSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionK
|
|||||||
return f.exceptions, nil
|
return f.exceptions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|
||||||
func TestReconcileReport(t *testing.T) {
|
func TestReconcileReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := fakeSources{
|
s := fakeSources{
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transac
|
|||||||
return parseTransactionRows(rows)
|
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).
|
// LoadExceptions fetches the exceptions tab (cached).
|
||||||
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type Handler struct {
|
|||||||
Logger *slog.Logger
|
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.
|
// ServeVersion handles GET /api/version.
|
||||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, VersionResponse{
|
writeJSON(w, VersionResponse{
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTMLHandler serves the Go-native HTML frontend.
|
// HTMLHandler serves the Go-native HTML frontend.
|
||||||
@@ -10,11 +13,12 @@ type HTMLHandler struct {
|
|||||||
renderer *Renderer
|
renderer *Renderer
|
||||||
build BuildInfo
|
build BuildInfo
|
||||||
apiHandler *api.Handler
|
apiHandler *api.Handler
|
||||||
|
actions ActionHandlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLHandler constructs an HTMLHandler.
|
// NewHTMLHandler constructs an HTMLHandler.
|
||||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
|
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
|
||||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
|
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
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) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
|
var buf bytes.Buffer
|
||||||
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.BankAccount
|
||||||
|
}
|
||||||
|
if amount == "" {
|
||||||
|
amount = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package web_test
|
package web_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/config"
|
"fuj-management/go/internal/config"
|
||||||
"fuj-management/go/internal/domain/reconcile"
|
"fuj-management/go/internal/domain/reconcile"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -41,6 +44,8 @@ func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.Exception
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
|
||||||
|
|
||||||
func fixtureHandler(t *testing.T) *api.Handler {
|
func fixtureHandler(t *testing.T) *api.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return &api.Handler{
|
return &api.Handler{
|
||||||
@@ -55,7 +60,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
|||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
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 {
|
cases := []struct {
|
||||||
path string
|
path string
|
||||||
@@ -65,7 +70,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
|||||||
{"/juniors", h.ServeJuniors},
|
{"/juniors", h.ServeJuniors},
|
||||||
{"/payments", h.ServePayments},
|
{"/payments", h.ServePayments},
|
||||||
{"/sync-bank", h.ServeSync},
|
{"/sync-bank", h.ServeSync},
|
||||||
{"/flush-cache", h.ServeFlushCache},
|
{"/flush-cache", h.ServeFlushCacheGET},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -99,7 +104,7 @@ func TestAdultsPage(t *testing.T) {
|
|||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -133,7 +138,7 @@ func TestModalMarkup(t *testing.T) {
|
|||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
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 {
|
cases := []struct {
|
||||||
path string
|
path string
|
||||||
@@ -174,7 +179,7 @@ func TestPaymentsPage(t *testing.T) {
|
|||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -198,3 +203,135 @@ 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 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
50
go/internal/web/qr.go
Normal 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)
|
||||||
|
}
|
||||||
91
go/internal/web/qr_test.go
Normal file
91
go/internal/web/qr_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,20 @@ type PaymentsPageData struct {
|
|||||||
Error string
|
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.
|
// Renderer parses and executes HTML templates from the embedded FS.
|
||||||
type Renderer struct {
|
type Renderer struct {
|
||||||
tmpls map[string]*template.Template
|
tmpls map[string]*template.Template
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/config"
|
"fuj-management/go/internal/config"
|
||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web/api"
|
"fuj-management/go/internal/web/api"
|
||||||
"fuj-management/go/internal/web/middleware"
|
"fuj-management/go/internal/web/middleware"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,8 +20,16 @@ type BuildInfo struct {
|
|||||||
BuildDate string
|
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.
|
// 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()
|
renderer, err := NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init templates: %w", err)
|
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,
|
Config: cfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
hh := NewHTMLHandler(renderer, build, ah)
|
hh := NewHTMLHandler(renderer, build, ah, actions)
|
||||||
|
|
||||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
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 /juniors", hh.ServeJuniors)
|
||||||
mux.HandleFunc("GET /payments", hh.ServePayments)
|
mux.HandleFunc("GET /payments", hh.ServePayments)
|
||||||
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
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
|
// Static files
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||||
|
|
||||||
// JSON API routes
|
// JSON API routes
|
||||||
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
||||||
|
mux.HandleFunc("GET /version", ah.ServeVersion)
|
||||||
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
||||||
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
||||||
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{{define "title"}}Flush Cache{{end}}
|
{{define "title"}}Flush Cache{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Flush Cache</h1>
|
<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}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
{{define "title"}}Sync Bank Data{{end}}
|
{{define "title"}}Sync Bank Data{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Sync Bank Data</h1>
|
<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}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user