# M6.4 — Go-native `/payments` page (grouped-by-person ledger) > Plan-mode note: per project convention this plan should live at > `docs/plans/2026-05-08-1220-go-m6-4-payments-page.md`. The first execution > step after approval is to copy this file there. ## Context The Go port of the Flask app is at milestone **M6.4**: replace the placeholder `/payments` template ([go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl)) with a feature-equivalent grouped-by-person ledger view, matching the Python [templates/payments.html](templates/payments.html) page. Compared to M6.2 (`/adults`) and M6.3 (`/juniors`), this page is **structurally much simpler**: - No filters (no name input, no month-range selects). - No totals / credits / debts / unmatched-as-extra-section. - No JS (no `filters.js`, no modal — modal is M6.5 territory and Python's payments page itself has none). - No QR / Pay buttons. - Single grouping dimension: **by person**, sorted alphabetically, with a synthetic `"Unmatched / Unknown"` bucket appended. - Each person's transactions sorted **newest-first**. - Columns: Date, Amount (right-aligned, green), Purpose, Bank Message. The JSON API side is already done (`/api/payments` at [go/internal/web/api/handler.go:78-86](go/internal/web/api/handler.go#L78-L86)), returning a `PaymentsResponse{ GroupedPayments, SortedPeople, AttendanceURL, PaymentsURL }`. The HTML side just needs to consume it. ## Approach Mirror the M6.2/M6.3 pattern: extract an `AssembleX(ctx)` method, add a typed `PageData` wrapper, render a real template, lift the page-specific CSS into the shared stylesheet. ### 1. Extract `AssemblePayments(ctx) (PaymentsResponse, error)` In [go/internal/web/api/handler.go](go/internal/web/api/handler.go), refactor `ServePayments` (currently inlines the load+build at lines 78-86) to mirror `AssembleAdults` / `AssembleJuniors`: ```go func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) { resp, err := h.AssemblePayments(r.Context()) if err != nil { h.writeError(w, r, err); return } writeJSON(w, resp) } func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) { txns, err := h.Sources.LoadTransactions(ctx) if err != nil { return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) } return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil } ``` Pure refactor — `make parity` should report zero diffs on `/api/payments`. ### 2. Add `PaymentsPageData` wrapper In [go/internal/web/render.go](go/internal/web/render.go), alongside `AdultsPageData` / `JuniorsPageData`: ```go type PaymentsPageData struct { PageData Data api.PaymentsResponse Error string } ``` No changes needed to `pageNames` — `"payments"` is already registered. ### 3. Rewire HTML handler Replace [go/internal/web/html_handler.go:50-52](go/internal/web/html_handler.go#L50-L52): ```go func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) { data := PaymentsPageData{ PageData: PageData{Active: "payments", Build: h.build} } resp, err := h.apiHandler.AssemblePayments(r.Context()) if err != nil { data.Error = err.Error() } else { data.Data = resp } h.renderer.Render(w, "payments", data) } ``` ### 4. Replace template stub Rewrite [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) (currently a 5-line placeholder). Structure: ```gotmpl {{define "title"}}Payments Ledger{{end}} {{define "content"}}

Payments Ledger

All bank transactions from the payments sheet, grouped by member. Names are matched against the attendance sheet.

{{if .Error}}

{{.Error}}

{{end}}
{{range $person := .Data.SortedPeople}}

{{$person}}

{{range $tx := index $.Data.GroupedPayments $person}} {{end}}
Date Amount Purpose Bank Message
{{$tx.Date}} {{printf "%.0f" $tx.Amount}} CZK {{$tx.Purpose}} {{$tx.Message}}
{{end}}
{{end}} ``` Notes: - `printf "%.0f"` matches the visual style of integer CZK amounts (Python prints the float as-is, but bank-row amounts are always whole CZK). If the user prefers exact Python parity (`750.0 CZK`), drop the `printf` and use `{{$tx.Amount}}`. - Iterating a `map` requires using `.Data.SortedPeople` to drive order then `index $.Data.GroupedPayments $person` — Go templates don't preserve map insertion order. ### 5. Add CSS classes to `app.css` Append the page-specific rules from [templates/payments.html:111-164](templates/payments.html#L111-L164) to [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css): `.ledger-container`, `.member-block`, `.txn-table`, `.txn-table th/td`, `.txn-date`, `.txn-amount`, `.txn-purpose`, `.txn-message`, `tr:hover`. The terminal-green palette already matches. ### 6. Unit test Add a markup-level test to `go/internal/web/html_handler_test.go` (the file created in M6.2/M6.3) following the same pattern: stub `Sources` returning a fixed transaction list, assert response is HTML 200, assert key strings appear (person names as `

`, "Payments Ledger" `

`, expected amount, "Unmatched / Unknown" bucket when an unattributed tx exists). ## Critical files | Action | Path | |---------|------| | edit | [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — extract `AssemblePayments` | | edit | [go/internal/web/render.go](go/internal/web/render.go) — add `PaymentsPageData` | | edit | [go/internal/web/html_handler.go](go/internal/web/html_handler.go) — rewire `ServePayments` | | rewrite | [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) — real template | | edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — append ledger CSS | | edit | `go/internal/web/html_handler_test.go` — payments markup test | No changes to `server.go`, `assets.go`, `Sources` interface, `buildPaymentsResponse`, or any domain code — these were completed in M5 / M6.1. ## Reused existing helpers - [go/internal/web/api/build_payments.go](go/internal/web/api/build_payments.go) — `buildPaymentsResponse` (existing, unchanged) - [go/internal/web/api/build_common.go:84-117](go/internal/web/api/build_common.go#L84-L117) — `groupRawPaymentsByPerson` handles canonicalization, comma-splitting, `[?]` stripping, newest-first sort - [go/internal/web/api/handler.go:116-129](go/internal/web/api/handler.go#L116-L129) — `allMemberNames` - [go/internal/web/templates/partials/nav.tmpl](go/internal/web/templates/partials/nav.tmpl) and `footer.tmpl` — already include the `[Payments Ledger]` nav link ## Verification End-to-end: 1. `cd go && make go-build go-test go-lint` — all green. 2. `make parity` — confirm the `AssemblePayments` extraction is byte-equal on `/api/payments`. 3. `make web-go &` then: - `curl -i localhost:8080/payments | head -30` — HTTP 200, `text/html`. - Browser load `http://localhost:8080/payments`: - "Payments Ledger" heading + description with two clickable sheet links. - Alphabetical member blocks, each with a table. - Amounts right-aligned, green, "CZK" suffix. - Newest-first ordering inside each block. - "Unmatched / Unknown" bucket sorts in alphabetical position (capital U → near end). - Hover on a row darkens the background. - Nav highlights `[Payments Ledger]` as the active link. - Cross-check with Python: `make web-py &` → `http://localhost:5001/payments` should look visually identical except for nav styling. 4. `make web-py & make web-go &` then `tea pr create` once committed (per project branch-per-feature workflow). ## Branching Create `feat/go-m6-4-payments-page` off `main`, push, open MR via `tea`. Follow project conventions: Co-Authored-By trailer, CHANGELOG entry once user confirms it works, tick **M6.4** in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:116](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L116) with the merge SHA after the user merges in Gitea. ## Out of scope (deferred) - **Modal / `[i]` info button** — M6.5 (not present in Python `/payments` either). - **Period selector** — `/payments` has none in Python; not adding one. - **embed.FS deploy verification** — M6.7. - **Shared partial extraction** — payments structurally diverges from adults/juniors enough that there's no obvious common partial to lift; defer.