d2d6955cbf
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
The /_gm user card gains a Grant panel: grant raw benefit atoms (hints / no-ads days / forever) or a defined value product (a reward bundle, including an archived one), origin-picked. Both write an admin_grant ledger row via payments.Grant / GrantProduct; the by-product grant records the source product_id + snapshot. Both refuse a chips atom (never grant currency) or a tournament atom (no credit target yet); chips/tournament products are also kept out of the by-product picker. Tests: the console grant end to end (raw, by-product, refuse a chips pack, CSRF-guarded).
299 lines
11 KiB
Go
299 lines
11 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
|
|
}
|
|
|
|
// Catalog returns the storefront for the execution context: every chip-priced value (shown in
|
|
// every context) plus the chip packs priced in the context's payment method. It is read straight
|
|
// from the catalog tables — small and rarely edited, so uncached. An untrusted context has no
|
|
// method and so shows values only; buying is gate-checked on Spend regardless (fail-closed).
|
|
func (s *Service) Catalog(ctx context.Context, cxt Context) (CatalogView, error) {
|
|
entries, err := s.store.loadCatalog(ctx)
|
|
if err != nil {
|
|
return CatalogView{}, err
|
|
}
|
|
return projectCatalog(entries, cxt), 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())
|
|
}
|
|
|
|
// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
|
|
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
|
|
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
|
|
// yet), and one whose atoms yield no grantable benefit.
|
|
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
|
|
if !origin.Valid() {
|
|
return fmt.Errorf("payments: invalid grant origin %q", origin)
|
|
}
|
|
in, err := s.store.productInput(ctx, productID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var d benefitDelta
|
|
for _, a := range in.Atoms {
|
|
switch a.Atom {
|
|
case "hints":
|
|
d.hintsAdd += a.Quantity
|
|
case "noads_days":
|
|
d.noAdsDays += a.Quantity
|
|
case atomChips:
|
|
return ErrCannotGrantChips
|
|
case "tournament":
|
|
return ErrCannotGrantTournament
|
|
}
|
|
}
|
|
if d.zero() {
|
|
return ErrNothingToGrant
|
|
}
|
|
snapshot, err := marshalGrantProduct(productID, in.Title, d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.store.grantProduct(ctx, accountID, origin, productID, 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
|
|
}
|
|
|
|
// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
|
|
// benefit atoms it granted (price 0).
|
|
func marshalGrantProduct(productID uuid.UUID, title string, 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{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|