Files
scrabble-game/backend/internal/payments/store_ledger.go
T
Ilia Denisov 3b744b7d2f
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
feat(admin): an all-accounts ledger section with filters and totals
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.
2026-07-28 12:43:28 +02:00

180 lines
6.4 KiB
Go

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, &currency, &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
}