feat(go): M6.4 — Go-native /payments page (grouped-by-person ledger)
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s

- Extract AssemblePayments(ctx) from ServePayments in api/handler.go,
  mirroring the AssembleAdults/AssembleJuniors pattern
- Add PaymentsPageData view-model wrapper in render.go
- Rewire html_handler.go ServePayments to call AssemblePayments and
  render with PaymentsPageData
- Replace payments.tmpl placeholder with real grouped-by-person ledger:
  alphabetical member blocks, txn-table (Date/Amount/Purpose/Message),
  newest-first rows, Unmatched/Unknown bucket
- Append ledger CSS classes to app.css (.ledger-container, .member-block,
  .txn-table, .txn-date/amount/purpose/message, tr:hover)
- Add TestPaymentsPage markup test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:29:32 +02:00
parent 7f87e63b7c
commit cb8a09b571
7 changed files with 368 additions and 6 deletions

View File

@@ -0,0 +1,215 @@
# M6.4 — Go-native `/payments` page (grouped-by-person ledger)
> Plan-mode note: per project convention this plan should live at
> `docs/plans/2026-05-08-1220-go-m6-4-payments-page.md`. The first execution
> step after approval is to copy this file there.
## Context
The Go port of the Flask app is at milestone **M6.4**: replace the placeholder
`/payments` template ([go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl))
with a feature-equivalent grouped-by-person ledger view, matching the Python
[templates/payments.html](templates/payments.html) page.
Compared to M6.2 (`/adults`) and M6.3 (`/juniors`), this page is **structurally
much simpler**:
- No filters (no name input, no month-range selects).
- No totals / credits / debts / unmatched-as-extra-section.
- No JS (no `filters.js`, no modal — modal is M6.5 territory and Python's
payments page itself has none).
- No QR / Pay buttons.
- Single grouping dimension: **by person**, sorted alphabetically, with a
synthetic `"Unmatched / Unknown"` bucket appended.
- Each person's transactions sorted **newest-first**.
- Columns: Date, Amount (right-aligned, green), Purpose, Bank Message.
The JSON API side is already done (`/api/payments` at
[go/internal/web/api/handler.go:78-86](go/internal/web/api/handler.go#L78-L86)),
returning a `PaymentsResponse{ GroupedPayments, SortedPeople, AttendanceURL,
PaymentsURL }`. The HTML side just needs to consume it.
## Approach
Mirror the M6.2/M6.3 pattern: extract an `AssembleX(ctx)` method, add a typed
`PageData` wrapper, render a real template, lift the page-specific CSS into the
shared stylesheet.
### 1. Extract `AssemblePayments(ctx) (PaymentsResponse, error)`
In [go/internal/web/api/handler.go](go/internal/web/api/handler.go), refactor
`ServePayments` (currently inlines the load+build at lines 78-86) to mirror
`AssembleAdults` / `AssembleJuniors`:
```go
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssemblePayments(r.Context())
if err != nil { h.writeError(w, r, err); return }
writeJSON(w, resp)
}
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
txns, err := h.Sources.LoadTransactions(ctx)
if err != nil { return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) }
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
}
```
Pure refactor — `make parity` should report zero diffs on `/api/payments`.
### 2. Add `PaymentsPageData` wrapper
In [go/internal/web/render.go](go/internal/web/render.go), alongside
`AdultsPageData` / `JuniorsPageData`:
```go
type PaymentsPageData struct {
PageData
Data api.PaymentsResponse
Error string
}
```
No changes needed to `pageNames``"payments"` is already registered.
### 3. Rewire HTML handler
Replace [go/internal/web/html_handler.go:50-52](go/internal/web/html_handler.go#L50-L52):
```go
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
data := PaymentsPageData{ PageData: PageData{Active: "payments", Build: h.build} }
resp, err := h.apiHandler.AssemblePayments(r.Context())
if err != nil { data.Error = err.Error() } else { data.Data = resp }
h.renderer.Render(w, "payments", data)
}
```
### 4. Replace template stub
Rewrite [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl)
(currently a 5-line placeholder). Structure:
```gotmpl
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
<h1>Payments Ledger</h1>
<p class="description">
All bank transactions from the
<a href="{{.Data.PaymentsURL}}" target="_blank">payments sheet</a>,
grouped by member. Names are matched against the
<a href="{{.Data.AttendanceURL}}" target="_blank">attendance sheet</a>.
</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="ledger-container">
{{range $person := .Data.SortedPeople}}
<div class="member-block">
<h2>{{$person}}</h2>
<table class="txn-table">
<thead><tr>
<th class="txn-date">Date</th>
<th class="txn-amount">Amount</th>
<th class="txn-purpose">Purpose</th>
<th class="txn-message">Bank Message</th>
</tr></thead>
<tbody>
{{range $tx := index $.Data.GroupedPayments $person}}
<tr>
<td class="txn-date">{{$tx.Date}}</td>
<td class="txn-amount">{{printf "%.0f" $tx.Amount}} CZK</td>
<td class="txn-purpose">{{$tx.Purpose}}</td>
<td class="txn-message">{{$tx.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}
```
Notes:
- `printf "%.0f"` matches the visual style of integer CZK amounts (Python prints
the float as-is, but bank-row amounts are always whole CZK). If the user
prefers exact Python parity (`750.0 CZK`), drop the `printf` and use
`{{$tx.Amount}}`.
- Iterating a `map` requires using `.Data.SortedPeople` to drive order then
`index $.Data.GroupedPayments $person` — Go templates don't preserve map
insertion order.
### 5. Add CSS classes to `app.css`
Append the page-specific rules from [templates/payments.html:111-164](templates/payments.html#L111-L164)
to [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css):
`.ledger-container`, `.member-block`, `.txn-table`, `.txn-table th/td`,
`.txn-date`, `.txn-amount`, `.txn-purpose`, `.txn-message`, `tr:hover`. The
terminal-green palette already matches.
### 6. Unit test
Add a markup-level test to `go/internal/web/html_handler_test.go` (the file
created in M6.2/M6.3) following the same pattern: stub `Sources` returning a
fixed transaction list, assert response is HTML 200, assert key strings appear
(person names as `<h2>`, "Payments Ledger" `<h1>`, expected amount, "Unmatched
/ Unknown" bucket when an unattributed tx exists).
## Critical files
| Action | Path |
|---------|------|
| edit | [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — extract `AssemblePayments` |
| edit | [go/internal/web/render.go](go/internal/web/render.go) — add `PaymentsPageData` |
| edit | [go/internal/web/html_handler.go](go/internal/web/html_handler.go) — rewire `ServePayments` |
| rewrite | [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) — real template |
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — append ledger CSS |
| edit | `go/internal/web/html_handler_test.go` — payments markup test |
No changes to `server.go`, `assets.go`, `Sources` interface, `buildPaymentsResponse`,
or any domain code — these were completed in M5 / M6.1.
## Reused existing helpers
- [go/internal/web/api/build_payments.go](go/internal/web/api/build_payments.go) — `buildPaymentsResponse` (existing, unchanged)
- [go/internal/web/api/build_common.go:84-117](go/internal/web/api/build_common.go#L84-L117) — `groupRawPaymentsByPerson` handles canonicalization, comma-splitting, `[?]` stripping, newest-first sort
- [go/internal/web/api/handler.go:116-129](go/internal/web/api/handler.go#L116-L129) — `allMemberNames`
- [go/internal/web/templates/partials/nav.tmpl](go/internal/web/templates/partials/nav.tmpl) and `footer.tmpl` — already include the `[Payments Ledger]` nav link
## Verification
End-to-end:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make parity` — confirm the `AssemblePayments` extraction is byte-equal on
`/api/payments`.
3. `make web-go &` then:
- `curl -i localhost:8080/payments | head -30` — HTTP 200, `text/html`.
- Browser load `http://localhost:8080/payments`:
- "Payments Ledger" heading + description with two clickable sheet links.
- Alphabetical member blocks, each with a table.
- Amounts right-aligned, green, "CZK" suffix.
- Newest-first ordering inside each block.
- "Unmatched / Unknown" bucket sorts in alphabetical position (capital U →
near end).
- Hover on a row darkens the background.
- Nav highlights `[Payments Ledger]` as the active link.
- Cross-check with Python: `make web-py &``http://localhost:5001/payments`
should look visually identical except for nav styling.
4. `make web-py & make web-go &` then `tea pr create` once committed (per
project branch-per-feature workflow).
## Branching
Create `feat/go-m6-4-payments-page` off `main`, push, open MR via `tea`.
Follow project conventions: Co-Authored-By trailer, CHANGELOG entry once user
confirms it works, tick **M6.4** in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:116](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L116)
with the merge SHA after the user merges in Gitea.
## Out of scope (deferred)
- **Modal / `[i]` info button** — M6.5 (not present in Python `/payments` either).
- **Period selector** — `/payments` has none in Python; not adding one.
- **embed.FS deploy verification** — M6.7.
- **Shared partial extraction** — payments structurally diverges from
adults/juniors enough that there's no obvious common partial to lift; defer.