Files
scrabble-game/backend/internal/payments/statement.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

164 lines
6.0 KiB
Go

package payments
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
// Statement is an account's full financial picture for the admin console: chip balances per funding
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
// (newest first). Read straight from the materialized tables + the ledger, uncached — an admin-only,
// rare view, not a hot path.
type Statement struct {
Segments []SegmentChips
Benefits []OriginBenefit
Risk RiskInfo
Ledger []LedgerEntry
}
// SegmentChips is one funding segment's chip balance.
type SegmentChips struct {
Source Source
Chips int
}
// OriginBenefit is one origin's benefit state: the hint wallet, the ad-free term (zero AdsPaidUntil
// when none) and the lifetime ad-free flag.
type OriginBenefit struct {
Origin Source
Hints int
AdsPaidUntil time.Time
AdsForever bool
}
// RiskInfo is the account's recorded refund risk: whether it is abuse-flagged and the unrecoverable
// chip loss accumulated by floor-0 refunds. Present is false when the account has no risk row.
type RiskInfo struct {
Present bool
Abuse bool
LossChips int
}
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
// the column is NULL; Shop is the direct-rail merchant channel the referenced order used (empty for
// other rails or order-less rows); Snapshot is the raw purchase/refund JSON (empty when none).
type LedgerEntry struct {
Kind string
Source string
Origin string
ChipsDelta int
ProductID string
OrderID string
Provider string
ProviderPaymentID string
Shop string
Snapshot string
CreatedAt time.Time
}
// AccountStatement assembles the account's financial picture (segments, benefits, risk, full ledger
// history) for the admin console, straight from the materialized tables and the ledger (uncached).
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
}