Files
scrabble-game/backend/internal/payments/store_catalog_admin.go
T
Ilia Denisov b6af682381
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
refactor(payments): one canonical catalog order for storefront, offer, admin; admin active/all toggle
The wallet storefront listed products in creation order — its purchases and its
chip-exchange values were unsorted, unlike the admin console and the public offer.
Extract the ordering (chip packs first by rouble price, then chip-priced values
grouped hints → no-ads → combo → tournament and by chip price) into one comparator,
compareCatalogRank over a small rank key, and apply it at all three sites
(projectCatalog, projectOfferPricing, SortAdminCatalog) — so the subgroup ranking
(valueGroup) and the full order now live in one place. The rewarded "watch for
chips" CTA is a wallet-driven element rendered above the packs, so it is unaffected
and stays on top.

Also add the admin catalog active/all toggle: AdminCatalog takes includeInactive,
the console shows active products by default and ?all=1 lists archived ones too
(the detail and grant forms keep loading all products).

Tests: a storefront canonical-order unit test (reproduced the unsorted bug first);
an integration test for the active/all toggle. Existing offer/admin order tests
unchanged — behaviour preserved.
2026-07-14 10:23:42 +02:00

225 lines
8.0 KiB
Go

package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// ErrProductTransacted is returned when a hard delete is attempted on a product an order or ledger
// row references — it must be archived instead, to keep the append-only ledger resolvable.
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
// archived products to the active ones; with it false only active products are returned.
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
where := table.Product.Active.IS_TRUE()
if includeInactive {
where = postgres.Bool(true)
}
var prods []model.Product
if err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
WHERE(where).
ORDER_BY(table.Product.CreatedAt.ASC()).
QueryContext(ctx, s.db, &prods); err != nil {
return nil, fmt.Errorf("payments: admin list products: %w", err)
}
out := make([]AdminProduct, 0, len(prods))
for _, p := range prods {
atoms, prices, err := s.productComposition(ctx, p.ProductID)
if err != nil {
return nil, err
}
transacted, err := s.productTransacted(ctx, p.ProductID)
if err != nil {
return nil, err
}
out = append(out, AdminProduct{
ID: p.ProductID, Title: p.Title, Active: p.Active,
Atoms: atoms, Prices: prices, Transacted: transacted,
})
}
return out, nil
}
// productComposition reads one product's atom lines and price rows.
func (s *Store) productComposition(ctx context.Context, id uuid.UUID) ([]AtomLine, []PriceLine, error) {
var items []model.ProductItem
if err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(id))).
ORDER_BY(table.ProductItem.AtomType.ASC()).
QueryContext(ctx, s.db, &items); err != nil {
return nil, nil, fmt.Errorf("payments: load items %s: %w", id, err)
}
atoms := make([]AtomLine, len(items))
for i, it := range items {
atoms[i] = AtomLine{Atom: it.AtomType, Quantity: int(it.Quantity)}
}
var prs []model.ProductPrice
if err := postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(id))).
QueryContext(ctx, s.db, &prs); err != nil {
return nil, nil, fmt.Errorf("payments: load prices %s: %w", id, err)
}
prices := make([]PriceLine, len(prs))
for i, pr := range prs {
method := ""
if pr.Method != nil {
method = *pr.Method
}
prices[i] = PriceLine{Method: method, Currency: Currency(pr.Currency), Amount: pr.Amount}
}
return atoms, prices, nil
}
// productTransacted reports whether any order or ledger row references the product.
func (s *Store) productTransacted(ctx context.Context, id uuid.UUID) (bool, error) {
var yes bool
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM payments.orders WHERE product_id=$1)
OR EXISTS(SELECT 1 FROM payments.ledger WHERE product_id=$1)`, id).Scan(&yes); err != nil {
return false, fmt.Errorf("payments: product transacted %s: %w", id, err)
}
return yes, nil
}
// productActive reads a product's archived flag, ErrProductNotFound when it is missing.
func (s *Store) productActive(ctx context.Context, id uuid.UUID) (bool, error) {
var p model.Product
err := postgres.SELECT(table.Product.Active).FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) {
return false, ErrProductNotFound
}
if err != nil {
return false, fmt.Errorf("payments: product active %s: %w", id, err)
}
return p.Active, nil
}
// productInput reads a product's current editable content (title, atoms, prices).
func (s *Store) productInput(ctx context.Context, id uuid.UUID) (ProductInput, error) {
var p model.Product
err := postgres.SELECT(table.Product.AllColumns).FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) {
return ProductInput{}, ErrProductNotFound
}
if err != nil {
return ProductInput{}, fmt.Errorf("payments: product input %s: %w", id, err)
}
atoms, prices, err := s.productComposition(ctx, id)
if err != nil {
return ProductInput{}, err
}
return ProductInput{Title: p.Title, Atoms: atoms, Prices: prices}, nil
}
// createProduct inserts a product with its atoms and prices in one transaction, returning its id.
func (s *Store) createProduct(ctx context.Context, in ProductInput, active bool, now time.Time) (uuid.UUID, error) {
id := uuid.New()
if err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product (product_id, title, active, created_at, updated_at) VALUES ($1,$2,$3,$4,$4)`,
id, in.Title, active, now); err != nil {
return fmt.Errorf("insert product: %w", err)
}
return insertComposition(ctx, tx, id, in)
}); err != nil {
return uuid.Nil, fmt.Errorf("payments: create product: %w", err)
}
return id, nil
}
// updateProduct replaces a product's title, atoms and prices in one transaction (active unchanged).
func (s *Store) updateProduct(ctx context.Context, id uuid.UUID, in ProductInput, now time.Time) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE payments.product SET title=$2, updated_at=$3 WHERE product_id=$1`, id, in.Title, now)
if err != nil {
return fmt.Errorf("payments: update product %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_item WHERE product_id=$1`, id); err != nil {
return fmt.Errorf("payments: clear items %s: %w", id, err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_price WHERE product_id=$1`, id); err != nil {
return fmt.Errorf("payments: clear prices %s: %w", id, err)
}
return insertComposition(ctx, tx, id, in)
})
}
// insertComposition inserts a product's atom items and price rows inside tx (a value's CHIP price
// carries a NULL method).
func insertComposition(ctx context.Context, tx *sql.Tx, id uuid.UUID, in ProductInput) error {
for _, a := range in.Atoms {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,$2,$3)`,
id, a.Atom, a.Quantity); err != nil {
return fmt.Errorf("insert item %s: %w", a.Atom, err)
}
}
for _, p := range in.Prices {
var method any
if p.Method != "" {
method = p.Method
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
id, method, string(p.Currency), p.Amount); err != nil {
return fmt.Errorf("insert price %s: %w", p.Currency, err)
}
}
return nil
}
// setProductActive flips the archived flag.
func (s *Store) setProductActive(ctx context.Context, id uuid.UUID, active bool, now time.Time) error {
res, err := s.db.ExecContext(ctx,
`UPDATE payments.product SET active=$2, updated_at=$3 WHERE product_id=$1`, id, active, now)
if err != nil {
return fmt.Errorf("payments: set product active %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
return nil
}
// deleteProduct hard-deletes a never-transacted product (its items/prices cascade); a transacted
// product is refused with ErrProductTransacted.
func (s *Store) deleteProduct(ctx context.Context, id uuid.UUID) error {
transacted, err := s.productTransacted(ctx, id)
if err != nil {
return err
}
if transacted {
return ErrProductTransacted
}
res, err := s.db.ExecContext(ctx, `DELETE FROM payments.product WHERE product_id=$1`, id)
if err != nil {
return fmt.Errorf("payments: delete product %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
return nil
}