feat(payments): wallet screen with balances, benefits and storefront
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add the "Кошелёк" section to the settings hub: context-visible chip balances, active benefits (no-ads term/forever, hints) and a storefront of chip-priced values and money-priced chip packs. Guests have no wallet; the Google Play build hides the money purchases behind a RuStore stub; a web purchase that would draw VK/Telegram chips warns first. Add the catalog read path the storefront needs — a context-projected GET /api/v1/user/wallet/catalog (payments service + store, gateway op wallet.catalog, FBS Catalog/CatalogProduct/CatalogAtom, client decode) — plus the client leg for the existing wallet.get/buy ops. Value spends reuse the existing spend path; the chip-pack purchase (money order flow) arrives with payment intake, so its action is a disabled placeholder for now. Covered by Go unit (catalog projection) + integration (/wallet/catalog over Postgres), vitest (formatting, spendable selection, web-spend warning, GP flag, codec + gateway encode round-trips) and Playwright mock e2e (render, guest-hidden, GP stub, warning) on Chromium + WebKit.
This commit is contained in:
+4
-1
@@ -100,7 +100,10 @@ state, lobby enqueue, chat). The social/account/history operations under
|
||||
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
||||
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
|
||||
`stats`, `games/:id/gcg` (finished-only), and the payments `wallet` /
|
||||
`wallet/catalog` (`GET` — the context-visible chip segments + benefits, and the
|
||||
storefront catalog) / `wallet/buy` (`POST` — a chip spend on a value), served by
|
||||
`internal/payments` behind the store-compliance gate. The `internal/notify` hub feeds a
|
||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// methodPrice is one per-method money price for a seeded chip pack.
|
||||
type methodPrice struct {
|
||||
method string
|
||||
currency string
|
||||
amount int64
|
||||
}
|
||||
|
||||
// seedPackProduct creates an active chip pack: a product carrying the chips atom and a money price
|
||||
// per method. It returns the product id.
|
||||
func seedPackProduct(t *testing.T, chips int, prices ...methodPrice) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
prod := uuid.New()
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO payments.product (product_id, title, active) VALUES ($1,'test pack',true)`, prod); err != nil {
|
||||
t.Fatalf("seed pack product: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'chips',$2)`, prod, chips); err != nil {
|
||||
t.Fatalf("seed chips item: %v", err)
|
||||
}
|
||||
for _, p := range prices {
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
|
||||
prod, p.method, p.currency, p.amount); err != nil {
|
||||
t.Fatalf("seed pack price %s: %v", p.method, err)
|
||||
}
|
||||
}
|
||||
return prod
|
||||
}
|
||||
|
||||
// findCatalogProduct returns the projected storefront product for id in the context (the catalog
|
||||
// is global, so tests assert by id rather than count — testDB is shared).
|
||||
func findCatalogProduct(t *testing.T, svc *payments.Service, cxt payments.Context, id uuid.UUID) (payments.CatalogProduct, bool) {
|
||||
t.Helper()
|
||||
view, err := svc.Catalog(context.Background(), cxt)
|
||||
if err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
for _, p := range view.Products {
|
||||
if p.ProductID == id.String() {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return payments.CatalogProduct{}, false
|
||||
}
|
||||
|
||||
// TestPaymentsCatalogByContext verifies the storefront projects a value at its chip price in every
|
||||
// context and a chip pack at the context method's money price, over Postgres.
|
||||
func TestPaymentsCatalogByContext(t *testing.T) {
|
||||
svc := newPaymentsService()
|
||||
value := seedValueProduct(t, 500, 250, 0)
|
||||
pack := seedPackProduct(t, 100,
|
||||
methodPrice{"vk", "VOTE", 20},
|
||||
methodPrice{"telegram", "XTR", 25},
|
||||
methodPrice{"direct", "RUB", 14900},
|
||||
)
|
||||
|
||||
for _, kind := range []string{"vk", "telegram", "direct"} {
|
||||
v, ok := findCatalogProduct(t, svc, payments.NewContext(kind, "web"), value)
|
||||
if !ok {
|
||||
t.Fatalf("value missing in %s context", kind)
|
||||
}
|
||||
if v.Kind != "value" || v.Chips != 500 || v.MoneyCurrency != "" {
|
||||
t.Errorf("value in %s = %+v, want kind=value chips=500 no money", kind, v)
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
kind string
|
||||
amount int64
|
||||
currency string
|
||||
}{
|
||||
{"vk", 20, "VOTE"},
|
||||
{"telegram", 25, "XTR"},
|
||||
{"direct", 14900, "RUB"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
p, ok := findCatalogProduct(t, svc, payments.NewContext(tc.kind, "web"), pack)
|
||||
if !ok {
|
||||
t.Fatalf("pack missing in %s context", tc.kind)
|
||||
}
|
||||
if p.Kind != "pack" || p.Chips != 0 || p.MoneyAmount != tc.amount || p.MoneyCurrency != tc.currency {
|
||||
t.Errorf("pack in %s = %+v, want kind=pack amount=%d currency=%s", tc.kind, p, tc.amount, tc.currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsCatalogExcludesDeactivated verifies a soft-deleted product drops out of the storefront.
|
||||
func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
|
||||
svc := newPaymentsService()
|
||||
prod := seedValueProduct(t, 100, 10, 0)
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.product SET active=false WHERE product_id=$1`, prod); err != nil {
|
||||
t.Fatalf("deactivate: %v", err)
|
||||
}
|
||||
if _, ok := findCatalogProduct(t, svc, payments.NewContext("direct", "web"), prod); ok {
|
||||
t.Error("deactivated product must not appear in the storefront")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package payments
|
||||
|
||||
// AtomQty is one atom line of a catalog product in the read model: the base value type it grants
|
||||
// (chips / hints / no-ads days / tournament) and how many of it the product carries.
|
||||
type AtomQty struct {
|
||||
AtomType string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// CatalogProduct is one storefront product projected for the execution context. A value is
|
||||
// bought with chips, so it carries Chips (its uniform chip price) and no money price. A chip pack
|
||||
// funds chips with money, so it carries MoneyAmount (minor units) and MoneyCurrency for the
|
||||
// context's payment method and no chip price. Atoms lists what the product grants.
|
||||
type CatalogProduct struct {
|
||||
Kind string // "value" (bought with chips) or "pack" (funds chips with money)
|
||||
ProductID string
|
||||
Title string
|
||||
Chips int // a value's uniform chip price; 0 for a pack
|
||||
MoneyAmount int64 // a pack's price in the context currency's minor units; 0 for a value
|
||||
MoneyCurrency string // a pack's currency for the context method; empty for a value
|
||||
Atoms []AtomQty
|
||||
}
|
||||
|
||||
// CatalogView is the storefront read model for one execution context: the products a player sees
|
||||
// and can buy there — every chip-priced value plus the chip packs priced in the context's method.
|
||||
type CatalogView struct {
|
||||
Products []CatalogProduct
|
||||
}
|
||||
|
||||
// atomChips is the atom type that marks a product as a chip pack (it funds chips) rather than a
|
||||
// value (bought with chips). A value never carries it.
|
||||
const atomChips = "chips"
|
||||
|
||||
// projectCatalog turns the raw active catalog into the storefront for the context. A value (no
|
||||
// chips atom) is shown everywhere at its CHIP price. A chip pack is shown only when it carries a
|
||||
// price for the context's payment method (cxt.Kind: vk / telegram / direct), priced in that
|
||||
// method's currency. A value missing its CHIP price and a pack missing a price for the context
|
||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||
// no method, so only values show; buying is gated server-side regardless.
|
||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||
var out CatalogView
|
||||
for _, e := range entries {
|
||||
isPack := false
|
||||
atoms := make([]AtomQty, 0, len(e.atoms))
|
||||
for _, a := range e.atoms {
|
||||
atoms = append(atoms, AtomQty{AtomType: a.atomType, Quantity: a.quantity})
|
||||
if a.atomType == atomChips {
|
||||
isPack = true
|
||||
}
|
||||
}
|
||||
p := CatalogProduct{ProductID: e.id.String(), Title: e.title, Atoms: atoms}
|
||||
if isPack {
|
||||
pr, ok := findPrice(e.prices, string(cxt.Kind))
|
||||
if !ok {
|
||||
continue // no price for this context's method — not purchasable here
|
||||
}
|
||||
p.Kind = "pack"
|
||||
p.MoneyAmount = pr.amount
|
||||
p.MoneyCurrency = string(pr.currency)
|
||||
} else {
|
||||
pr, ok := findPrice(e.prices, "")
|
||||
if !ok {
|
||||
continue // a value must carry a CHIP price
|
||||
}
|
||||
p.Kind = "value"
|
||||
p.Chips = int(pr.amount)
|
||||
}
|
||||
out.Products = append(out.Products, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findPrice returns the price row for the given payment method — the empty string selects a
|
||||
// value's CHIP price row (stored with a NULL method).
|
||||
func findPrice(prices []priceRow, method string) (priceRow, bool) {
|
||||
for _, pr := range prices {
|
||||
if pr.method == method {
|
||||
return pr, true
|
||||
}
|
||||
}
|
||||
return priceRow{}, false
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// catalog fixtures: a chip-priced value (hints), a multi-method chip pack, a VK-only chip pack, a
|
||||
// value with no CHIP price (misconfigured), and a pack whose only method is telegram.
|
||||
func fixtureCatalog() []catalogEntry {
|
||||
value := catalogEntry{
|
||||
id: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
|
||||
title: "250 hints",
|
||||
atoms: []atomQty{{atomType: "hints", quantity: 250}},
|
||||
prices: []priceRow{
|
||||
{method: "", currency: CurrencyChip, amount: 500},
|
||||
},
|
||||
}
|
||||
pack := catalogEntry{
|
||||
id: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
|
||||
title: "100 chips",
|
||||
atoms: []atomQty{{atomType: "chips", quantity: 100}},
|
||||
prices: []priceRow{
|
||||
{method: "vk", currency: CurrencyVote, amount: 20},
|
||||
{method: "telegram", currency: CurrencyStar, amount: 25},
|
||||
{method: "direct", currency: CurrencyRUB, amount: 14900},
|
||||
},
|
||||
}
|
||||
vkOnlyPack := catalogEntry{
|
||||
id: uuid.MustParse("00000000-0000-0000-0000-000000000003"),
|
||||
title: "VK-only chips",
|
||||
atoms: []atomQty{{atomType: "chips", quantity: 50}},
|
||||
prices: []priceRow{
|
||||
{method: "vk", currency: CurrencyVote, amount: 10},
|
||||
},
|
||||
}
|
||||
brokenValue := catalogEntry{
|
||||
id: uuid.MustParse("00000000-0000-0000-0000-000000000004"),
|
||||
title: "no chip price",
|
||||
atoms: []atomQty{{atomType: "noads_days", quantity: 30}},
|
||||
// no CHIP price row — misconfigured, must be omitted
|
||||
}
|
||||
return []catalogEntry{value, pack, vkOnlyPack, brokenValue}
|
||||
}
|
||||
|
||||
// byID indexes a projected storefront by product id for assertions.
|
||||
func byID(v CatalogView) map[string]CatalogProduct {
|
||||
m := make(map[string]CatalogProduct, len(v.Products))
|
||||
for _, p := range v.Products {
|
||||
m[p.ProductID] = p
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestProjectCatalog_ContextMatrix(t *testing.T) {
|
||||
const (
|
||||
valueID = "00000000-0000-0000-0000-000000000001"
|
||||
packID = "00000000-0000-0000-0000-000000000002"
|
||||
vkPackID = "00000000-0000-0000-0000-000000000003"
|
||||
brokenID = "00000000-0000-0000-0000-000000000004"
|
||||
)
|
||||
entries := fixtureCatalog()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cxt Context
|
||||
wantPackAmt int64
|
||||
wantPackCur Currency
|
||||
wantVKPack bool // the vk-only pack visible?
|
||||
}{
|
||||
{"vk", Context{Kind: SourceVK}, 20, CurrencyVote, true},
|
||||
{"vk-ios frozen still lists vk price", Context{Kind: SourceVK, Subtype: SubtypeIOS}, 20, CurrencyVote, true},
|
||||
{"telegram", Context{Kind: SourceTelegram}, 25, CurrencyStar, false},
|
||||
{"direct", Context{Kind: SourceDirect}, 14900, CurrencyRUB, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := byID(projectCatalog(entries, tc.cxt))
|
||||
|
||||
// The value shows everywhere at its uniform chip price, never a money price.
|
||||
v, ok := got[valueID]
|
||||
if !ok {
|
||||
t.Fatalf("value missing from %s storefront", tc.name)
|
||||
}
|
||||
if v.Kind != "value" || v.Chips != 500 || v.MoneyCurrency != "" || v.MoneyAmount != 0 {
|
||||
t.Errorf("value = %+v, want kind=value chips=500 no money", v)
|
||||
}
|
||||
if len(v.Atoms) != 1 || v.Atoms[0].AtomType != "hints" || v.Atoms[0].Quantity != 250 {
|
||||
t.Errorf("value atoms = %+v, want [hints:250]", v.Atoms)
|
||||
}
|
||||
|
||||
// The multi-method pack shows the context method's price, in that currency, no chips.
|
||||
p, ok := got[packID]
|
||||
if !ok {
|
||||
t.Fatalf("pack missing from %s storefront", tc.name)
|
||||
}
|
||||
if p.Kind != "pack" || p.Chips != 0 || p.MoneyAmount != tc.wantPackAmt || p.MoneyCurrency != string(tc.wantPackCur) {
|
||||
t.Errorf("pack = %+v, want kind=pack amount=%d currency=%s", p, tc.wantPackAmt, tc.wantPackCur)
|
||||
}
|
||||
|
||||
// The vk-only pack shows only where a vk price exists.
|
||||
if _, ok := got[vkPackID]; ok != tc.wantVKPack {
|
||||
t.Errorf("vk-only pack present=%v, want %v (%s)", ok, tc.wantVKPack, tc.name)
|
||||
}
|
||||
|
||||
// The misconfigured value (no CHIP price) is never shown.
|
||||
if _, ok := got[brokenID]; ok {
|
||||
t.Errorf("misconfigured value must be omitted (%s)", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An untrusted context (empty Kind) has no payment method, so it shows values only — no packs.
|
||||
func TestProjectCatalog_UntrustedShowsValuesOnly(t *testing.T) {
|
||||
got := byID(projectCatalog(fixtureCatalog(), Context{}))
|
||||
if _, ok := got["00000000-0000-0000-0000-000000000001"]; !ok {
|
||||
t.Error("untrusted context should still list the chip-priced value")
|
||||
}
|
||||
for _, id := range []string{
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
"00000000-0000-0000-0000-000000000003",
|
||||
} {
|
||||
if _, ok := got[id]; ok {
|
||||
t.Errorf("untrusted context must not list pack %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty catalog projects to an empty storefront (no products seeded yet — the Release-1 state).
|
||||
func TestProjectCatalog_Empty(t *testing.T) {
|
||||
if got := projectCatalog(nil, Context{Kind: SourceDirect}); len(got.Products) != 0 {
|
||||
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,18 @@ func (s *Service) Wallet(ctx context.Context, accountID uuid.UUID, cxt Context,
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// Catalog returns the storefront for the execution context: every chip-priced value (shown in
|
||||
// every context) plus the chip packs priced in the context's payment method. It is read straight
|
||||
// from the catalog tables — small and rarely edited, so uncached. An untrusted context has no
|
||||
// method and so shows values only; buying is gate-checked on Spend regardless (fail-closed).
|
||||
func (s *Service) Catalog(ctx context.Context, cxt Context) (CatalogView, error) {
|
||||
entries, err := s.store.loadCatalog(ctx)
|
||||
if err != nil {
|
||||
return CatalogView{}, err
|
||||
}
|
||||
return projectCatalog(entries, cxt), nil
|
||||
}
|
||||
|
||||
// AdFree reports whether ads are suppressed for the account in the context: some origin
|
||||
// applicable there has an active no-ads term or the forever flag. Fail-closed on an untrusted
|
||||
// platform (no origin applies).
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// atomQty is one atom line of a product as loaded from the catalog: the atom type and quantity.
|
||||
type atomQty struct {
|
||||
atomType string
|
||||
quantity int
|
||||
}
|
||||
|
||||
// priceRow is one price of a product as loaded from the catalog: the payment method (empty for a
|
||||
// value's CHIP price, stored with a NULL method) and the amount in that currency's minor units.
|
||||
type priceRow struct {
|
||||
method string
|
||||
currency Currency
|
||||
amount int64
|
||||
}
|
||||
|
||||
// catalogEntry is a raw active product loaded for the storefront: its atom composition and every
|
||||
// price row. [projectCatalog] turns it into the context-visible [CatalogProduct].
|
||||
type catalogEntry struct {
|
||||
id uuid.UUID
|
||||
title string
|
||||
atoms []atomQty
|
||||
prices []priceRow
|
||||
}
|
||||
|
||||
// loadCatalog reads every active product with its atoms and prices, ordered by creation, straight
|
||||
// from the catalog tables. The catalog is small and rarely edited (admin console only), so it is
|
||||
// read uncached — unlike the per-account balances/benefits the read cache fronts.
|
||||
func (s *Store) loadCatalog(ctx context.Context) ([]catalogEntry, error) {
|
||||
var prods []model.Product
|
||||
if err := postgres.SELECT(table.Product.AllColumns).
|
||||
FROM(table.Product).
|
||||
WHERE(table.Product.Active.IS_TRUE()).
|
||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||
QueryContext(ctx, s.db, &prods); err != nil {
|
||||
return nil, fmt.Errorf("payments: load catalog products: %w", err)
|
||||
}
|
||||
if len(prods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
entries := make([]catalogEntry, len(prods))
|
||||
index := make(map[uuid.UUID]int, len(prods))
|
||||
for i, p := range prods {
|
||||
entries[i] = catalogEntry{id: p.ProductID, title: p.Title}
|
||||
index[p.ProductID] = i
|
||||
}
|
||||
|
||||
var items []model.ProductItem
|
||||
if err := postgres.SELECT(table.ProductItem.AllColumns).
|
||||
FROM(table.ProductItem).
|
||||
QueryContext(ctx, s.db, &items); err != nil {
|
||||
return nil, fmt.Errorf("payments: load catalog items: %w", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
if i, ok := index[it.ProductID]; ok {
|
||||
entries[i].atoms = append(entries[i].atoms, atomQty{atomType: it.AtomType, quantity: int(it.Quantity)})
|
||||
}
|
||||
}
|
||||
|
||||
var prices []model.ProductPrice
|
||||
if err := postgres.SELECT(table.ProductPrice.AllColumns).
|
||||
FROM(table.ProductPrice).
|
||||
QueryContext(ctx, s.db, &prices); err != nil {
|
||||
return nil, fmt.Errorf("payments: load catalog prices: %w", err)
|
||||
}
|
||||
for _, pr := range prices {
|
||||
i, ok := index[pr.ProductID]
|
||||
if !ok {
|
||||
continue // a price for a deactivated product — not in the storefront
|
||||
}
|
||||
method := ""
|
||||
if pr.Method != nil {
|
||||
method = *pr.Method
|
||||
}
|
||||
entries[i].prices = append(entries[i].prices, priceRow{method: method, currency: Currency(pr.Currency), amount: pr.Amount})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
@@ -67,8 +67,10 @@ func (s *Server) registerRoutes() {
|
||||
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.
|
||||
// The wallet: the context-visible chip segments + benefits, the storefront catalog, and a
|
||||
// chip spend on a value.
|
||||
u.GET("/wallet", s.handleWallet)
|
||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
}
|
||||
if s.links != nil {
|
||||
|
||||
@@ -45,6 +45,68 @@ func walletDTOFrom(v payments.WalletView) walletDTO {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user