diff --git a/backend/README.md b/backend/README.md
index e77d444..bf3adcf 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -142,7 +142,11 @@ publishes a `notify` `banner` re-poll signal so the client shows/hides it in pla
The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The user card
also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per
funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history
-(newest first) — read straight from the payments tables, uncached. The shared wire
+(newest first) — read straight from the payments tables, uncached. The **catalog editor**
+(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
+edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
+hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
+backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml
index 4fab715..c472e03 100644
--- a/backend/internal/adminconsole/templates/layout.gohtml
+++ b/backend/internal/adminconsole/templates/layout.gohtml
@@ -21,6 +21,7 @@
Throttled
Reasons
Banners
+ Catalog
Dictionary
Broadcast
Grafana ↗
diff --git a/backend/internal/adminconsole/templates/pages/catalog.gohtml b/backend/internal/adminconsole/templates/pages/catalog.gohtml
new file mode 100644
index 0000000..a738175
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/catalog.gohtml
@@ -0,0 +1,44 @@
+{{define "content" -}}
+
Product catalog
+{{with .Data}}
+A pack funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a value buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The tournament atom is not sellable yet — keep such a product archived.
+
+Products
+
+| Title | Status | Atoms | Prices | |
+
+{{range .Products}}
+
+| {{.Title}} |
+{{if .Active}}active{{else}}archived{{end}}{{if .Transacted}} transacted{{end}} |
+{{range .Atoms}}{{.Atom}}×{{.Quantity}} {{end}} |
+{{range .Prices}}{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}} {{end}} |
+
+
+{{if not .Transacted}}{{end}}
+ |
+
+{{else}}| no products |
{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/product_detail.gohtml b/backend/internal/adminconsole/templates/pages/product_detail.gohtml
new file mode 100644
index 0000000..369a042
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/product_detail.gohtml
@@ -0,0 +1,25 @@
+{{define "content" -}}
+{{with .Data}}
+← all products
+{{.Title}} {{if .Active}}active{{else}}archived{{end}}{{if .Transacted}} transacted{{end}}
+Edit
+A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the catalog list.
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 7fddc20..c1d5e66 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -653,3 +653,51 @@ type FeedbackDetailView struct {
UserTZ string
Banned bool
}
+
+// CatalogView is the product-catalog list page.
+type CatalogView struct {
+ Products []ProductRow
+}
+
+// ProductRow is one product in the catalog list: its composition, prices, the archived flag
+// (Active) and the transacted flag (which forbids a hard delete).
+type ProductRow struct {
+ ID string
+ Title string
+ Active bool
+ Atoms []AtomRow
+ Prices []PriceRow
+ Transacted bool
+}
+
+// AtomRow is one atom line of a product row.
+type AtomRow struct {
+ Atom string
+ Quantity int
+}
+
+// PriceRow is one price of a product: the method ("" for a value's CHIP price), the currency, and
+// the amount in that currency's minor units.
+type PriceRow struct {
+ Method string
+ Currency string
+ Amount int64
+}
+
+// ProductFormView is the product edit form, pre-filled from the current composition. Atom quantities
+// and prices are flattened to the fixed fields the form offers (0 = absent); Transacted disables the
+// delete action.
+type ProductFormView struct {
+ ID string
+ Title string
+ Active bool
+ Chips int
+ Hints int
+ NoAds int
+ Tournament int
+ PriceRUB int64
+ PriceVote int64
+ PriceStar int64
+ PriceChip int64
+ Transacted bool
+}
diff --git a/backend/internal/inttest/catalog_editor_test.go b/backend/internal/inttest/catalog_editor_test.go
new file mode 100644
index 0000000..02379a7
--- /dev/null
+++ b/backend/internal/inttest/catalog_editor_test.go
@@ -0,0 +1,97 @@
+//go:build integration
+
+package inttest
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/payments"
+)
+
+// productByTitle finds a catalog product by its (unique in the test) title.
+func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
+ t.Helper()
+ all, err := pay.AdminCatalog(context.Background())
+ if err != nil {
+ t.Fatalf("admin catalog: %v", err)
+ }
+ for _, p := range all {
+ if p.Title == title {
+ return p
+ }
+ }
+ t.Fatalf("product %q not found", title)
+ return payments.AdminProduct{}
+}
+
+// TestConsoleCatalogEditor drives the catalog editor end to end: create is CSRF-guarded; a value and
+// a pack are created and edited; an invalid active product is refused; archive hides it; a
+// never-transacted product deletes; a transacted product is refused deletion (archive only).
+func TestConsoleCatalogEditor(t *testing.T) {
+ ctx := context.Background()
+ srv, _, pay := bannerServer(t)
+ h := srv.Handler()
+ const origin = "http://admin.test"
+ const catalog = "http://admin.test/_gm/catalog"
+
+ // CSRF: a create without the origin header is refused.
+ if code, _ := consoleDo(h, http.MethodPost, catalog, "title=x&hints=1&price_chip=10&active=true", ""); code != http.StatusForbidden {
+ t.Fatalf("create without origin = %d, want 403", code)
+ }
+
+ // Create a value product: 5 hints for 50 chips, active.
+ if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-hints-5&hints=5&price_chip=50&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
+ t.Fatalf("create value = %d, has 'Created' = %v", code, strings.Contains(body, "Created"))
+ }
+ val := productByTitle(t, pay, "cat-hints-5")
+ if !val.Active || len(val.Atoms) != 1 || val.Atoms[0].Atom != "hints" || val.Atoms[0].Quantity != 5 {
+ t.Fatalf("created value = %+v", val)
+ }
+
+ // An invalid active pack (chips, no money price) is refused.
+ if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
+ t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
+ }
+ if _, err := pay.AdminCatalog(ctx); err != nil {
+ t.Fatalf("catalog: %v", err)
+ }
+
+ // Edit the value: raise to 8 hints.
+ base := catalog + "/" + val.ID.String()
+ if code, _ := consoleDo(h, http.MethodPost, base, "title=cat-hints-8&hints=8&price_chip=50&active=true", origin); code != http.StatusOK {
+ t.Fatalf("edit = %d", code)
+ }
+ if got := productByTitle(t, pay, "cat-hints-8"); len(got.Atoms) != 1 || got.Atoms[0].Quantity != 8 {
+ t.Fatalf("after edit atoms = %+v, want 8 hints", got.Atoms)
+ }
+
+ // Archive it → hidden from the storefront (the user Catalog filters active).
+ if code, _ := consoleDo(h, http.MethodPost, base+"/archive", "active=false", origin); code != http.StatusOK {
+ t.Fatalf("archive = %d", code)
+ }
+ if productByTitle(t, pay, "cat-hints-8").Active {
+ t.Error("product still active after archive")
+ }
+
+ // Delete the never-transacted product.
+ if code, body := consoleDo(h, http.MethodPost, base+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
+ t.Fatalf("delete clean = %d, has 'Deleted' = %v", code, strings.Contains(body, "Deleted"))
+ }
+
+ // A transacted product cannot be deleted — only archived.
+ if code, _ := consoleDo(h, http.MethodPost, catalog, "title=cat-pack&chips=100&price_rub=14900&active=true", origin); code != http.StatusOK {
+ t.Fatal("create pack failed")
+ }
+ pack := productByTitle(t, pay, "cat-pack")
+ if _, err := pay.CreateOrder(ctx, uuid.New(), payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, pack.ID, "robokassa"); err != nil {
+ t.Fatalf("create order: %v", err)
+ }
+ if code, body := consoleDo(h, http.MethodPost, catalog+"/"+pack.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Cannot delete") {
+ t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
+ }
+}
diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go
new file mode 100644
index 0000000..999cbfb
--- /dev/null
+++ b/backend/internal/payments/catalog_admin.go
@@ -0,0 +1,191 @@
+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)
+}
diff --git a/backend/internal/payments/catalog_admin_test.go b/backend/internal/payments/catalog_admin_test.go
new file mode 100644
index 0000000..7a60a80
--- /dev/null
+++ b/backend/internal/payments/catalog_admin_test.go
@@ -0,0 +1,47 @@
+package payments
+
+import "testing"
+
+func TestValidateProduct(t *testing.T) {
+ pack := ProductInput{
+ Title: "100 chips",
+ Atoms: []AtomLine{{Atom: "chips", Quantity: 100}},
+ Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 14900}},
+ }
+ value := ProductInput{
+ Title: "5 hints",
+ Atoms: []AtomLine{{Atom: "hints", Quantity: 5}},
+ Prices: []PriceLine{{Method: "", Currency: CurrencyChip, Amount: 50}},
+ }
+
+ tests := []struct {
+ name string
+ in ProductInput
+ sellable bool
+ wantErr bool
+ }{
+ {"valid pack", pack, true, false},
+ {"valid value", value, true, false},
+ {"pack with a benefit atom", ProductInput{Title: "x", Atoms: []AtomLine{{"chips", 100}, {"hints", 5}}, Prices: pack.Prices}, true, true},
+ {"pack without money price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: value.Prices}, true, true},
+ {"value without chip price", ProductInput{Title: "x", Atoms: value.Atoms, Prices: pack.Prices}, true, true},
+ {"tournament sellable is refused", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}, Prices: value.Prices}, true, true},
+ {"tournament draft is allowed", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}}, false, false},
+ {"unknown atom", ProductInput{Title: "x", Atoms: []AtomLine{{"gold", 1}}}, false, true},
+ {"duplicate atom", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 1}, {"hints", 2}}}, false, true},
+ {"non-positive quantity", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 0}}}, false, true},
+ {"chip price with a method", ProductInput{Title: "x", Atoms: value.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyChip, Amount: 50}}}, true, true},
+ {"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true},
+ {"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true},
+ {"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true},
+ {"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateProduct(tt.in, tt.sellable)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("validateProduct = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/backend/internal/payments/store_catalog_admin.go b/backend/internal/payments/store_catalog_admin.go
new file mode 100644
index 0000000..c63f8c1
--- /dev/null
+++ b/backend/internal/payments/store_catalog_admin.go
@@ -0,0 +1,218 @@
+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
+}
diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go
new file mode 100644
index 0000000..bae1644
--- /dev/null
+++ b/backend/internal/server/handlers_admin_catalog.go
@@ -0,0 +1,183 @@
+package server
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "scrabble/backend/internal/adminconsole"
+ "scrabble/backend/internal/payments"
+)
+
+// catalogBack is the product-list path the catalog console actions return to.
+const catalogBack = "/_gm/catalog"
+
+// atomField pairs a form field with its atom type; priceField pairs a form field with the payment
+// method + currency it prices, following the one-currency-per-rail mapping (direct→RUB, vk→VOTE,
+// telegram→XTR) plus the value's CHIP price (no method).
+var atomFields = []struct{ field, atom string }{
+ {"chips", "chips"}, {"hints", "hints"}, {"noads", "noads_days"}, {"tournament", "tournament"},
+}
+var priceFields = []struct {
+ field, method string
+ currency payments.Currency
+}{
+ {"price_rub", "direct", payments.CurrencyRUB},
+ {"price_vote", "vk", payments.CurrencyVote},
+ {"price_star", "telegram", payments.CurrencyStar},
+ {"price_chip", "", payments.CurrencyChip},
+}
+
+// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
+// flag, and the inline create form.
+func (s *Server) consoleCatalog(c *gin.Context) {
+ products, err := s.payments.AdminCatalog(c.Request.Context())
+ if err != nil {
+ s.consoleError(c, err)
+ return
+ }
+ var view adminconsole.CatalogView
+ for _, p := range products {
+ view.Products = append(view.Products, catalogRow(p))
+ }
+ s.renderConsole(c, "catalog", "catalog", "Catalog", view)
+}
+
+// catalogRow projects a product into its list row.
+func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
+ row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
+ for _, a := range p.Atoms {
+ row.Atoms = append(row.Atoms, adminconsole.AtomRow{Atom: a.Atom, Quantity: a.Quantity})
+ }
+ for _, pr := range p.Prices {
+ row.Prices = append(row.Prices, adminconsole.PriceRow{Method: pr.Method, Currency: string(pr.Currency), Amount: pr.Amount})
+ }
+ return row
+}
+
+// consoleCatalogDetail renders one product's edit form, pre-filled from its current composition.
+func (s *Server) consoleCatalogDetail(c *gin.Context) {
+ id, ok := s.consoleUUID(c, catalogBack)
+ if !ok {
+ return
+ }
+ products, err := s.payments.AdminCatalog(c.Request.Context())
+ if err != nil {
+ s.consoleError(c, err)
+ return
+ }
+ for _, p := range products {
+ if p.ID == id {
+ s.renderConsole(c, "product_detail", "catalog", p.Title, productForm(p))
+ return
+ }
+ }
+ s.renderConsoleMessage(c, "Not found", "no such product", catalogBack)
+}
+
+// productForm builds the pre-filled edit form for a product.
+func productForm(p payments.AdminProduct) adminconsole.ProductFormView {
+ fv := adminconsole.ProductFormView{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
+ for _, a := range p.Atoms {
+ switch a.Atom {
+ case "chips":
+ fv.Chips = a.Quantity
+ case "hints":
+ fv.Hints = a.Quantity
+ case "noads_days":
+ fv.NoAds = a.Quantity
+ case "tournament":
+ fv.Tournament = a.Quantity
+ }
+ }
+ for _, pr := range p.Prices {
+ switch pr.Currency {
+ case payments.CurrencyRUB:
+ fv.PriceRUB = pr.Amount
+ case payments.CurrencyVote:
+ fv.PriceVote = pr.Amount
+ case payments.CurrencyStar:
+ fv.PriceStar = pr.Amount
+ case payments.CurrencyChip:
+ fv.PriceChip = pr.Amount
+ }
+ }
+ return fv
+}
+
+// parseProductForm reads a product's title, atoms, prices and active flag from the submitted form.
+func parseProductForm(c *gin.Context) (payments.ProductInput, bool) {
+ in := payments.ProductInput{Title: strings.TrimSpace(c.PostForm("title"))}
+ for _, a := range atomFields {
+ if q, err := strconv.Atoi(strings.TrimSpace(c.PostForm(a.field))); err == nil && q > 0 {
+ in.Atoms = append(in.Atoms, payments.AtomLine{Atom: a.atom, Quantity: q})
+ }
+ }
+ for _, p := range priceFields {
+ if amt, err := strconv.ParseInt(strings.TrimSpace(c.PostForm(p.field)), 10, 64); err == nil && amt > 0 {
+ in.Prices = append(in.Prices, payments.PriceLine{Method: p.method, Currency: p.currency, Amount: amt})
+ }
+ }
+ return in, c.PostForm("active") != ""
+}
+
+// consoleCreateProduct validates and inserts a new product from the create form.
+func (s *Server) consoleCreateProduct(c *gin.Context) {
+ in, active := parseProductForm(c)
+ if _, err := s.payments.CreateProduct(c.Request.Context(), in, active); err != nil {
+ s.renderConsoleMessage(c, "Invalid product", err.Error(), catalogBack)
+ return
+ }
+ s.renderConsoleMessage(c, "Created", "the product was created", catalogBack)
+}
+
+// consoleUpdateProduct validates and replaces a product's title, atoms and prices.
+func (s *Server) consoleUpdateProduct(c *gin.Context) {
+ id, ok := s.consoleUUID(c, catalogBack)
+ if !ok {
+ return
+ }
+ in, _ := parseProductForm(c)
+ back := catalogBack + "/" + id.String()
+ if err := s.payments.UpdateProduct(c.Request.Context(), id, in); err != nil {
+ s.renderConsoleMessage(c, "Invalid product", err.Error(), back)
+ return
+ }
+ s.renderConsoleMessage(c, "Saved", "the product was updated", back)
+}
+
+// consoleArchiveProduct archives or unarchives a product (the desired state rides in the form);
+// unarchiving revalidates the sellable shape.
+func (s *Server) consoleArchiveProduct(c *gin.Context) {
+ id, ok := s.consoleUUID(c, catalogBack)
+ if !ok {
+ return
+ }
+ active := c.PostForm("active") == "true"
+ if err := s.payments.SetProductActive(c.Request.Context(), id, active); err != nil {
+ s.renderConsoleMessage(c, "Cannot change status", err.Error(), catalogBack)
+ return
+ }
+ s.renderConsoleMessage(c, "Updated", "the product status was changed", catalogBack)
+}
+
+// consoleDeleteProductAction hard-deletes a never-transacted product; a transacted one is refused
+// with a hint to archive instead.
+func (s *Server) consoleDeleteProductAction(c *gin.Context) {
+ id, ok := s.consoleUUID(c, catalogBack)
+ if !ok {
+ return
+ }
+ err := s.payments.DeleteProduct(c.Request.Context(), id)
+ if errors.Is(err, payments.ErrProductTransacted) {
+ s.renderConsoleMessage(c, "Cannot delete", "this product has transactions; archive it instead", catalogBack)
+ return
+ }
+ if err != nil {
+ s.consoleError(c, err)
+ return
+ }
+ s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index a0c10a7..1853fd7 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -102,6 +102,14 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/banner-settings", s.consoleBannerSettings)
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
}
+ if s.payments != nil {
+ gm.GET("/catalog", s.consoleCatalog)
+ gm.POST("/catalog", s.consoleCreateProduct)
+ gm.GET("/catalog/:id", s.consoleCatalogDetail)
+ gm.POST("/catalog/:id", s.consoleUpdateProduct)
+ gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
+ gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
+ }
}
// consoleDashboard renders the landing page: the top-line counts and the resident