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
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).
192 lines
6.1 KiB
Go
192 lines
6.1 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// AdminProduct is one catalog product for the admin editor: its full composition, every price, the
|
|
// archived flag (Active), and whether it has ever been transacted — which forbids a hard delete
|
|
// (orders / ledger rows reference it, and the ledger is append-only).
|
|
type AdminProduct struct {
|
|
ID uuid.UUID
|
|
Title string
|
|
Active bool
|
|
Atoms []AtomLine
|
|
Prices []PriceLine
|
|
Transacted bool
|
|
}
|
|
|
|
// AtomLine is one atom of a product: the atom type and its positive quantity.
|
|
type AtomLine struct {
|
|
Atom string
|
|
Quantity int
|
|
}
|
|
|
|
// PriceLine is one price of a product: the payment method ("" for a value's CHIP price, which is
|
|
// stored with a NULL method), the currency, and the amount in that currency's minor units.
|
|
type PriceLine struct {
|
|
Method string
|
|
Currency Currency
|
|
Amount int64
|
|
}
|
|
|
|
// ProductInput is the editable content of a product: its title, atom composition and prices.
|
|
type ProductInput struct {
|
|
Title string
|
|
Atoms []AtomLine
|
|
Prices []PriceLine
|
|
}
|
|
|
|
// knownAtoms is the fixed atom set, mirroring the catalog_atom seed / CHECK.
|
|
var knownAtoms = map[string]bool{atomChips: true, "hints": true, "noads_days": true, "tournament": true}
|
|
|
|
// validCurrency reports whether c is one of the four catalog currencies.
|
|
func validCurrency(c Currency) bool {
|
|
switch c {
|
|
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// validateProduct checks a product's composition and prices. When sellable is true (the product is
|
|
// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money
|
|
// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no
|
|
// money price). The tournament atom is never sellable (its economy is the tournament stage), so an
|
|
// active product may not carry it; an archived draft may (a template for later) and skips the shape
|
|
// check.
|
|
func validateProduct(in ProductInput, sellable bool) error {
|
|
if strings.TrimSpace(in.Title) == "" {
|
|
return errors.New("product title is required")
|
|
}
|
|
if len(in.Atoms) == 0 {
|
|
return errors.New("product needs at least one atom")
|
|
}
|
|
seen := map[string]bool{}
|
|
hasChips, hasTournament, hasBenefit := false, false, false
|
|
for _, a := range in.Atoms {
|
|
if !knownAtoms[a.Atom] {
|
|
return fmt.Errorf("unknown atom %q", a.Atom)
|
|
}
|
|
if seen[a.Atom] {
|
|
return fmt.Errorf("duplicate atom %q", a.Atom)
|
|
}
|
|
seen[a.Atom] = true
|
|
if a.Quantity <= 0 {
|
|
return fmt.Errorf("atom %q quantity must be positive", a.Atom)
|
|
}
|
|
switch a.Atom {
|
|
case atomChips:
|
|
hasChips = true
|
|
case "tournament":
|
|
hasTournament = true
|
|
default:
|
|
hasBenefit = true
|
|
}
|
|
}
|
|
|
|
priceSeen := map[string]bool{}
|
|
hasChipPrice, hasMoneyPrice := false, false
|
|
for _, p := range in.Prices {
|
|
if p.Method != "" && p.Method != string(SourceVK) && p.Method != string(SourceTelegram) && p.Method != string(SourceDirect) {
|
|
return fmt.Errorf("invalid price method %q", p.Method)
|
|
}
|
|
if !validCurrency(p.Currency) {
|
|
return fmt.Errorf("invalid currency %q", p.Currency)
|
|
}
|
|
if p.Amount < 0 {
|
|
return errors.New("price amount must be non-negative")
|
|
}
|
|
if p.Currency == CurrencyChip && p.Method != "" {
|
|
return errors.New("a CHIP price must have no method")
|
|
}
|
|
if p.Currency != CurrencyChip && p.Method == "" {
|
|
return fmt.Errorf("a %s price needs a payment method", p.Currency)
|
|
}
|
|
key := p.Method + "|" + string(p.Currency)
|
|
if priceSeen[key] {
|
|
return fmt.Errorf("duplicate price (%s, %s)", p.Method, p.Currency)
|
|
}
|
|
priceSeen[key] = true
|
|
if p.Currency == CurrencyChip {
|
|
hasChipPrice = true
|
|
} else {
|
|
hasMoneyPrice = true
|
|
}
|
|
}
|
|
|
|
if !sellable {
|
|
return nil // an archived draft may be incomplete
|
|
}
|
|
if hasTournament {
|
|
return errors.New("a product carrying the tournament atom cannot be sold yet")
|
|
}
|
|
if hasChips {
|
|
if hasBenefit {
|
|
return errors.New("a chip pack must contain only the chips atom")
|
|
}
|
|
if !hasMoneyPrice || hasChipPrice {
|
|
return errors.New("a chip pack needs a money price per method and no CHIP price")
|
|
}
|
|
} else {
|
|
if !hasChipPrice || hasMoneyPrice {
|
|
return errors.New("a value needs a single CHIP price and no money price")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
|
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
|
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
|
return s.store.adminCatalog(ctx)
|
|
}
|
|
|
|
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
|
// product created active must satisfy the sellable shape.
|
|
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
|
|
if err := validateProduct(in, active); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
return s.store.createProduct(ctx, in, active, s.clock())
|
|
}
|
|
|
|
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
|
// satisfy the sellable shape.
|
|
func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInput) (err error) {
|
|
active, err := s.store.productActive(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateProduct(in, active); err != nil {
|
|
return err
|
|
}
|
|
return s.store.updateProduct(ctx, id, in, s.clock())
|
|
}
|
|
|
|
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
|
// sellable shape, so a draft or a tournament product cannot be put on sale.
|
|
func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active bool) error {
|
|
if active {
|
|
in, err := s.store.productInput(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateProduct(in, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.store.setProductActive(ctx, id, active, s.clock())
|
|
}
|
|
|
|
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
|
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
|
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
|
return s.store.deleteProduct(ctx, id)
|
|
}
|