feat(admin): an all-accounts ledger section with filters and totals
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s

There was no way to see the money as a whole: the ledger was only ever rendered
inside one account's card, so "what came in last month" meant exporting the
entire CSV and reading it elsewhere.

/_gm/ledger lists every operation, newest first, filtered by date range
(defaulting to the last 30 days), by wallet, by rail, by kind and by account.
Wallet and rail are deliberately separate axes because they answer different
questions — "what happened on VK" matches the funded segment or the benefit
origin, while "what came through YooKassa" matches the settling provider, and a
chip spend has no rail at all, so it drops out of that filter by construction.

Above the table sit the totals for everything the filter matches, not merely the
page on screen, which is the point of showing them. Money is listed per currency
because the rails settle in roubles, Votes and Stars and one sum across them
would mean nothing. Row amounts come from the operation snapshot, since the
ledger's own columns count chips rather than money.

Paging, the CSV export and a refund's way back are all built from the same
encoded filter, so none of them can quietly show a different slice than the
screen. The export moves here from the user card and gains the filter along with
money columns; it is capped, because an append-only ledger grows forever and an
unbounded export would eventually time out.

The refund action moves onto the funded rows here — an operator can now find a
payment by filter without knowing whose it is first — and returns to the same
filtered view. The destination travels with the form but is only honoured when
it is a console path, so a crafted form cannot turn it into an open redirect.

The user card keeps the account's standing rather than its history: balances,
benefits, the risk flag and a lifetime summary (money paid and refunded per
currency, chips credited and spent), plus a link into the ledger scoped to that
account. Operations are rendered in one place, with filters and paging, instead
of two.

Tests: the filter axes, paging that neither repeats nor drops a row, totals that
describe the range rather than the page, money recovered from a snapshot and
absent on a spend, and the console page, the export and the card hand-off. The
existing finance-panel test now asserts the summary and that the card no longer
re-renders the rows.
This commit is contained in:
Ilia Denisov
2026-07-28 12:43:28 +02:00
parent e3961fe4ca
commit 3b744b7d2f
13 changed files with 998 additions and 80 deletions
@@ -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 {
@@ -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 <input type="date"> 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()
}
@@ -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()
}