diff --git a/docs/plans/2026-05-08-1220-go-m6-4-payments-page.md b/docs/plans/2026-05-08-1220-go-m6-4-payments-page.md
new file mode 100644
index 0000000..ec5b9a9
--- /dev/null
+++ b/docs/plans/2026-05-08-1220-go-m6-4-payments-page.md
@@ -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"}}
+
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}}
+
+
+ | Date |
+ Amount |
+ Purpose |
+ Bank Message |
+
+
+ {{range $tx := index $.Data.GroupedPayments $person}}
+
+ | {{$tx.Date}} |
+ {{printf "%.0f" $tx.Amount}} CZK |
+ {{$tx.Purpose}} |
+ {{$tx.Message}} |
+
+ {{end}}
+
+
+
+{{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.
diff --git a/go/internal/web/api/handler.go b/go/internal/web/api/handler.go
index cad3258..4a3b880 100644
--- a/go/internal/web/api/handler.go
+++ b/go/internal/web/api/handler.go
@@ -76,13 +76,22 @@ func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error)
// ServePayments handles GET /api/payments.
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- txns, err := h.Sources.LoadTransactions(ctx)
+ resp, err := h.AssemblePayments(r.Context())
if err != nil {
- h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
+ h.writeError(w, r, err)
return
}
- writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx)))
+ writeJSON(w, resp)
+}
+
+// AssemblePayments loads transactions and builds the payments view model.
+// Shared between the JSON API route and the HTML handler.
+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
}
func (h *Handler) loadAll(ctx context.Context, adults bool) (
diff --git a/go/internal/web/html_handler.go b/go/internal/web/html_handler.go
index 658012e..adacb81 100644
--- a/go/internal/web/html_handler.go
+++ b/go/internal/web/html_handler.go
@@ -48,7 +48,18 @@ func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
}
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
- h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build})
+ data, err := h.apiHandler.AssemblePayments(r.Context())
+ if err != nil {
+ h.renderer.Render(w, "payments", PaymentsPageData{
+ PageData: PageData{Active: "payments", Build: h.build},
+ Error: err.Error(),
+ })
+ return
+ }
+ h.renderer.Render(w, "payments", PaymentsPageData{
+ PageData: PageData{Active: "payments", Build: h.build},
+ Data: data,
+ })
}
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
diff --git a/go/internal/web/html_handler_test.go b/go/internal/web/html_handler_test.go
index 0de63bc..a871102 100644
--- a/go/internal/web/html_handler_test.go
+++ b/go/internal/web/html_handler_test.go
@@ -126,3 +126,34 @@ func TestAdultsPage(t *testing.T) {
t.Error("body should not contain >OK<")
}
}
+
+func TestPaymentsPage(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))
+
+ req := httptest.NewRequest(http.MethodGet, "/payments", nil)
+ w := httptest.NewRecorder()
+ h.ServePayments(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+
+ body := w.Body.String()
+
+ for _, want := range []string{
+ "Payments Ledger",
+ "Test Member
",
+ "750 CZK",
+ "2026-01-01",
+ "2026-01",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("body missing %q", want)
+ }
+ }
+}
diff --git a/go/internal/web/render.go b/go/internal/web/render.go
index 0484fa5..c9ee3a9 100644
--- a/go/internal/web/render.go
+++ b/go/internal/web/render.go
@@ -30,6 +30,13 @@ type JuniorsPageData struct {
Error string
}
+// PaymentsPageData is the view model for the /payments HTML page.
+type PaymentsPageData struct {
+ PageData
+ Data api.PaymentsResponse
+ Error string
+}
+
// Renderer parses and executes HTML templates from the embedded FS.
type Renderer struct {
tmpls map[string]*template.Template
diff --git a/go/internal/web/static/css/app.css b/go/internal/web/static/css/app.css
index 4f28cea..ff30e45 100644
--- a/go/internal/web/static/css/app.css
+++ b/go/internal/web/static/css/app.css
@@ -476,3 +476,59 @@ tr:hover {
color: #00ff00;
font-family: monospace;
}
+
+/* /payments ledger */
+.ledger-container {
+ width: 100%;
+ max-width: 1000px;
+ margin-bottom: 40px;
+}
+
+.member-block {
+ margin-bottom: 20px;
+}
+
+.txn-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-top: 5px;
+}
+
+.txn-table th,
+.txn-table td {
+ padding: 2px 8px;
+ text-align: left;
+ border-bottom: 1px dashed #222;
+}
+
+.txn-table th {
+ color: #555;
+ text-transform: lowercase;
+ font-weight: normal;
+ border-bottom: 1px solid #333;
+}
+
+.txn-date {
+ min-width: 80px;
+ color: #888;
+}
+
+.txn-amount {
+ min-width: 80px;
+ text-align: right !important;
+ color: #00ff00;
+}
+
+.txn-purpose {
+ min-width: 100px;
+ color: #aaa;
+}
+
+.txn-message {
+ color: #666;
+ font-style: italic;
+}
+
+.txn-table tr:hover {
+ background-color: #1a1a1a;
+}
diff --git a/go/internal/web/templates/payments.tmpl b/go/internal/web/templates/payments.tmpl
index fe059c2..b67f41e 100644
--- a/go/internal/web/templates/payments.tmpl
+++ b/go/internal/web/templates/payments.tmpl
@@ -1,5 +1,38 @@
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
Payments Ledger
-
Coming in M6.4
+
+ 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}}
+
+
+
+ | Date |
+ Amount |
+ Purpose |
+ Bank Message |
+
+
+
+ {{range $tx := index $.Data.GroupedPayments $person}}
+
+ | {{$tx.Date}} |
+ {{printf "%.0f" $tx.Amount}} CZK |
+ {{$tx.Purpose}} |
+ {{$tx.Message}} |
+
+ {{end}}
+
+
+
+{{end}}
+
{{end}}