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 }