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>
216 lines
8.9 KiB
Markdown
216 lines
8.9 KiB
Markdown
# 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.
|