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:
@@ -18,6 +18,8 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// Service is the game domain: it drives the engine over a single match, persists
|
||||
@@ -50,6 +52,11 @@ type Service struct {
|
||||
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
|
||||
// these games). Kept as a func so the game package never imports the robot package.
|
||||
aiTrigger func(gameID uuid.UUID)
|
||||
// hintWallet is the payments surface the online-game hint path spends against (the
|
||||
// segmented, context-aware hint balance). It is set by SetHintWallet during wiring; when
|
||||
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
|
||||
// wallet-free and never touch it.
|
||||
hintWallet HintWallet
|
||||
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||
// best-effort and kept as a func so the game package never imports the social package.
|
||||
@@ -105,6 +112,39 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
|
||||
svc.aiTrigger = trigger
|
||||
}
|
||||
|
||||
// HintWallet is the payments surface the online-game hint path uses: the context-aware hint
|
||||
// balance (HintsAvailable) and a one-hint spend (SpendHint), both keyed by the trusted execution
|
||||
// context and the account's present identity sources. *payments.Service satisfies it.
|
||||
type HintWallet interface {
|
||||
HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (int, error)
|
||||
SpendHint(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (bool, error)
|
||||
}
|
||||
|
||||
// SetHintWallet installs the payments hint wallet the online-game hint path spends against. It
|
||||
// must be called during startup wiring; the default (nil) serves only the free per-game allowance.
|
||||
func (svc *Service) SetHintWallet(w HintWallet) {
|
||||
svc.hintWallet = w
|
||||
}
|
||||
|
||||
// walletContext resolves the payments gate inputs for an account on the current request: the
|
||||
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
|
||||
// the account's present identity sources (which chip/benefit segments are awake, §6).
|
||||
func (svc *Service) walletContext(ctx context.Context, accountID uuid.UUID) (payments.Context, []payments.Source, error) {
|
||||
var cxt payments.Context
|
||||
if p, ok := session.PlatformFromContext(ctx); ok {
|
||||
cxt = payments.NewContext(p.Kind, p.Subtype)
|
||||
}
|
||||
ids, err := svc.accounts.Identities(ctx, accountID)
|
||||
if err != nil {
|
||||
return payments.Context{}, nil, err
|
||||
}
|
||||
kinds := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
kinds[i] = id.Kind
|
||||
}
|
||||
return cxt, payments.PresentSources(kinds), nil
|
||||
}
|
||||
|
||||
// SetNudgeClearer installs the hook that marks a mover's pending nudges read after
|
||||
// their move commits. It must be called during startup wiring; the default (nil)
|
||||
// leaves nudges to be cleared only when the recipient opens the move history or chat.
|
||||
@@ -1121,13 +1161,20 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
}
|
||||
return HintResult{Move: move}, nil
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
cxt, present, err := svc.walletContext(ctx, accountID)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
wallet := 0
|
||||
if svc.hintWallet != nil {
|
||||
wallet, err = svc.hintWallet.HintsAvailable(ctx, accountID, cxt, present)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
}
|
||||
used := pre.Seats[seat].HintsUsed
|
||||
fromAllowance := used < pre.HintsPerPlayer
|
||||
if !fromAllowance && acc.HintBalance <= 0 {
|
||||
if !fromAllowance && wallet <= 0 {
|
||||
return HintResult{}, ErrNoHintsLeft
|
||||
}
|
||||
|
||||
@@ -1142,9 +1189,9 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
return HintResult{}, ErrNoHintAvailable
|
||||
}
|
||||
|
||||
walletAfter := acc.HintBalance
|
||||
walletAfter := wallet
|
||||
if !fromAllowance {
|
||||
spent, err := svc.accounts.SpendHint(ctx, accountID)
|
||||
spent, err := svc.hintWallet.SpendHint(ctx, accountID, cxt, present)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user