feat(payments): chip wallet, store-compliance gate and benefit application
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Stand up the internal chip/benefit mechanic behind the narrow payments interface: context-aware balances and benefits, an atomic chip spend, admin grants as zero-price value sales, the one-directional store-compliance gate (VK/TG same- origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and per-origin hint and no-ads application with term stacking. Reads are served from an in-process, account-keyed write-through cache (mirroring the suspension gate), so hot paths issue no query to the payments schema. Flip the online-game hint wallet and the ad-banner suppression from the deprecated accounts.hint_balance / paid_account columns to the payments benefit (a hint balance no longer suppresses the banner — only a no-ads benefit does), and fold chip segments and benefits by origin on account merge, inside the merge tx. Add the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus its codec unit test; no wallet UI yet. Bring the frozen owner decisions log into the repo at docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it from PLAN.md; record the read-cache design and the present-sources interface in PLAN.md and docs/PAYMENTS.md (+ RU mirror).
This commit is contained in:
@@ -1,17 +1,234 @@
|
||||
package payments
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// Service is the payments domain's application layer — the narrow surface other
|
||||
// domains depend on, keeping the schema reachable through one seam. The
|
||||
// data-foundation layer exposes only a health check; the wallet, benefit and
|
||||
// store-compliance operations arrive with the currency mechanics.
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Service is the payments domain's application layer — the narrow surface other domains depend
|
||||
// on, keeping the schema reachable through one seam. Every read/gate method takes the trusted
|
||||
// execution Context and the account's present identity sources (which segments are awake, §6);
|
||||
// payments holds no cross-schema identity knowledge, so the caller supplies present. Reads are
|
||||
// served from the store's in-process cache, so the steady-state hot path issues no query to the
|
||||
// payments schema.
|
||||
type Service struct {
|
||||
store *Store
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewService constructs a Service over store.
|
||||
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||
// NewService constructs a Service over store with a wall-clock time source.
|
||||
func NewService(store *Store) *Service {
|
||||
return &Service{store: store, clock: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// Ping reports whether the payments schema is reachable.
|
||||
func (s *Service) Ping(ctx context.Context) error { return s.store.Ping(ctx) }
|
||||
|
||||
// Wallet returns the read model for the account in the execution context: the segments visible
|
||||
// there (each with its chip count and whether it is spendable) plus the context-applicable
|
||||
// benefits. In a store context only the same-named segment is shown; on web/native or an
|
||||
// untrusted platform all attached segments are shown, spendable only when the gate allows.
|
||||
func (s *Service) Wallet(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (WalletView, error) {
|
||||
st, err := s.store.state(ctx, accountID)
|
||||
if err != nil {
|
||||
return WalletView{}, err
|
||||
}
|
||||
now := s.clock()
|
||||
view := WalletView{Benefits: benefitView(st, cxt, present, now)}
|
||||
spendable := spendableSources(cxt, present)
|
||||
for _, src := range visibleSources(cxt) {
|
||||
if !has(present, src) {
|
||||
continue // only the account's own (attached) segments are shown
|
||||
}
|
||||
view.Segments = append(view.Segments, Segment{
|
||||
Source: src,
|
||||
Chips: st.chipsOf(src),
|
||||
Spendable: has(spendable, src),
|
||||
})
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// AdFree reports whether ads are suppressed for the account in the context: some origin
|
||||
// applicable there has an active no-ads term or the forever flag. Fail-closed on an untrusted
|
||||
// platform (no origin applies).
|
||||
func (s *Service) AdFree(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (bool, error) {
|
||||
st, err := s.store.state(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
now := s.clock()
|
||||
for _, o := range applicableOrigins(cxt, present) {
|
||||
b := st.benefitOf(o)
|
||||
if b.adsForever || (b.adsPaidUntil != nil && b.adsPaidUntil.After(now)) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// HintsAvailable returns how many hints the account can use in the context — the sum over the
|
||||
// applicable origins. Zero on an untrusted platform.
|
||||
func (s *Service) HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (int, error) {
|
||||
st, err := s.store.state(ctx, accountID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := 0
|
||||
for _, o := range applicableOrigins(cxt, present) {
|
||||
total += st.benefitOf(o).hints
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// SpendHint consumes one hint from the first applicable origin that has one (priority
|
||||
// direct→vk→tg), returning whether a hint was spent. It spends nothing when no origin is
|
||||
// applicable (untrusted platform or no attached segment).
|
||||
func (s *Service) SpendHint(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (bool, error) {
|
||||
origins := applicableOrigins(cxt, present)
|
||||
if len(origins) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.store.consumeHint(ctx, accountID, origins, s.clock())
|
||||
}
|
||||
|
||||
// Spend buys a chip-priced value: it gate-checks the context, draws the price across the
|
||||
// spendable segments by priority direct→vk→tg, and applies the benefit — atomically. The
|
||||
// benefit's origin is the purchase context. It fails closed on an untrusted or frozen platform
|
||||
// (ErrUntrusted) and on insufficient chips (ErrInsufficientChips).
|
||||
func (s *Service) Spend(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID) error {
|
||||
spendable := spendableSources(cxt, present)
|
||||
if len(spendable) == 0 {
|
||||
return ErrUntrusted // untrusted, frozen, or no attached segment — no spend
|
||||
}
|
||||
prod, err := s.store.loadProduct(ctx, productID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.store.state(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draws, ok := planDraws(st, spendable, prod.priceChips)
|
||||
if !ok {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
snapshot, err := marshalSnapshot(prod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.spend(ctx, accountID, draws, cxt.Kind, productID, prod.delta, snapshot, s.clock())
|
||||
}
|
||||
|
||||
// Grant applies a benefit to an origin as a zero-price sale — an admin_grant ledger row and the
|
||||
// benefit (hints, a no-ads term in whole days, or the forever flag). It never grants chips (no
|
||||
// balance is touched — D16), so its signature has no chip amount. The origin is the admin's
|
||||
// compliance choice.
|
||||
func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source, hints, noAdsDays int, forever bool) error {
|
||||
if !origin.Valid() {
|
||||
return fmt.Errorf("payments: invalid grant origin %q", origin)
|
||||
}
|
||||
d := benefitDelta{hintsAdd: hints, noAdsDays: noAdsDays, forever: forever}
|
||||
if d.zero() {
|
||||
return fmt.Errorf("payments: empty grant")
|
||||
}
|
||||
snapshot, err := marshalGrant(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
|
||||
}
|
||||
|
||||
// MergeTx merges the secondary account's segments and benefits into the primary inside the
|
||||
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
||||
// after committing (Invalidate).
|
||||
func (s *Service) MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
return s.store.MergeTx(ctx, tx, primary, secondary, s.clock())
|
||||
}
|
||||
|
||||
// Invalidate drops the cached state of the listed accounts (called after a merge commit).
|
||||
func (s *Service) Invalidate(ids ...uuid.UUID) { s.store.Invalidate(ids...) }
|
||||
|
||||
// benefitView aggregates the benefits applicable in the context into the wallet view: ads-off
|
||||
// forever if any applicable origin is perpetual, else the latest active term end, plus the total
|
||||
// available hints.
|
||||
func benefitView(st walletState, cxt Context, present []Source, now time.Time) BenefitView {
|
||||
var v BenefitView
|
||||
for _, o := range applicableOrigins(cxt, present) {
|
||||
b := st.benefitOf(o)
|
||||
if b.adsForever {
|
||||
v.AdsForever = true
|
||||
}
|
||||
if b.adsPaidUntil != nil && b.adsPaidUntil.After(now) {
|
||||
if v.AdsPaidUntil == nil || b.adsPaidUntil.After(*v.AdsPaidUntil) {
|
||||
v.AdsPaidUntil = b.adsPaidUntil
|
||||
}
|
||||
}
|
||||
v.Hints += b.hints
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// planDraws greedily allocates price across the spendable segments in priority order, draining
|
||||
// each before moving on. It returns the per-segment draws and whether the segments together held
|
||||
// enough.
|
||||
func planDraws(st walletState, spendable []Source, price int) ([]sourceAmount, bool) {
|
||||
remaining := price
|
||||
var draws []sourceAmount
|
||||
for _, src := range spendable {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
avail := st.chipsOf(src)
|
||||
if avail <= 0 {
|
||||
continue
|
||||
}
|
||||
take := min(avail, remaining)
|
||||
draws = append(draws, sourceAmount{source: src, amount: take})
|
||||
remaining -= take
|
||||
}
|
||||
if remaining > 0 {
|
||||
return nil, false
|
||||
}
|
||||
return draws, true
|
||||
}
|
||||
|
||||
// purchaseSnapshot is the catalog snapshot stored on a spend/grant ledger row, so history and
|
||||
// receipts stay independent of later catalog edits (§7/D34).
|
||||
type purchaseSnapshot struct {
|
||||
ProductID string `json:"product_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Atoms map[string]int `json:"atoms,omitempty"`
|
||||
PriceChips int `json:"price_chips"`
|
||||
Forever bool `json:"forever,omitempty"`
|
||||
}
|
||||
|
||||
// marshalSnapshot builds the snapshot for a chip spend.
|
||||
func marshalSnapshot(p catalogProduct) ([]byte, error) {
|
||||
b, err := json.Marshal(purchaseSnapshot{ProductID: p.id.String(), Title: p.title, Atoms: p.atoms, PriceChips: p.priceChips})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal snapshot: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// marshalGrant builds the snapshot for an admin grant (price 0).
|
||||
func marshalGrant(d benefitDelta) ([]byte, error) {
|
||||
atoms := map[string]int{}
|
||||
if d.hintsAdd > 0 {
|
||||
atoms["hints"] = d.hintsAdd
|
||||
}
|
||||
if d.noAdsDays > 0 {
|
||||
atoms["noads_days"] = d.noAdsDays
|
||||
}
|
||||
b, err := json.Marshal(purchaseSnapshot{Atoms: atoms, PriceChips: 0, Forever: d.forever})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal grant snapshot: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user