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
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.
225 lines
8.0 KiB
Go
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
|
|
}
|