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 } id, err := s.store.createProduct(ctx, in, active, s.clock()) if err == nil { s.markOfferStale() } return id, err } // 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 } if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil { return err } s.markOfferStale() return nil } // 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 } } if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil { return err } s.markOfferStale() return nil } // 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 { if err := s.store.deleteProduct(ctx, id); err != nil { return err } s.markOfferStale() return nil }