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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user