diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 6a21d6d..a6fb598 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -23,6 +23,7 @@ Reasons Banners Catalog + Ledger Dictionary Broadcast Grafana ↗ diff --git a/backend/internal/adminconsole/templates/pages/ledger.gohtml b/backend/internal/adminconsole/templates/pages/ledger.gohtml new file mode 100644 index 0000000..5dcb79b --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/ledger.gohtml @@ -0,0 +1,64 @@ +{{define "content" -}} +

Ledger

+{{with .Data}} +
+ + + + + + + +Export CSV ↓ +
+

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

+ +
+

Totals for the filtered range

+

+Money in: {{range .Totals.MoneyIn}}{{.}} {{else}}none{{end}} +· Refunded: {{range .Totals.MoneyRefunded}}{{.}} {{else}}none{{end}} +· Chips credited: {{.Totals.ChipsIn}} +· Chips spent: {{.Totals.ChipsOut}} +

+
+ + + + +{{range .Rows}} + + + + + + + + + + + + +{{else}} + +{{end}} + +
TimeAccountKindMoneyChipsWhatWalletRailShop
{{.At}}card{{.Kind}}{{.Money}}{{.ChipsDelta}}{{.Title}}{{if .Order}} {{.Order}}{{end}}{{.Source}}{{if and .Origin (ne .Origin .Source)}} → {{.Origin}}{{end}}{{.Provider}}{{.Shop}}{{if .Refundable}}
{{end}}
no operations in this range
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 30bc5b3..531d913 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -82,20 +82,14 @@
-

Ledger

-{{$uid := .ID}} -{{if .Finance.Ledger}} - - - -{{range .Finance.Ledger}} - - -{{end}} - -
TimeKindSourceOriginChipsOrderProviderShopDetail
{{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{.Shop}}{{if .Snapshot}}{{.Snapshot}}{{end}}{{if and (eq .Kind "fund") .Order}}
{{end}}
-

Export the full ledger (CSV) — all accounts, for tax + reconciliation.

-{{else}}

no ledger entries

{{end}} +

Totals

+

+Paid: {{range .Finance.Paid}}{{.}} {{else}}nothing{{end}} +· Refunded: {{range .Finance.Refunded}}{{.}} {{else}}nothing{{end}} +· Chips credited: {{.Finance.ChipsBought}} +· Chips spent: {{.Finance.ChipsSpent}} +

+

Open this account's operations in the ledger → — every entry, with filters, paging and the refund action.

{{else}}

payments not enabled

{{end}}

Grant benefits

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. Налоги и комплаенс