1c06d1d0d1
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).
235 lines
8.4 KiB
Go
235 lines
8.4 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"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 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
|
|
}
|