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
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user