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