From e089a0a997eef39c0db6fa47dce37b91f335e20d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 04:55:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20per-user=20finance=20panel=20?= =?UTF-8?q?=E2=80=94=20balances,=20benefits,=20risk,=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /_gm user card gains a Finance panel: an account's chip balances per funding segment, benefits per origin (hints, no-ads until/forever), the recorded refund risk (abuse flag + floor-0 loss), and the append-only ledger history newest-first. Backed by a new payments.AccountStatement read straight from the materialized tables + the ledger (uncached — an admin, rare view). Folds the agreed admin / reports / catalog plan into PLAN.md (the PR stack: this panel, then the catalog editor, admin grant, refund + ledger export) and moves the tournament-entry storage design to the tournament stage. --- PLAN.md | 117 ++++++++++++++---- backend/README.md | 5 +- .../templates/pages/user_detail.gohtml | 22 ++++ backend/internal/adminconsole/views.go | 46 +++++++ .../inttest/payments_statement_test.go | 101 +++++++++++++++ backend/internal/payments/statement.go | 63 ++++++++++ backend/internal/payments/store_statement.go | 102 +++++++++++++++ .../internal/server/handlers_admin_console.go | 32 +++++ 8 files changed, 460 insertions(+), 28 deletions(-) create mode 100644 backend/internal/inttest/payments_statement_test.go create mode 100644 backend/internal/payments/statement.go create mode 100644 backend/internal/payments/store_statement.go 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}} + +{{else}}

no balances or benefits

{{end}} +

Ledger

+{{if .Finance.Ledger}} + + + +{{range .Finance.Ledger}} + +{{end}} + +
TimeKindSourceOriginChipsOrderProviderDetail
{{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{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 {