Defaults to the last 30 days. The wallet filter matches the funded segment or the
+benefit origin; the rail filter matches the settling provider, so chip spends (which have no rail)
+drop out of it. clear filters
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 4099425..617a2dc 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -207,16 +207,26 @@ type UserDetailView struct {
}
// 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.
+// segment, benefits per origin, the recorded refund risk, and a short lifetime summary. The
+// operations themselves live in the ledger section, which this links to pre-filtered to the
+// account — the card answers "where does this account stand", not "what happened when".
+// 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
+ Abuse bool
+ Loss int
+ // Paid is the money the account has spent, per currency, already formatted; Refunded is what
+ // came back. ChipsBought counts every chip ever credited (purchases, rewarded views, grants) and
+ // ChipsSpent every chip spent on a value.
+ Paid []string
+ Refunded []string
+ ChipsBought int
+ ChipsSpent int
+ // LedgerQuery is the ledger-section query string that shows this account's operations.
+ LedgerQuery template.URL
}
// SegmentRow is one funding segment's chip balance.
@@ -234,22 +244,6 @@ type BenefitRow struct {
Forever bool
}
-// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
-// delta, the product / order / provider / direct-rail shop 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
- Shop string
- Snapshot string
- At string
-}
-
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
type RelationRow struct {
@@ -764,3 +758,53 @@ type GrantProductOption struct {
Summary string
Archived bool
}
+
+// LedgerView is the all-accounts financial ledger: one page of operations, the totals for
+// everything the current filter matches (not merely the page), and the filter state itself so the
+// form renders back what the operator asked for. FilterQuery is those filters URL-encoded, carried
+// into the pager, the CSV export and the refund's return link so every one of them stays on the
+// same slice of the ledger (see MessagesView.FilterQuery).
+type LedgerView struct {
+ Rows []LedgerRow
+ Pager Pager
+ Totals LedgerTotalsRow
+ From string
+ To string
+ UserID string
+ Kind string
+ Wallet string
+ Provider string
+ Kinds []string
+ Wallets []string
+ Providers []string
+ FilterQuery template.URL
+}
+
+// LedgerRow is one operation in the ledger list. Money is the amount the customer paid or was
+// refunded, already formatted with its currency, and empty for a row that moved no money (a chip
+// spend, an admin grant, a rewarded-video credit). Refundable marks a funded order the operator may
+// still reverse, which is what puts the Refund button on that row.
+type LedgerRow struct {
+ At string
+ AccountID string
+ Kind string
+ Source string
+ Origin string
+ ChipsDelta int
+ Money string
+ Title string
+ Order string
+ Provider string
+ Shop string
+ Refundable bool
+}
+
+// LedgerTotalsRow is the summary above the table, over everything the filter matches. Money is
+// listed per currency because the rails settle in different ones and one sum across them would mean
+// nothing.
+type LedgerTotalsRow struct {
+ MoneyIn []string
+ MoneyRefunded []string
+ ChipsIn int
+ ChipsOut int
+}
diff --git a/backend/internal/inttest/ledger_console_test.go b/backend/internal/inttest/ledger_console_test.go
new file mode 100644
index 0000000..4d4e758
--- /dev/null
+++ b/backend/internal/inttest/ledger_console_test.go
@@ -0,0 +1,263 @@
+//go:build integration
+
+package inttest
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/payments"
+)
+
+// seedLedgerRow writes one ledger row directly, at a chosen age, so a test can stand up a history
+// that spans a date range without waiting for one.
+func seedLedgerRow(t *testing.T, acc uuid.UUID, kind, source, provider string, chips int, snapshot string, age time.Duration) {
+ t.Helper()
+ var providerPaymentID any
+ if provider != "" {
+ providerPaymentID = "pp-" + uuid.NewString()
+ }
+ if _, err := testDB.ExecContext(context.Background(), `
+ INSERT INTO payments.ledger (ledger_id, account_id, kind, source, origin, chips_delta,
+ provider, provider_payment_id, snapshot, created_at)
+ VALUES ($1,$2,$3,$4,$4,$5,NULLIF($6,''),$7,$8::jsonb, now() - $9::interval)`,
+ uuid.New(), acc, kind, source, chips, provider, providerPaymentID, snapshot,
+ fmt.Sprintf("%d seconds", int(age.Seconds()))); err != nil {
+ t.Fatalf("seed ledger row: %v", err)
+ }
+}
+
+// ledgerReport reads a filtered page through the service, the way the console does.
+func ledgerReport(t *testing.T, pay *payments.Service, f payments.LedgerFilter, limit, offset int) payments.LedgerReport {
+ t.Helper()
+ rep, err := pay.LedgerReportPage(context.Background(), f, limit, offset)
+ if err != nil {
+ t.Fatalf("ledger report: %v", err)
+ }
+ return rep
+}
+
+// TestLedgerReportFilters covers each filter axis over one seeded history, including the property
+// that matters most for an operator: the totals describe the whole filtered range, not the page.
+func TestLedgerReportFilters(t *testing.T) {
+ acc, other := provisionAccount(t), provisionAccount(t)
+ // Two recent purchases on different rails, one older purchase outside a narrow range, a spend
+ // (no rail at all) and a refund.
+ seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"100 chips"}`, time.Hour)
+ seedLedgerRow(t, acc, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"50 chips"}`, 2*time.Hour)
+ seedLedgerRow(t, other, "fund", "direct", "yookassa", 30, `{"amount_minor":4900,"currency":"RUB","title":"30 chips"}`, 40*24*time.Hour)
+ seedLedgerRow(t, acc, "spend", "direct", "", -20, `{"title":"hints"}`, 30*time.Minute)
+ seedLedgerRow(t, acc, "refund", "direct", "yookassa", -100, `{"amount_minor":14900,"currency":"RUB"}`, 10*time.Minute)
+
+ pay := newPaymentsService()
+ // Scoped to this test's own account: the suite shares one database and other tests seed recent
+ // ledger rows, so an unscoped count would be a coin toss.
+ recent := payments.LedgerFilter{From: time.Now().Add(-24 * time.Hour), AccountID: acc}
+
+ t.Run("date range excludes older rows", func(t *testing.T) {
+ rep := ledgerReport(t, pay, recent, 100, 0)
+ for _, r := range rep.Rows {
+ if r.AccountID == other {
+ t.Error("a row from outside the range was returned")
+ }
+ }
+ })
+
+ t.Run("account", func(t *testing.T) {
+ rep := ledgerReport(t, pay, payments.LedgerFilter{AccountID: other}, 100, 0)
+ if rep.Total != 1 {
+ t.Fatalf("rows for the other account = %d, want 1", rep.Total)
+ }
+ rep = ledgerReport(t, pay, payments.LedgerFilter{AccountID: acc}, 100, 0)
+ if rep.Total != 4 {
+ t.Fatalf("rows for the account = %d, want 4", rep.Total)
+ }
+ })
+
+ t.Run("kind", func(t *testing.T) {
+ f := recent
+ f.Kind = "spend"
+ rep := ledgerReport(t, pay, f, 100, 0)
+ for _, r := range rep.Rows {
+ if r.Kind != "spend" {
+ t.Errorf("kind filter returned a %q row", r.Kind)
+ }
+ }
+ if rep.Total == 0 {
+ t.Error("kind filter matched nothing")
+ }
+ })
+
+ t.Run("wallet", func(t *testing.T) {
+ f := recent
+ f.Wallet = "vk"
+ rep := ledgerReport(t, pay, f, 100, 0)
+ if rep.Total != 1 {
+ t.Fatalf("vk wallet rows = %d, want 1", rep.Total)
+ }
+ })
+
+ t.Run("rail excludes spends, which have none", func(t *testing.T) {
+ f := recent
+ f.Provider = "yookassa"
+ rep := ledgerReport(t, pay, f, 100, 0)
+ for _, r := range rep.Rows {
+ if r.Kind == "spend" {
+ t.Error("a chip spend matched a rail filter, but a spend has no rail")
+ }
+ }
+ })
+
+ t.Run("totals cover the range, not the page", func(t *testing.T) {
+ f := recent
+ full := ledgerReport(t, pay, f, 100, 0)
+ firstPage := ledgerReport(t, pay, f, 1, 0)
+ if len(firstPage.Rows) != 1 {
+ t.Fatalf("page rows = %d, want 1", len(firstPage.Rows))
+ }
+ if firstPage.Total != full.Total {
+ t.Errorf("page total = %d, want the full match count %d", firstPage.Total, full.Total)
+ }
+ if firstPage.Totals.ChipsIn != full.Totals.ChipsIn || firstPage.Totals.ChipsOut != full.Totals.ChipsOut {
+ t.Errorf("page totals = %+v, want the whole range's %+v", firstPage.Totals, full.Totals)
+ }
+ // 100 + 50 credited, 20 spent + 100 refunded revoked.
+ if full.Totals.ChipsIn != 150 || full.Totals.ChipsOut != 120 {
+ t.Errorf("chip totals = in %d / out %d, want 150 / 120", full.Totals.ChipsIn, full.Totals.ChipsOut)
+ }
+ // Money is kept per currency; summing roubles and Votes together would be meaningless.
+ var rub, vote bool
+ for _, m := range full.Totals.MoneyIn {
+ switch m.Currency() {
+ case payments.CurrencyRUB:
+ rub = true
+ if m.Minor() != 14900 {
+ t.Errorf("RUB in = %d, want 14900", m.Minor())
+ }
+ case payments.CurrencyVote:
+ vote = true
+ }
+ }
+ if !rub || !vote {
+ t.Errorf("money in = %v, want both a RUB and a VOTE total", full.Totals.MoneyIn)
+ }
+ if len(full.Totals.MoneyRefunded) != 1 || full.Totals.MoneyRefunded[0].Minor() != 14900 {
+ t.Errorf("money refunded = %v, want one 149.00 RUB total", full.Totals.MoneyRefunded)
+ }
+ })
+
+ t.Run("money is recovered from the snapshot", func(t *testing.T) {
+ f := recent
+ f.Kind = "fund"
+ f.Wallet = "direct"
+ rep := ledgerReport(t, pay, f, 100, 0)
+ if len(rep.Rows) != 1 {
+ t.Fatalf("rows = %d, want 1", len(rep.Rows))
+ }
+ r := rep.Rows[0]
+ if !r.HasMoney() || r.Money.Minor() != 14900 || r.Money.Currency() != payments.CurrencyRUB {
+ t.Errorf("row money = %v, want 149.00 RUB", r.Money)
+ }
+ })
+
+ t.Run("a spend carries no money", func(t *testing.T) {
+ f := recent
+ f.Kind = "spend"
+ rep := ledgerReport(t, pay, f, 100, 0)
+ if len(rep.Rows) != 1 {
+ t.Fatalf("rows = %d, want 1", len(rep.Rows))
+ }
+ if rep.Rows[0].HasMoney() {
+ t.Errorf("a chip spend reported money %v", rep.Rows[0].Money)
+ }
+ })
+}
+
+// TestLedgerReportPaging checks paging walks the range without repeating or dropping a row.
+func TestLedgerReportPaging(t *testing.T) {
+ acc := provisionAccount(t)
+ for i := range 5 {
+ seedLedgerRow(t, acc, "fund", "direct", "yookassa", 10,
+ `{"amount_minor":1000,"currency":"RUB"}`, time.Duration(i+1)*time.Minute)
+ }
+ pay := newPaymentsService()
+ f := payments.LedgerFilter{AccountID: acc}
+
+ seen := map[string]bool{}
+ for offset := 0; offset < 5; offset += 2 {
+ rep := ledgerReport(t, pay, f, 2, offset)
+ if rep.Total != 5 {
+ t.Fatalf("total = %d, want 5 at every offset", rep.Total)
+ }
+ for _, r := range rep.Rows {
+ key := r.ProviderPaymentID
+ if seen[key] {
+ t.Errorf("row %s appeared on two pages", key)
+ }
+ seen[key] = true
+ }
+ }
+ if len(seen) != 5 {
+ t.Errorf("walked %d rows across the pages, want 5", len(seen))
+ }
+}
+
+// TestLedgerConsolePageAndExport drives the console: the page renders under a filter, the CSV export
+// honours that same filter, and the user card links to the ledger scoped to the account instead of
+// listing the rows itself.
+func TestLedgerConsolePageAndExport(t *testing.T) {
+ srv, _, _ := bannerServer(t)
+ acc, other := provisionAccount(t), provisionAccount(t)
+ seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"Pack A"}`, time.Hour)
+ seedLedgerRow(t, other, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"Pack B"}`, time.Hour)
+
+ rec := consoleGet(t, srv, "/_gm/ledger")
+ if rec.Code != 200 {
+ t.Fatalf("ledger page = %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"Pack A", "Pack B", "149.00 RUB", "Totals for the filtered range"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("ledger page does not show %q", want)
+ }
+ }
+
+ // Filtering to one account must drop the other's operation from the page.
+ rec = consoleGet(t, srv, "/_gm/ledger?user="+acc.String())
+ if body := rec.Body.String(); !strings.Contains(body, "Pack A") || strings.Contains(body, "Pack B") {
+ t.Error("the user filter did not scope the ledger page to one account")
+ }
+
+ // The pager links must carry the filters, or paging would silently widen the view.
+ if body := rec.Body.String(); strings.Contains(body, "page=") && !strings.Contains(body, "user="+acc.String()+"&page=") {
+ t.Error("a pager link dropped the active filters")
+ }
+
+ // The export honours the same filter as the screen it is linked from.
+ rec = consoleGet(t, srv, "/_gm/ledger.csv?user="+acc.String())
+ if rec.Code != 200 {
+ t.Fatalf("ledger export = %d, want 200", rec.Code)
+ }
+ csv := rec.Body.String()
+ if !strings.Contains(csv, "Pack A") || strings.Contains(csv, "Pack B") {
+ t.Error("the CSV export ignored the filter")
+ }
+ if !strings.Contains(csv, "amount_minor,currency") {
+ t.Error("the CSV export has no money columns")
+ }
+
+ // The user card is a summary now: totals and a link into the ledger, not the rows themselves.
+ rec = consoleGet(t, srv, "/_gm/users/"+acc.String())
+ card := rec.Body.String()
+ if !strings.Contains(card, "/_gm/ledger?user="+acc.String()) {
+ t.Error("the user card does not link to its ledger slice")
+ }
+ if !strings.Contains(card, "Chips credited") {
+ t.Error("the user card shows no totals summary")
+ }
+}
diff --git a/backend/internal/inttest/payments_statement_test.go b/backend/internal/inttest/payments_statement_test.go
index 1cc8250..888cad7 100644
--- a/backend/internal/inttest/payments_statement_test.go
+++ b/backend/internal/inttest/payments_statement_test.go
@@ -70,7 +70,8 @@ func TestAccountStatement(t *testing.T) {
}
// 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.
+// grant surface as the segment balance, the benefit and the lifetime summary. The operations
+// themselves belong to the ledger section, which the card links to scoped to this account.
func TestConsoleFinancePanel(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
@@ -93,9 +94,17 @@ func TestConsoleFinancePanel(t *testing.T) {
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"} {
+ for _, want := range []string{
+ "Finance", "Chips (direct)", "Benefits (direct)", "5 hints",
+ "Chips credited", "149.00 RUB", // the lifetime summary: chips in, money paid
+ "/_gm/ledger?user=" + id.String(), // the hand-off to the operations view
+ } {
if !strings.Contains(body, want) {
t.Errorf("finance panel missing %q", want)
}
}
+ // The card must not re-render the operations the ledger section owns.
+ if strings.Contains(body, "admin_grant") {
+ t.Error("the user card still lists ledger rows; they belong to /_gm/ledger now")
+ }
}
diff --git a/backend/internal/payments/statement.go b/backend/internal/payments/statement.go
index 41045cf..a3a078f 100644
--- a/backend/internal/payments/statement.go
+++ b/backend/internal/payments/statement.go
@@ -2,6 +2,7 @@ package payments
import (
"context"
+ "encoding/json"
"time"
"github.com/google/uuid"
@@ -63,3 +64,100 @@ type LedgerEntry struct {
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
return s.store.accountStatement(ctx, accountID)
}
+
+// LedgerFilter narrows the operator's ledger report. A zero value matches everything; each set field
+// narrows further. From is inclusive and To exclusive, so a day range does not double-count a row on
+// the boundary.
+//
+// Wallet and Provider are deliberately separate axes, because they answer different questions.
+// Wallet ("what happened on VK") matches the row's funded segment or the origin a benefit was bought
+// in; Provider ("what came through YooKassa") matches the rail that settled it — and a chip spend has
+// no rail at all, so a provider filter excludes spends by construction.
+type LedgerFilter struct {
+ From time.Time
+ To time.Time
+ AccountID uuid.UUID
+ Kind string
+ Wallet string
+ Provider string
+}
+
+// LedgerReportRow is one ledger row for the operator report: the entry itself, the account it
+// belongs to (the per-account report already knows that, the all-accounts one does not) and the
+// money the row moved, recovered from the snapshot — the ledger's own columns count chips.
+type LedgerReportRow struct {
+ LedgerEntry
+ AccountID uuid.UUID
+ // Money is what the customer actually paid or was refunded, or the zero Money for a row that
+ // moved no money (a spend, an admin grant, a rewarded-video credit).
+ Money Money
+}
+
+// HasMoney reports whether the row moved real money.
+func (r LedgerReportRow) HasMoney() bool { return r.Money.Currency() != "" }
+
+// LedgerTotals sums everything a filter matches, not merely the page on screen. Money is listed per
+// currency because the rails settle in different ones (roubles, Votes, Stars) and summing across
+// them would be meaningless.
+type LedgerTotals struct {
+ MoneyIn []Money
+ MoneyRefunded []Money
+ ChipsIn int
+ ChipsOut int
+}
+
+// LedgerReport is one page of the filtered ledger plus the totals for the whole filtered range and
+// how many rows it matches.
+type LedgerReport struct {
+ Rows []LedgerReportRow
+ Totals LedgerTotals
+ Total int
+}
+
+// exportLimit caps the CSV export. The ledger is append-only and grows forever, so an unbounded
+// export would eventually time out; the filter is the way to narrow a real accounting export.
+const exportLimit = 100_000
+
+// LedgerReportPage reads one page of the filtered ledger together with the totals for everything the
+// filter matches. It is the all-accounts operator view: uncached, admin-only, not a hot path.
+func (s *Service) LedgerReportPage(ctx context.Context, f LedgerFilter, limit, offset int) (LedgerReport, error) {
+ rows, total, err := s.store.ledgerPage(ctx, f, limit, offset)
+ if err != nil {
+ return LedgerReport{}, err
+ }
+ totals, err := s.store.ledgerTotals(ctx, f)
+ if err != nil {
+ return LedgerReport{}, err
+ }
+ return LedgerReport{Rows: rows, Totals: totals, Total: total}, nil
+}
+
+// FilteredLedgerExport reads the whole filtered ledger for the CSV export, capped at exportLimit.
+func (s *Service) FilteredLedgerExport(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
+ return s.store.filteredLedger(ctx, f)
+}
+
+// MoneyFromLedgerSnapshot recovers the money a ledger row moved from its snapshot, returning the
+// zero Money when the row carries none (a spend, a grant) or the snapshot predates the fields. It is
+// exported for callers folding their own totals out of a Statement's history.
+func MoneyFromLedgerSnapshot(snapshot string) Money { return moneyFromSnapshot(snapshot) }
+
+// moneyFromSnapshot recovers the money a ledger row moved from its snapshot, returning the zero
+// Money when the row carries none (a spend, a grant) or the snapshot predates the fields.
+func moneyFromSnapshot(snapshot string) Money {
+ if snapshot == "" {
+ return Money{}
+ }
+ var snap struct {
+ Amount int64 `json:"amount_minor"`
+ Currency string `json:"currency"`
+ }
+ if err := json.Unmarshal([]byte(snapshot), &snap); err != nil || snap.Currency == "" {
+ return Money{}
+ }
+ m, err := MoneyFromMinor(snap.Amount, Currency(snap.Currency))
+ if err != nil {
+ return Money{}
+ }
+ return m
+}
diff --git a/backend/internal/payments/store_ledger.go b/backend/internal/payments/store_ledger.go
new file mode 100644
index 0000000..3b5f85d
--- /dev/null
+++ b/backend/internal/payments/store_ledger.go
@@ -0,0 +1,179 @@
+package payments
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// ledgerColumns is the projection every ledger report row is read through, joined to the order so a
+// row carries the merchant channel its payment used.
+const ledgerColumns = `l.account_id, l.kind, l.source, l.origin, l.chips_delta, l.product_id,
+ l.order_id, l.provider, l.provider_payment_id, COALESCE(o.shop, ''), l.snapshot, l.created_at`
+
+// ledgerWhere renders a LedgerFilter as a SQL predicate plus its arguments. Every value is bound as
+// a parameter; only the fixed fragments are concatenated.
+//
+// The wallet filter deliberately matches either side of a row. A row names up to two wallets — the
+// segment whose chips moved (source) and, for a benefit purchase, where that benefit was bought
+// (origin) — and an operator asking "what happened on VK" means both.
+func ledgerWhere(f LedgerFilter) (string, []any) {
+ var clauses []string
+ var args []any
+ add := func(clause string, v any) {
+ args = append(args, v)
+ clauses = append(clauses, fmt.Sprintf(clause, len(args)))
+ }
+ if !f.From.IsZero() {
+ add("l.created_at >= $%d", f.From)
+ }
+ if !f.To.IsZero() {
+ add("l.created_at < $%d", f.To)
+ }
+ if f.AccountID != uuid.Nil {
+ add("l.account_id = $%d", f.AccountID)
+ }
+ if f.Kind != "" {
+ add("l.kind = $%d", f.Kind)
+ }
+ if f.Provider != "" {
+ add("l.provider = $%d", f.Provider)
+ }
+ if f.Wallet != "" {
+ args = append(args, f.Wallet)
+ clauses = append(clauses, fmt.Sprintf("(l.source = $%d OR l.origin = $%d)", len(args), len(args)))
+ }
+ if len(clauses) == 0 {
+ return "", nil
+ }
+ return " WHERE " + strings.Join(clauses, " AND "), args
+}
+
+// ledgerPage reads one page of the filtered ledger, newest first, together with how many rows the
+// filter matches in total (which is what the pager needs — the page itself cannot report it).
+func (s *Store) ledgerPage(ctx context.Context, f LedgerFilter, limit, offset int) ([]LedgerReportRow, int, error) {
+ where, args := ledgerWhere(f)
+
+ var total int
+ if err := s.db.QueryRowContext(ctx,
+ `SELECT count(*) FROM payments.ledger l`+where, args...).Scan(&total); err != nil {
+ return nil, 0, fmt.Errorf("payments: count ledger: %w", err)
+ }
+
+ q := `SELECT ` + ledgerColumns + `
+ FROM payments.ledger l
+ LEFT JOIN payments.orders o ON o.order_id = l.order_id` + where + `
+ ORDER BY l.created_at DESC, l.ledger_id DESC
+ LIMIT $%d OFFSET $%d`
+ args = append(args, limit, offset)
+ rows, err := s.db.QueryContext(ctx, fmt.Sprintf(q, len(args)-1, len(args)), args...)
+ if err != nil {
+ return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
+ }
+ defer rows.Close()
+
+ var out []LedgerReportRow
+ for rows.Next() {
+ var (
+ r LedgerReportRow
+ accountID uuid.UUID
+ source, origin, provider, paymentID sql.NullString
+ productID, orderID sql.NullString
+ snapshot sql.NullString
+ shop string
+ chipsDelta int32
+ createdAt time.Time
+ )
+ if err := rows.Scan(&accountID, &r.Kind, &source, &origin, &chipsDelta, &productID,
+ &orderID, &provider, &paymentID, &shop, &snapshot, &createdAt); err != nil {
+ return nil, 0, fmt.Errorf("payments: scan ledger row: %w", err)
+ }
+ r.AccountID = accountID
+ r.LedgerEntry = LedgerEntry{
+ Kind: r.Kind,
+ Source: source.String,
+ Origin: origin.String,
+ ChipsDelta: int(chipsDelta),
+ ProductID: productID.String,
+ OrderID: orderID.String,
+ Provider: provider.String,
+ ProviderPaymentID: paymentID.String,
+ Shop: shop,
+ Snapshot: snapshot.String,
+ CreatedAt: createdAt,
+ }
+ r.Money = moneyFromSnapshot(snapshot.String)
+ out = append(out, r)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
+ }
+ return out, total, nil
+}
+
+// ledgerTotals sums what the filter matches — across every matching row, not just the page on
+// screen, which is the point of showing them at all. Money is summed per currency from the row
+// snapshot, since the ledger's own columns count chips, not money.
+func (s *Store) ledgerTotals(ctx context.Context, f LedgerFilter) (LedgerTotals, error) {
+ where, args := ledgerWhere(f)
+ out := LedgerTotals{}
+
+ if err := s.db.QueryRowContext(ctx, `
+ SELECT COALESCE(SUM(l.chips_delta) FILTER (WHERE l.chips_delta > 0), 0),
+ COALESCE(-SUM(l.chips_delta) FILTER (WHERE l.chips_delta < 0), 0)
+ FROM payments.ledger l`+where, args...).Scan(&out.ChipsIn, &out.ChipsOut); err != nil {
+ return LedgerTotals{}, fmt.Errorf("payments: sum ledger chips: %w", err)
+ }
+
+ // The money predicate is folded into the same WHERE, because the filter's own clause list may be
+ // empty and an "AND ..." tacked onto a missing WHERE is a syntax error.
+ moneyWhere := where
+ const moneyOnly = `l.kind IN ('fund','refund') AND l.snapshot->>'amount_minor' IS NOT NULL`
+ if moneyWhere == "" {
+ moneyWhere = " WHERE " + moneyOnly
+ } else {
+ moneyWhere += " AND " + moneyOnly
+ }
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT l.kind, l.snapshot->>'currency',
+ COALESCE(SUM((l.snapshot->>'amount_minor')::bigint), 0)
+ FROM payments.ledger l`+moneyWhere+`
+ GROUP BY 1, 2`, args...)
+ if err != nil {
+ return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var kind string
+ var currency sql.NullString
+ var minor int64
+ if err := rows.Scan(&kind, ¤cy, &minor); err != nil {
+ return LedgerTotals{}, fmt.Errorf("payments: scan ledger money: %w", err)
+ }
+ m, err := MoneyFromMinor(minor, Currency(currency.String))
+ if err != nil {
+ continue // an unknown currency in an old snapshot must not break the report
+ }
+ switch kind {
+ case "fund":
+ out.MoneyIn = append(out.MoneyIn, m)
+ case "refund":
+ out.MoneyRefunded = append(out.MoneyRefunded, m)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
+ }
+ return out, nil
+}
+
+// filteredLedger reads the whole filtered ledger for the CSV export — unpaginated by design, since
+// an export of the page on screen would be useless for accounting.
+func (s *Store) filteredLedger(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
+ rows, _, err := s.ledgerPage(ctx, f, exportLimit, 0)
+ return rows, err
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index abe2297..449baec 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -8,6 +8,7 @@ import (
"html/template"
"net/http"
"net/url"
+ "sort"
"strconv"
"strings"
"time"
@@ -119,6 +120,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
+ gm.GET("/ledger", s.consoleLedger)
gm.GET("/ledger.csv", s.consoleLedgerExport)
}
}
@@ -458,7 +460,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
if s.payments != nil {
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
- view.Finance = financeView(stmt)
+ view.Finance = financeView(id, stmt)
} else {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
}
@@ -470,9 +472,11 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
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 {
+// financeView projects an account's payments statement into the user-card finance panel: where the
+// account stands (balances, benefits, lifetime money and chip totals), not what happened when. The
+// operations themselves are the ledger section's job, which the panel links to pre-filtered to this
+// account — one place renders them, with filters and paging, instead of two.
+func financeView(id uuid.UUID, 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})
@@ -484,16 +488,52 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
}
fv.Benefits = append(fv.Benefits, row)
}
+ // The lifetime totals are folded from the history the statement already carries, so the summary
+ // costs no extra query. Money is kept per currency: the rails settle in roubles, Votes and Stars,
+ // and one sum across them would mean nothing.
+ paid, refunded := map[payments.Currency]int64{}, map[payments.Currency]int64{}
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, Shop: e.Shop, Snapshot: e.Snapshot,
- At: fmtTime(e.CreatedAt),
- })
+ if e.ChipsDelta > 0 {
+ fv.ChipsBought += e.ChipsDelta
+ } else if e.Kind == "spend" {
+ fv.ChipsSpent -= e.ChipsDelta
+ }
+ m := payments.MoneyFromLedgerSnapshot(e.Snapshot)
+ if m.Currency() == "" {
+ continue
+ }
+ switch e.Kind {
+ case "fund":
+ paid[m.Currency()] += m.Minor()
+ case "refund":
+ refunded[m.Currency()] += m.Minor()
+ }
}
+ fv.Paid = fmtMoneyTotals(paid)
+ fv.Refunded = fmtMoneyTotals(refunded)
+ fv.LedgerQuery = template.URL(url.Values{"user": {id.String()}}.Encode())
return fv
}
+// fmtMoneyTotals renders per-currency minor-unit sums as display strings, in a stable order so the
+// panel does not reshuffle between reloads (map iteration is random).
+func fmtMoneyTotals(totals map[payments.Currency]int64) []string {
+ currencies := make([]string, 0, len(totals))
+ for c := range totals {
+ currencies = append(currencies, string(c))
+ }
+ sort.Strings(currencies)
+ out := make([]string, 0, len(currencies))
+ for _, c := range currencies {
+ m, err := payments.MoneyFromMinor(totals[payments.Currency(c)], payments.Currency(c))
+ if err != nil {
+ continue
+ }
+ out = append(out, fmtMoney(m))
+ }
+ return out
+}
+
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
func overrideName(ov payments.PurchaseOverride) string {
switch ov {
diff --git a/backend/internal/server/handlers_admin_ledger.go b/backend/internal/server/handlers_admin_ledger.go
new file mode 100644
index 0000000..de43173
--- /dev/null
+++ b/backend/internal/server/handlers_admin_ledger.go
@@ -0,0 +1,212 @@
+package server
+
+import (
+ "encoding/csv"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/adminconsole"
+ "scrabble/backend/internal/payments"
+)
+
+// ledgerPageSize is how many operations one ledger page shows.
+const ledgerPageSize = 100
+
+// ledgerDefaultDays is how far back the ledger looks when the operator has not chosen a range. A
+// month is the reporting period that matters (it is what a tax filing covers) and it keeps the
+// default view bounded on a ledger that only ever grows.
+const ledgerDefaultDays = 30
+
+// ledgerDateFormat is the date form the filter accepts and renders (an value).
+const ledgerDateFormat = "2006-01-02"
+
+// ledgerKinds, ledgerWallets and ledgerProviders are the filter's fixed option lists. They are
+// spelled out rather than derived from the data so the form is stable on an empty ledger and an
+// operator can see what exists at all.
+var (
+ ledgerKinds = []string{"fund", "spend", "admin_grant", "refund"}
+ ledgerWallets = []string{string(payments.SourceDirect), string(payments.SourceVK), string(payments.SourceTelegram)}
+ ledgerProviders = []string{providerYooKassa, providerRobokassa, providerVK, providerTelegram, "vk_ads", "admin"}
+)
+
+// ledgerFilterFrom reads the ledger filter out of the query string, defaulting the range to the last
+// ledgerDefaultDays. An unparseable date or id is treated as unset rather than as an error: a
+// hand-edited URL should narrow nothing, not break the page.
+func ledgerFilterFrom(c *gin.Context, now time.Time) (payments.LedgerFilter, adminconsole.LedgerView) {
+ view := adminconsole.LedgerView{
+ Kinds: ledgerKinds,
+ Wallets: ledgerWallets,
+ Providers: ledgerProviders,
+ }
+ f := payments.LedgerFilter{}
+
+ from, to := strings.TrimSpace(c.Query("from")), strings.TrimSpace(c.Query("to"))
+ if from == "" && to == "" {
+ from = now.AddDate(0, 0, -ledgerDefaultDays).Format(ledgerDateFormat)
+ }
+ if t, err := time.Parse(ledgerDateFormat, from); err == nil {
+ f.From, view.From = t, from
+ }
+ if t, err := time.Parse(ledgerDateFormat, to); err == nil {
+ // The range is inclusive of the chosen end date, so it runs to the start of the next day.
+ f.To, view.To = t.AddDate(0, 0, 1), to
+ }
+ if id, err := uuid.Parse(strings.TrimSpace(c.Query("user"))); err == nil {
+ f.AccountID, view.UserID = id, id.String()
+ }
+ if v := c.Query("kind"); slicesHas(ledgerKinds, v) {
+ f.Kind, view.Kind = v, v
+ }
+ if v := c.Query("wallet"); slicesHas(ledgerWallets, v) {
+ f.Wallet, view.Wallet = v, v
+ }
+ if v := c.Query("provider"); slicesHas(ledgerProviders, v) {
+ f.Provider, view.Provider = v, v
+ }
+ view.FilterQuery = template.URL(ledgerFilterQuery(view))
+ return f, view
+}
+
+// slicesHas reports whether v is one of the allowed options. The filter only ever accepts a value
+// from its own list, so a crafted query string cannot reach the store with something unexpected.
+func slicesHas(allowed []string, v string) bool {
+ for _, a := range allowed {
+ if a == v {
+ return true
+ }
+ }
+ return false
+}
+
+// ledgerFilterQuery renders the active filters as an escaped query fragment. Every link that must
+// stay on the same slice of the ledger — the pager, the CSV export, a refund's way back — is built
+// from it, which is what keeps them in step with the form.
+func ledgerFilterQuery(v adminconsole.LedgerView) string {
+ q := url.Values{}
+ for key, val := range map[string]string{
+ "from": v.From, "to": v.To, "user": v.UserID,
+ "kind": v.Kind, "wallet": v.Wallet, "provider": v.Provider,
+ } {
+ if val != "" {
+ q.Set(key, val)
+ }
+ }
+ return q.Encode()
+}
+
+// consoleLedger renders the all-accounts financial ledger: one page of operations under the current
+// filter, and the totals for everything that filter matches — the picture no per-account card can
+// give. The page number rides the same query string as the filters, so paging never silently widens
+// the view.
+func (s *Server) consoleLedger(c *gin.Context) {
+ f, view := ledgerFilterFrom(c, time.Now())
+ page, _ := strconv.Atoi(c.Query("page"))
+ if page < 1 {
+ page = 1
+ }
+ report, err := s.payments.LedgerReportPage(c.Request.Context(), f, ledgerPageSize, (page-1)*ledgerPageSize)
+ if err != nil {
+ s.consoleError(c, err)
+ return
+ }
+ view.Pager = adminconsole.NewPager(page, ledgerPageSize, report.Total)
+ view.Totals = ledgerTotalsRow(report.Totals)
+ for _, r := range report.Rows {
+ view.Rows = append(view.Rows, ledgerRow(r))
+ }
+ s.renderConsole(c, "ledger", "ledger", "Ledger", view)
+}
+
+// ledgerRow projects one report row for the logic-free template: times and money pre-formatted, the
+// product title lifted out of the snapshot, and the refund affordance decided here rather than in
+// the template — only a funded order can be reversed.
+func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow {
+ row := adminconsole.LedgerRow{
+ At: fmtTime(r.CreatedAt),
+ AccountID: r.AccountID.String(),
+ Kind: r.Kind,
+ Source: r.Source,
+ Origin: r.Origin,
+ ChipsDelta: r.ChipsDelta,
+ Order: r.OrderID,
+ Provider: r.Provider,
+ Shop: r.Shop,
+ Refundable: r.Kind == "fund" && r.OrderID != "",
+ Title: snapshotTitle(r.Snapshot),
+ }
+ if r.HasMoney() {
+ row.Money = fmtMoney(r.Money)
+ }
+ return row
+}
+
+// ledgerTotalsRow pre-formats the period totals for the template.
+func ledgerTotalsRow(t payments.LedgerTotals) adminconsole.LedgerTotalsRow {
+ out := adminconsole.LedgerTotalsRow{ChipsIn: t.ChipsIn, ChipsOut: t.ChipsOut}
+ for _, m := range t.MoneyIn {
+ out.MoneyIn = append(out.MoneyIn, fmtMoney(m))
+ }
+ for _, m := range t.MoneyRefunded {
+ out.MoneyRefunded = append(out.MoneyRefunded, fmtMoney(m))
+ }
+ return out
+}
+
+// fmtMoney renders an amount with its currency, e.g. "149.00 RUB".
+func fmtMoney(m payments.Money) string {
+ return fmt.Sprintf("%s %s", m.Major(), m.Currency())
+}
+
+// snapshotTitle lifts the sold product's title out of a ledger row snapshot, so the table names what
+// was bought instead of only its id. It returns "" when the snapshot carries no title.
+func snapshotTitle(snapshot string) string {
+ if snapshot == "" {
+ return ""
+ }
+ var snap struct {
+ Title string `json:"title"`
+ }
+ if err := json.Unmarshal([]byte(snapshot), &snap); err != nil {
+ return ""
+ }
+ return snap.Title
+}
+
+// consoleLedgerExport streams the filtered ledger as a CSV attachment for tax reporting and rail
+// reconciliation. It honours the same filters as the page it is linked from — an export that
+// silently covered a different range than the screen would be worse than no export.
+func (s *Server) consoleLedgerExport(c *gin.Context) {
+ f, _ := ledgerFilterFrom(c, time.Now())
+ rows, err := s.payments.FilteredLedgerExport(c.Request.Context(), f)
+ if err != nil {
+ s.consoleError(c, err)
+ return
+ }
+ c.Header("Content-Type", "text/csv; charset=utf-8")
+ c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
+ w := csv.NewWriter(c.Writer)
+ _ = w.Write([]string{
+ "created_at", "account_id", "kind", "source", "origin", "chips_delta",
+ "amount_minor", "currency", "product_id", "order_id", "provider", "provider_payment_id", "shop", "snapshot",
+ })
+ for _, r := range rows {
+ amount, currency := "", ""
+ if r.HasMoney() {
+ amount, currency = strconv.FormatInt(r.Money.Minor(), 10), string(r.Money.Currency())
+ }
+ _ = w.Write([]string{
+ r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID.String(), r.Kind, r.Source, r.Origin,
+ strconv.Itoa(r.ChipsDelta), amount, currency,
+ r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Shop, r.Snapshot,
+ })
+ }
+ w.Flush()
+}
diff --git a/backend/internal/server/handlers_admin_refund.go b/backend/internal/server/handlers_admin_refund.go
index c674028..ac493d6 100644
--- a/backend/internal/server/handlers_admin_refund.go
+++ b/backend/internal/server/handlers_admin_refund.go
@@ -2,12 +2,9 @@ package server
import (
"context"
- "encoding/csv"
"errors"
"fmt"
- "strconv"
"strings"
- "time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -27,7 +24,7 @@ func (s *Server) consoleRefund(c *gin.Context) {
if !ok {
return
}
- back := "/_gm/users/" + id.String()
+ back := consoleReturnTo(c.PostForm("back"), "/_gm/users/"+id.String())
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
@@ -69,6 +66,18 @@ func (s *Server) consoleRefund(c *gin.Context) {
s.renderConsoleMessage(c, "Refunded", msg, back)
}
+// consoleReturnTo picks where the result page links back to: the caller's requested destination when
+// it is a console page, else the fallback. A refund can be started from the account card or from the
+// ledger (where the operator wants to land back on the same filtered slice), so the destination
+// travels with the form — but only ever as a console-relative path, never as an arbitrary URL a
+// crafted form could point elsewhere.
+func consoleReturnTo(requested, fallback string) string {
+ if strings.HasPrefix(requested, "/_gm/") && !strings.HasPrefix(requested, "/_gm//") {
+ return requested
+ }
+ return fallback
+}
+
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
// must never claim a refund that did not happen. The reversal is then recorded under the provider's
@@ -93,27 +102,3 @@ func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (paymen
}
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
}
-
-// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
-// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
-func (s *Server) consoleLedgerExport(c *gin.Context) {
- rows, err := s.payments.LedgerExport(c.Request.Context())
- if err != nil {
- s.consoleError(c, err)
- return
- }
- c.Header("Content-Type", "text/csv; charset=utf-8")
- c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
- w := csv.NewWriter(c.Writer)
- _ = w.Write([]string{
- "created_at", "account_id", "kind", "source", "origin", "chips_delta",
- "product_id", "order_id", "provider", "provider_payment_id", "snapshot",
- })
- for _, r := range rows {
- _ = w.Write([]string{
- r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
- strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
- })
- }
- w.Flush()
-}
diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md
index 2963093..a64dc52 100644
--- a/docs/PAYMENTS.md
+++ b/docs/PAYMENTS.md
@@ -418,9 +418,23 @@ origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
-**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
-spends, grants, refunds, full history — as an extension of the existing user card
-(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
+**The ledger section** `/_gm/ledger` is the all-accounts view of the money: every operation, newest
+first, filtered by date range (defaulting to the last 30 days), by **wallet** (`vk`/`telegram`/
+`direct` — matching the funded segment or the benefit origin), by **rail** (the settling provider, so
+chip spends drop out by construction), by kind and by account. Above the table sit the totals for
+**everything the filter matches**, not merely the page: money in and refunded per currency (the rails
+settle in roubles, Votes and Stars, so one sum across them would mean nothing) and chips credited and
+spent. The row amounts come from the ledger snapshot, since the ledger's own columns count chips.
+Paging, the CSV export and a refund's way back all carry the same filter query, so none of them can
+silently show a different slice than the screen.
+
+The **refund action** lives on the funded rows here — the operator can find a payment by filter
+without knowing whose it is first — and returns to the same filtered view afterwards.
+
+**The user card** carries the account's standing rather than its history: segment balances, benefits,
+the refund-risk flag, and a short lifetime summary (money paid and refunded per currency, chips
+credited and spent), with a link into the ledger section pre-filtered to that account. One place
+renders operations, with filters and paging, instead of two.
## 12. Taxes and compliance
diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md
index e53f512..5d3e2cf 100644
--- a/docs/PAYMENTS_ru.md
+++ b/docs/PAYMENTS_ru.md
@@ -410,9 +410,24 @@ in-process кэш сегментов и бенефитов по ключу-ак
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
полный аудит наград.
-**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
-гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
-`handlers_admin_console.go`). Плюс экспорт журнала.
+**Раздел журнала** `/_gm/ledger` — общий вид на деньги по всем аккаунтам: все операции, новые
+сверху, с фильтрами по диапазону дат (по умолчанию последние 30 дней), по **кошельку**
+(`vk`/`telegram`/`direct` — совпадение по пополненному сегменту или по origin бенефита), по
+**рельсу** (провайдер, который провёл платёж, поэтому траты Фишек в такой фильтр не попадают вовсе),
+по виду операции и по аккаунту. Над таблицей — итоги по **всему, что попало под фильтр**, а не по
+странице: пришло и возвращено денег по каждой валюте отдельно (рельсы считают в рублях, Голосах и
+Stars, общая сумма по ним не значила бы ничего) и начислено/списано Фишек. Суммы в строках берутся из
+снимка операции, потому что собственные колонки журнала считают Фишки, а не деньги. Пагинация,
+выгрузка в CSV и возврат оператора после refund несут одну и ту же строку фильтров, поэтому ни один
+из них не может незаметно показать срез, отличный от экрана.
+
+**Кнопка возврата** живёт на строках пополнения здесь же — оператор может найти платёж фильтрами, не
+зная заранее, чей он, — и после возврата возвращает на тот же отфильтрованный вид.
+
+**Карточка пользователя** показывает положение дел, а не историю: балансы сегментов, бенефиты, флаг
+риска и краткую сводку за всё время (сколько заплачено и возвращено по валютам, сколько Фишек
+начислено и потрачено), плюс ссылку в раздел журнала, уже отфильтрованный по этому аккаунту.
+Операции рисуются в одном месте, с фильтрами и страницами, а не в двух.
## 12. Налоги и комплаенс