1c06d1d0d1
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).
103 lines
3.2 KiB
Go
103 lines
3.2 KiB
Go
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))
|
|
}
|