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:
@@ -10,6 +10,7 @@ import (
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// bannerDTO is the advertising-banner block attached to an eligible viewer's
|
||||
@@ -55,7 +56,21 @@ type bannerTimingsDTO struct {
|
||||
// a language switch).
|
||||
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
||||
r := profileResponseFor(acc)
|
||||
r.Banner = s.bannerFor(ctx, acc)
|
||||
// Resolve the payments gate once (execution context + present sources) and feed it to both
|
||||
// the hint count and the banner. The profile hint balance now comes from the payments benefit
|
||||
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy
|
||||
// value from profileResponseFor (zeroed in production) stands.
|
||||
cxt, present, err := s.walletGate(ctx, acc.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
} else if s.payments != nil {
|
||||
if hints, herr := s.payments.HintsAvailable(ctx, acc.ID, cxt, present); herr == nil {
|
||||
r.HintBalance = hints
|
||||
} else {
|
||||
s.log.Warn("profile: hint balance read failed", zap.String("account", acc.ID.String()), zap.Error(herr))
|
||||
}
|
||||
}
|
||||
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||
r.DictVersions = s.currentDictVersions()
|
||||
return r
|
||||
@@ -115,7 +130,7 @@ func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, a
|
||||
// language, falling back to its interface language and then English. A failure
|
||||
// reading roles or campaigns is logged and treated as "no banner" so the profile
|
||||
// response still succeeds.
|
||||
func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO {
|
||||
func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *bannerDTO {
|
||||
if s.ads == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -124,16 +139,24 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
|
||||
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
|
||||
// hides the banner).
|
||||
// An urgent campaign is shown to every viewer; otherwise the banner is suppressed by the
|
||||
// no_banner role or by an active no-ads benefit applicable in the viewer's context (the
|
||||
// payments gate — a hint balance no longer suppresses the banner). An untrusted platform is
|
||||
// fail-closed by the gate, so it does not suppress the banner.
|
||||
if !urgent {
|
||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
||||
if err != nil {
|
||||
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
||||
adFree := false
|
||||
if s.payments != nil {
|
||||
if adFree, err = s.payments.AdFree(ctx, acc.ID, cxt, present); err != nil {
|
||||
s.log.Warn("banner: ad-free check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if hasNoBanner || adFree {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"scrabble/backend/internal/feedback"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
@@ -65,6 +66,11 @@ func (s *Server) registerRoutes() {
|
||||
// client may still reach, to fetch the block's expiry and reason for the blocked screen.
|
||||
u.GET("/block-status", s.handleBlockStatus)
|
||||
}
|
||||
if s.payments != nil {
|
||||
// The wallet: the context-visible chip segments + benefits, and a chip spend on a value.
|
||||
u.GET("/wallet", s.handleWallet)
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
}
|
||||
if s.links != nil {
|
||||
// Account linking & merge. The request step always mails a code;
|
||||
// a required merge is revealed only after the code is verified, and the
|
||||
@@ -250,6 +256,14 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "last_identity"
|
||||
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
||||
return http.StatusConflict, "merge_active_game_conflict"
|
||||
case errors.Is(err, payments.ErrUntrusted):
|
||||
return http.StatusForbidden, "payments_untrusted"
|
||||
case errors.Is(err, payments.ErrInsufficientChips):
|
||||
return http.StatusConflict, "insufficient_chips"
|
||||
case errors.Is(err, payments.ErrProductNotFound):
|
||||
return http.StatusNotFound, "product_not_found"
|
||||
case errors.Is(err, payments.ErrNotAValue):
|
||||
return http.StatusBadRequest, "not_a_value"
|
||||
case errors.Is(err, account.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "invalid_email"
|
||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// walletSegmentDTO is one chip balance in the wallet view: the funding source, the chip count,
|
||||
// and whether it can be spent in the current execution context (false for a frozen VK-iOS
|
||||
// balance or an untrusted platform).
|
||||
type walletSegmentDTO struct {
|
||||
Source string `json:"source"`
|
||||
Chips int `json:"chips"`
|
||||
Spendable bool `json:"spendable"`
|
||||
}
|
||||
|
||||
// walletDTO is the user-facing wallet: the context-visible chip segments and the
|
||||
// context-applicable benefits (the no-ads term or forever flag, and the available hints).
|
||||
type walletDTO struct {
|
||||
Segments []walletSegmentDTO `json:"segments"`
|
||||
AdsForever bool `json:"ads_forever"`
|
||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
|
||||
Hints int `json:"hints"`
|
||||
}
|
||||
|
||||
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
|
||||
type walletBuyRequest struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// walletDTOFrom projects a payments wallet view into the wire DTO.
|
||||
func walletDTOFrom(v payments.WalletView) walletDTO {
|
||||
out := walletDTO{AdsForever: v.Benefits.AdsForever, Hints: v.Benefits.Hints}
|
||||
if v.Benefits.AdsPaidUntil != nil {
|
||||
out.AdsPaidUntil = v.Benefits.AdsPaidUntil.UnixMilli()
|
||||
}
|
||||
out.Segments = make([]walletSegmentDTO, 0, len(v.Segments))
|
||||
for _, seg := range v.Segments {
|
||||
out.Segments = append(out.Segments, walletSegmentDTO{Source: string(seg.Source), Chips: seg.Chips, Spendable: seg.Spendable})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
||||
// trusted execution context.
|
||||
func (s *Server) handleWallet(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cxt, present, err := s.walletGate(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
view, err := s.payments.Wallet(ctx, uid, cxt, present)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
}
|
||||
|
||||
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
|
||||
// fail-closed: an untrusted or frozen context, or an insufficient balance, is refused.
|
||||
func (s *Server) handleWalletBuy(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req walletBuyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
|
||||
return
|
||||
}
|
||||
productID, err := uuid.Parse(req.ProductID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cxt, present, err := s.walletGate(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.payments.Spend(ctx, uid, cxt, present, productID); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
view, err := s.payments.Wallet(ctx, uid, cxt, present)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// walletGate resolves the payments gate inputs for an account on the current request: the
|
||||
// trusted execution context (the session platform carried on ctx by the platformContext
|
||||
// middleware; absent ⇒ untrusted, fail-closed) and the account's present identity sources
|
||||
// (which chip/benefit segments are awake, §6). The payments domain holds no cross-schema
|
||||
// identity knowledge, so the server supplies present from account.Identities.
|
||||
func (s *Server) walletGate(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 := s.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
|
||||
}
|
||||
Reference in New Issue
Block a user