Files
scrabble-game/backend/internal/server/handlers_wallet.go
T
Ilia Denisov dbd76d53e8
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored
by the caps; payout from config rewarded_payout_chips, default 0 = off) + the
wallet.reward edge op returning the updated wallet (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
2026-07-10 00:41:42 +02:00

239 lines
8.2 KiB
Go

package server
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"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).
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
// only when it is positive.
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"`
RewardChips int `json:"reward_chips"`
}
// 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
}
// catalogAtomDTO is one atom line of a storefront product: the base value type it grants and how
// many of it the product carries.
type catalogAtomDTO struct {
AtomType string `json:"atom_type"`
Quantity int `json:"quantity"`
}
// catalogProductDTO is one storefront product for the caller's context: a chip-priced value
// (chips set, no money price) or a chip pack priced in the context's payment method
// (money_amount minor units + money_currency, no chips), plus what it grants.
type catalogProductDTO struct {
Kind string `json:"kind"`
ProductID string `json:"product_id"`
Title string `json:"title"`
Chips int `json:"chips"`
MoneyAmount int64 `json:"money_amount"`
MoneyCurrency string `json:"money_currency"`
Atoms []catalogAtomDTO `json:"atoms"`
}
// catalogDTO is the storefront: the products visible and purchasable in the caller's context.
type catalogDTO struct {
Products []catalogProductDTO `json:"products"`
}
// catalogDTOFrom projects a payments catalog view into the wire DTO.
func catalogDTOFrom(v payments.CatalogView) catalogDTO {
out := catalogDTO{Products: make([]catalogProductDTO, 0, len(v.Products))}
for _, p := range v.Products {
atoms := make([]catalogAtomDTO, 0, len(p.Atoms))
for _, a := range p.Atoms {
atoms = append(atoms, catalogAtomDTO{AtomType: a.AtomType, Quantity: a.Quantity})
}
out.Products = append(out.Products, catalogProductDTO{
Kind: p.Kind, ProductID: p.ProductID, Title: p.Title,
Chips: p.Chips, MoneyAmount: p.MoneyAmount, MoneyCurrency: p.MoneyCurrency, Atoms: atoms,
})
}
return out
}
// handleWalletCatalog returns the storefront for the caller's trusted execution context: the
// chip-priced values and the chip packs priced in the context's payment method.
func (s *Server) handleWalletCatalog(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
ctx := c.Request.Context()
cxt, _, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
view, err := s.payments.Catalog(ctx, cxt)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, catalogDTOFrom(view))
}
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
// unconfigured), which gates the client's "watch for chips" button.
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
}
dto := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
} else {
dto.RewardChips = payout
}
c.JSON(http.StatusOK, dto)
}
// 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))
}
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce (the idempotency
// key for a single watched view — a retry credits once) and Diag, the raw VK ad result forwarded for
// a temporary diagnostic log while we confirm exactly what VK returns on the contour.
type walletRewardRequest struct {
Nonce string `json:"nonce"`
Diag string `json:"diag"`
}
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
// updated wallet (like a spend).
func (s *Server) handleWalletReward(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletRewardRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
abortBadRequest(c, "nonce is required")
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
// Temporary diagnostic: log the raw VK ad result (bounded — untrusted client input) to learn its
// exact shape on the contour; if it carries a verifiable token we harden past client-attested.
if req.Diag != "" {
diag := req.Diag
if len(diag) > 2000 {
diag = diag[:2000]
}
s.log.Info("rewarded ad diagnostic", zap.String("account", uid.String()), zap.String("vk_data", diag))
}
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
if err != nil {
s.abortErr(c, err)
return
}
if outcome.Capped {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
return
}
if outcome.Chips == 0 && !outcome.AlreadyCredited {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
view2 := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
view2.RewardChips = payout
}
c.JSON(http.StatusOK, view2)
}