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 } // 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. 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)) }