Files
scrabble-game/backend/internal/payments/store_catalog_admin.go
T
Ilia Denisov 0036b55618
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
feat(admin): product catalog editor
The /_gm console gains a Catalog editor — the source of truth for products.
Create / edit / archive-unarchive products, their atom composition and per-rail
prices (RUB via direct / VOTE via vk / XTR via telegram / CHIP value), and
hard-delete only a never-transacted product (an order or ledger reference forces
archive-only, backed by the FK RESTRICT). The archived flag reuses the existing
product.active. Activation revalidates the sellable shape — a pack (the chips
atom ⇒ a money price per rail, chips-only) or a value (no chips ⇒ a CHIP price);
a tournament-bearing product is composable but never sellable yet.

Backed by payments AdminCatalog / CreateProduct / UpdateProduct /
SetProductActive / DeleteProduct + a pure validateProduct.

Tests: validateProduct (pack / value / tournament / duplicate / shape); the
console editor end to end (create, edit, archive, delete-if-clean, refuse a
transacted delete).
2026-07-10 05:18:54 +02:00

219 lines
7.8 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 every product (active and archived) with its atoms, prices and transacted flag.
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
var prods []model.Product
if err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
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
}