- 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>
8.9 KiB
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)
with a feature-equivalent grouped-by-person ledger view, matching the Python
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),
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, refactor
ServePayments (currently inlines the load+build at lines 78-86) to mirror
AssembleAdults / AssembleJuniors:
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, alongside
AdultsPageData / JuniorsPageData:
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:
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 (currently a 5-line placeholder). Structure:
{{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 theprintfand use{{$tx.Amount}}.- Iterating a
maprequires using.Data.SortedPeopleto drive order thenindex $.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
to 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 — extract AssemblePayments |
| edit | go/internal/web/render.go — add PaymentsPageData |
| edit | go/internal/web/html_handler.go — rewire ServePayments |
| rewrite | go/internal/web/templates/payments.tmpl — real template |
| edit | 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 —
buildPaymentsResponse(existing, unchanged) - go/internal/web/api/build_common.go:84-117 —
groupRawPaymentsByPersonhandles canonicalization, comma-splitting,[?]stripping, newest-first sort - go/internal/web/api/handler.go:116-129 —
allMemberNames - go/internal/web/templates/partials/nav.tmpl and
footer.tmpl— already include the[Payments Ledger]nav link
Verification
End-to-end:
cd go && make go-build go-test go-lint— all green.make parity— confirm theAssemblePaymentsextraction is byte-equal on/api/payments.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/paymentsshould look visually identical except for nav styling.
make web-py & make web-go &thentea pr createonce 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
with the merge SHA after the user merges in Gitea.
Out of scope (deferred)
- Modal /
[i]info button — M6.5 (not present in Python/paymentseither). - Period selector —
/paymentshas 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.