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 }