diff --git a/PLAN.md b/PLAN.md
index 02352b3..f1cf9a3 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -36,7 +36,7 @@ status — without re-deriving decisions.
| E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | DONE |
-| E7 | Admin & reports | 2 | TODO |
+| E7 | Admin, reports & catalog | 2 | TODO |
| E8 | Guest limits | — | TODO |
| E9 | Tournament fee | future | TODO |
@@ -693,38 +693,89 @@ it tunes without a store release.
---
-## E7 — Admin & reports
+## E7 — Admin, reports & catalog
-**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
-mechanics: PAYMENTS §11.
+**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
+E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
-**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
-refund UI, ledger export.
+**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
-**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
+- **Archived product = the existing `product.active` flag** (no new column, no migration):
+ `active=false` **is** "archived". Its three behaviours already hold — hidden from the user
+ storefront (`store_catalog.go` filters `active`), an **in-flight external payment still
+ credits** (the `fund` credit path resolves the order and never re-checks `active`; only order
+ *creation* / chip *spend* require `active`), and a product with any order/ledger row **cannot
+ be hard-deleted** (FK `orders→product` / `ledger→product` are RESTRICT). The admin toggle is
+ labelled **Archive / Unarchive**.
+- **Delete vs archive:** the editor offers a hard **Delete** only for a product with **no
+ orders and no ledger rows** (never transacted — the FK is the DB backstop); a transacted
+ product is **archive-only**.
+- **Admin grant = raw atoms + by-product.** Keep the quick raw-atom grant (N hints / no-ads
+ days / forever) AND add grant-by-product: pick a defined product (including archived "reward"
+ bundles) and grant its atoms. Both write an `admin_grant` ledger row; by-product records
+ `product_id` + the snapshot. **Both refuse any set containing `chips`** (admin never grants
+ currency) **or `tournament`** (no credit target until E9) — an explicit refusal, never a
+ silent no-op.
+- **Manual refund = full order only** for now (the E5 `Refund` engine takes an amount; partial
+ is a later add if needed).
+- **Tournament stays atom-only (E0); its entry economy is E9.** The catalog editor can compose
+ products carrying the `tournament` atom (archived templates for E9), but granting/spending a
+ `tournament` atom is refused until E9 designs the storage (recurring types, each its own
+ benefit + price) — see E9. Adding a `benefits.tournament` counter now was rejected: the model
+ is multi-type, so a single column would be wrong and force a second DB break.
+- The admin grant **no longer mirrors `grant-hints`** (E6/D31 removed that action); it is a
+ fresh action on `payments.Grant`.
-- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
- `UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
- read from the append-only ledger + materialized cache.
-- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
- concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
- `admin_grant` ledger row (E2 `Grant`).
-- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
- path); records a `refund` ledger row; best-effort benefit revoke.
-- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
- reconciliation (export-ready schema; reconciliation itself not built).
+**Goal.** The admin console financial surface — per-user report, admin grant, manual refund,
+ledger export — plus the **configurable product catalog editor** (D32).
+
+**Work (`/_gm`, `handlers_admin_console.go` + `adminconsole/`, `internal/payments/`).**
+
+- **Per-user financial panel** on the user card (`consoleUserDetail`, `UserDetailView`): segment
+ chip balances `(account, source)`, benefits `(account, origin)` (hints, no-ads until/forever),
+ and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product,
+ provider, snapshot) from the ledger + materialized cache. Replaces the retired
+ `PaidAccount`/`HintBalance` fields with the segmented view.
+- **Catalog editor** (`/_gm/catalog`): list every product (active + archived) with its atoms and
+ per-method/currency prices; create/edit (title, atom items `atom_type→quantity`, price rows
+ `method+currency→amount`); **Archive/Unarchive** (`active`); **Delete** (never-transacted
+ only). Enforce the projection's shape: a **pack** (carries `chips`) needs a money price per
+ method; a **value** (no `chips`) needs a single `CHIP` price. A `tournament`-bearing product is
+ allowed in composition but cannot be activated for sale until E9.
+- **Admin grant** action: raw atoms (hints / no-ads days / forever) and by-product (a value
+ product, incl. archived); **origin picker**; refuses `chips`/`tournament`; `admin_grant` ledger
+ row (+ `product_id`/snapshot for by-product) via `payments.Grant`.
+- **Manual refund** action: refund a specific paid order **in full** — a `refund` ledger row +
+ best-effort floor-0 benefit revoke (E5 `Refund`).
+- **Ledger export**: CSV/JSON for tax + future Robokassa reconciliation (export-ready;
+ reconciliation itself not built).
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
**Tests.**
-- unit: report view assembly; grant refuses chips; export shape.
-- integration: grant/refund write correct ledger rows; report reflects balances+history.
+- unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament;
+ editor validation (pack⇒money price, value⇒CHIP price); export shape.
+- integration: grant (raw + by-product) writes the right `admin_grant` row + benefit; refund writes
+ a `refund` row + floor-0 revoke; catalog CRUD round-trips (create→edit→archive→delete-if-clean);
+ delete refused on a transacted product; panel reflects balances + benefits + history.
-**Done-criteria.** An operator can see a user's full financial picture, grant concrete
-values (origin-picked, never chips), issue a manual refund, and export the ledger.
+**Done-criteria.** An operator can: see a user's full financial picture; create/edit/archive
+products + prices and delete only never-transacted ones; grant concrete values raw or by-product
+(origin-picked, never chips/tournament); refund an order in full; export the ledger.
-**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
-those with the segmented view as the legacy columns retire.
+**PR stack (linear into `development`).**
+
+1. **Per-user financial panel** (read-only ledger/segments/benefits on the user card; retires the
+ `PaidAccount`/`HintBalance` display).
+2. **Catalog editor** (product/atom/price CRUD + archive/unarchive + delete-if-clean + shape
+ validation).
+3. **Admin grant** (raw + by-product, refuse chips/tournament, origin-picked, `admin_grant` +
+ snapshot).
+4. **Manual refund** (full order via E5 `Refund`) + **ledger export** (CSV/JSON).
+
+**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
+refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
+become bootstrap-only.
---
@@ -770,11 +821,23 @@ on creation, not mid-game) at implementation and record it here.
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
mechanics: PAYMENTS §5, §7.
-**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
-are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
-feature and its coupling to entry are out of scope until the tournament feature exists.
+**Goal.** A chip entry economy for tournaments. The `tournament` atom is provisioned (E0) and
+E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage
++ credit/spend** here, to avoid guessing the schema.
-**Done-criteria.** Deferred — revisit when tournaments are built.
+**Design to settle here (not before — avoid a double DB break).** Tournaments are expected in
+**several recurring types** (daily / weekly / monthly …), each its **own benefit with its own
+price** — so a single `benefits.tournament` counter is wrong. Model the entry store as
+**per-tournament-type** (e.g. a `tournament_type` catalog + a per-`(account, type)` entry
+balance), design the pricing, then:
+
+- lift E7's **refusal** of granting/spending the `tournament` atom (admin grant by-product +
+ chip spend);
+- add the **spend** (charge an entry) reusing E2 `Spend`;
+- wire the coupling to the actual tournament feature (out of scope until it exists).
+
+**Done-criteria.** Deferred — revisit when tournaments are built; this stage owns the
+tournament-entry storage + pricing design.
---
diff --git a/backend/README.md b/backend/README.md
index 7ed70c5..e77d444 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -139,7 +139,10 @@ the resolved, weighted campaign feed for an **eligible** viewer (no active **no-
applicable in the current context and no **`no_banner`** role; the message language picked by
`preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place.
-The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The shared wire
+The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The user card
+also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per
+funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history
+(newest first) — read straight from the payments tables, uncached. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
index 0a134b5..d79888e 100644
--- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
@@ -63,6 +63,28 @@
+
Finance
+{{if .Finance.Present}}
+{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
+
+{{range .Finance.Segments}}
Chips ({{.Source}}) {{.Chips}}
{{end}}
+{{range .Finance.Benefits}}
Benefits ({{.Origin}}) {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}
{{end}}
+{{if or .Finance.Abuse .Finance.Loss}}
Refund risk{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}
{{end}}
+
+{{else}}
no balances or benefits
{{end}}
+
Ledger
+{{if .Finance.Ledger}}
+
+
Time
Kind
Source
Origin
Chips
Order
Provider
Detail
+
+{{range .Finance.Ledger}}
+
{{.At}}
{{.Kind}}
{{.Source}}
{{.Origin}}
{{.ChipsDelta}}
{{if .Order}}{{.Order}}{{end}}
{{.Provider}}
{{if .Snapshot}}{{.Snapshot}}{{end}}
+{{end}}
+
+
+{{else}}
no ledger entries
{{end}}
+{{else}}
payments not enabled
{{end}}
+
Roles
{{$id := .ID}}
{{if .Roles}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index e9d84f6..7fddc20 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -195,6 +195,52 @@ type UserDetailView struct {
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
+ // Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
+ // is false when the payments domain is unwired.
+ Finance FinanceView
+}
+
+// FinanceView is the account's payments picture on the user card: chip balances per funding
+// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
+// (newest first). Present is false when the payments domain is unwired.
+type FinanceView struct {
+ Present bool
+ Segments []SegmentRow
+ Benefits []BenefitRow
+ // Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
+ Abuse bool
+ Loss int
+ Ledger []LedgerRow
+}
+
+// SegmentRow is one funding segment's chip balance.
+type SegmentRow struct {
+ Source string
+ Chips int
+}
+
+// BenefitRow is one origin's benefit: the hint wallet, the ad-free expiry (pre-formatted, empty
+// when none) and the lifetime ad-free flag.
+type BenefitRow struct {
+ Origin string
+ Hints int
+ AdsUntil string
+ Forever bool
+}
+
+// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
+// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
+// the pre-formatted time.
+type LedgerRow struct {
+ Kind string
+ Source string
+ Origin string
+ ChipsDelta int
+ Product string
+ Order string
+ Provider string
+ Snapshot string
+ At string
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
diff --git a/backend/internal/inttest/payments_statement_test.go b/backend/internal/inttest/payments_statement_test.go
new file mode 100644
index 0000000..1cc8250
--- /dev/null
+++ b/backend/internal/inttest/payments_statement_test.go
@@ -0,0 +1,101 @@
+//go:build integration
+
+package inttest
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/payments"
+)
+
+// TestAccountStatement checks the admin financial statement: a funded pack shows as a chip segment
+// + a fund ledger row, an admin grant as a benefit + an admin_grant row, the ledger is newest-first,
+// and a clean account carries no refund risk.
+func TestAccountStatement(t *testing.T) {
+ ctx := context.Background()
+ svc := newPaymentsService()
+ acc := uuid.New()
+
+ // A funded pack: 100 chips to the direct segment + a fund ledger row.
+ prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
+ res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
+ if err != nil {
+ t.Fatalf("create order: %v", err)
+ }
+ paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
+ if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
+ t.Fatalf("fund: %v", err)
+ }
+ // An admin grant: 5 hints to the direct origin + an admin_grant ledger row.
+ if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, false); err != nil {
+ t.Fatalf("grant: %v", err)
+ }
+
+ stmt, err := svc.AccountStatement(ctx, acc)
+ if err != nil {
+ t.Fatalf("statement: %v", err)
+ }
+
+ if len(stmt.Segments) != 1 || stmt.Segments[0].Source != payments.SourceDirect || stmt.Segments[0].Chips != 100 {
+ t.Fatalf("segments = %+v, want 100 chips on direct", stmt.Segments)
+ }
+ if len(stmt.Benefits) != 1 || stmt.Benefits[0].Origin != payments.SourceDirect || stmt.Benefits[0].Hints != 5 {
+ t.Fatalf("benefits = %+v, want 5 hints on direct", stmt.Benefits)
+ }
+ if len(stmt.Ledger) != 2 {
+ t.Fatalf("ledger rows = %d, want 2 (fund + admin_grant)", len(stmt.Ledger))
+ }
+ // Newest-first ordering (the grant is the later write).
+ if stmt.Ledger[0].CreatedAt.Before(stmt.Ledger[1].CreatedAt) {
+ t.Error("ledger is not newest-first")
+ }
+ kinds := map[string]int{}
+ for _, e := range stmt.Ledger {
+ kinds[e.Kind]++
+ if e.Kind == "fund" && e.ChipsDelta != 100 {
+ t.Errorf("fund chips delta = %d, want 100", e.ChipsDelta)
+ }
+ }
+ if kinds["fund"] != 1 || kinds["admin_grant"] != 1 {
+ t.Fatalf("ledger kinds = %v, want one fund + one admin_grant", kinds)
+ }
+ if stmt.Risk.Present {
+ t.Errorf("risk = %+v, want none for a clean account", stmt.Risk)
+ }
+}
+
+// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
+// grant surface as the segment balance, the benefit and the ledger rows.
+func TestConsoleFinancePanel(t *testing.T) {
+ ctx := context.Background()
+ srv, _, pay := bannerServer(t)
+ id := provisionAccount(t)
+
+ prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
+ res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
+ if err != nil {
+ t.Fatalf("create order: %v", err)
+ }
+ paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
+ if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
+ t.Fatalf("fund: %v", err)
+ }
+ if err := pay.Grant(ctx, id, payments.SourceDirect, 5, 0, false); err != nil {
+ t.Fatalf("grant: %v", err)
+ }
+
+ code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/users/"+id.String(), "", "")
+ if code != http.StatusOK {
+ t.Fatalf("user card = %d, want 200", code)
+ }
+ for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("finance panel missing %q", want)
+ }
+ }
+}
diff --git a/backend/internal/payments/statement.go b/backend/internal/payments/statement.go
new file mode 100644
index 0000000..1ecc816
--- /dev/null
+++ b/backend/internal/payments/statement.go
@@ -0,0 +1,63 @@
+package payments
+
+import (
+ "context"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// Statement is an account's full financial picture for the admin console: chip balances per funding
+// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
+// (newest first). Read straight from the materialized tables + the ledger, uncached — an admin-only,
+// rare view, not a hot path.
+type Statement struct {
+ Segments []SegmentChips
+ Benefits []OriginBenefit
+ Risk RiskInfo
+ Ledger []LedgerEntry
+}
+
+// SegmentChips is one funding segment's chip balance.
+type SegmentChips struct {
+ Source Source
+ Chips int
+}
+
+// OriginBenefit is one origin's benefit state: the hint wallet, the ad-free term (zero AdsPaidUntil
+// when none) and the lifetime ad-free flag.
+type OriginBenefit struct {
+ Origin Source
+ Hints int
+ AdsPaidUntil time.Time
+ AdsForever bool
+}
+
+// RiskInfo is the account's recorded refund risk: whether it is abuse-flagged and the unrecoverable
+// chip loss accumulated by floor-0 refunds. Present is false when the account has no risk row.
+type RiskInfo struct {
+ Present bool
+ Abuse bool
+ LossChips int
+}
+
+// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
+// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
+type LedgerEntry struct {
+ Kind string
+ Source string
+ Origin string
+ ChipsDelta int
+ ProductID string
+ OrderID string
+ Provider string
+ ProviderPaymentID string
+ Snapshot string
+ CreatedAt time.Time
+}
+
+// AccountStatement assembles the account's financial picture (segments, benefits, risk, full ledger
+// history) for the admin console, straight from the materialized tables and the ledger (uncached).
+func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
+ return s.store.accountStatement(ctx, accountID)
+}
diff --git a/backend/internal/payments/store_statement.go b/backend/internal/payments/store_statement.go
new file mode 100644
index 0000000..dc18516
--- /dev/null
+++ b/backend/internal/payments/store_statement.go
@@ -0,0 +1,102 @@
+package payments
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/go-jet/jet/v2/postgres"
+ "github.com/go-jet/jet/v2/qrm"
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/postgres/jet/payments/model"
+ "scrabble/backend/internal/postgres/jet/payments/table"
+)
+
+// statementOrder is the fixed segment/origin ordering the report renders, so the panel is stable
+// (the balance/benefit maps iterate randomly).
+var statementOrder = []Source{SourceDirect, SourceVK, SourceTelegram}
+
+// accountStatement reads the account's balances, benefits, refund risk and full ledger history for
+// the admin report. Uncached and outside any transaction — an operator view, not a hot path.
+func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
+ st, err := s.loadState(ctx, accountID)
+ if err != nil {
+ return Statement{}, err
+ }
+ var out Statement
+ for _, src := range statementOrder {
+ if chips, ok := st.chips[src]; ok {
+ out.Segments = append(out.Segments, SegmentChips{Source: src, Chips: chips})
+ }
+ if b, ok := st.benefits[src]; ok {
+ out.Benefits = append(out.Benefits, OriginBenefit{
+ Origin: src, Hints: b.hints, AdsPaidUntil: derefTime(b.adsPaidUntil), AdsForever: b.adsForever,
+ })
+ }
+ }
+
+ var risk model.AccountRisk
+ err = postgres.SELECT(table.AccountRisk.AllColumns).
+ FROM(table.AccountRisk).
+ WHERE(table.AccountRisk.AccountID.EQ(postgres.UUID(accountID))).
+ LIMIT(1).
+ QueryContext(ctx, s.db, &risk)
+ switch {
+ case err == nil:
+ out.Risk = RiskInfo{Present: true, Abuse: risk.Abuse, LossChips: int(risk.LossChips)}
+ case errors.Is(err, qrm.ErrNoRows):
+ // no risk row — a clean account
+ default:
+ return Statement{}, fmt.Errorf("payments: load risk %s: %w", accountID, err)
+ }
+
+ var rows []model.Ledger
+ if err := postgres.SELECT(table.Ledger.AllColumns).
+ FROM(table.Ledger).
+ WHERE(table.Ledger.AccountID.EQ(postgres.UUID(accountID))).
+ ORDER_BY(table.Ledger.CreatedAt.DESC()).
+ QueryContext(ctx, s.db, &rows); err != nil {
+ return Statement{}, fmt.Errorf("payments: load ledger %s: %w", accountID, err)
+ }
+ for _, r := range rows {
+ out.Ledger = append(out.Ledger, LedgerEntry{
+ Kind: r.Kind,
+ Source: derefStr(r.Source),
+ Origin: derefStr(r.Origin),
+ ChipsDelta: int(r.ChipsDelta),
+ ProductID: derefUUID(r.ProductID),
+ OrderID: derefUUID(r.OrderID),
+ Provider: derefStr(r.Provider),
+ ProviderPaymentID: derefStr(r.ProviderPaymentID),
+ Snapshot: derefStr(r.Snapshot),
+ CreatedAt: r.CreatedAt,
+ })
+ }
+ return out, nil
+}
+
+// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
+func derefStr(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+}
+
+// derefUUID renders the pointed-to UUID as a string, or "" when the pointer is nil.
+func derefUUID(p *uuid.UUID) string {
+ if p == nil {
+ return ""
+ }
+ return p.String()
+}
+
+// derefTime returns the pointed-to time, or the zero time when the pointer is nil (a NULL column).
+func derefTime(p *time.Time) time.Time {
+ if p == nil {
+ return time.Time{}
+ }
+ return *p
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 190a829..a0c10a7 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -21,6 +21,7 @@ import (
"scrabble/backend/internal/dictadmin"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
+ "scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
@@ -436,9 +437,40 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Friends = relationRows(rels)
}
}
+ if s.payments != nil {
+ if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
+ view.Finance = financeView(stmt)
+ } else {
+ s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
+ }
+ }
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
+// financeView projects an account's payments statement into the user-card finance panel, with the
+// benefit expiry and ledger times pre-formatted for the logic-free template.
+func financeView(stmt payments.Statement) adminconsole.FinanceView {
+ fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
+ for _, sg := range stmt.Segments {
+ fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
+ }
+ for _, b := range stmt.Benefits {
+ row := adminconsole.BenefitRow{Origin: string(b.Origin), Hints: b.Hints, Forever: b.AdsForever}
+ if !b.AdsPaidUntil.IsZero() {
+ row.AdsUntil = fmtTime(b.AdsPaidUntil)
+ }
+ fv.Benefits = append(fv.Benefits, row)
+ }
+ for _, e := range stmt.Ledger {
+ fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
+ Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
+ Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
+ At: fmtTime(e.CreatedAt),
+ })
+ }
+ return fv
+}
+
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {