Files
scrabble-game/backend/internal/payments/store_catalog.go
T
Ilia Denisov 4aa6174968
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
feat(payments): wallet screen with balances, benefits and storefront
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.
2026-07-08 12:37:32 +02:00

90 lines
2.8 KiB
Go

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
}