feat(admin): product catalog editor #231
+5
-1
@@ -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`
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
|
||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "content" -}}
|
||||
<h1>Product catalog</h1>
|
||||
{{with .Data}}
|
||||
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> 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 <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
|
||||
<section class="panel"><h2>Add product</h2>
|
||||
<form class="form col" method="post" action="/_gm/catalog">
|
||||
<label>Title <input type="text" name="title" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; blank = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; blank = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
|
||||
</fieldset>
|
||||
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
|
||||
<div><button type="submit">Add</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Products</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Products}}
|
||||
<tr>
|
||||
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
|
||||
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
|
||||
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
|
||||
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
|
||||
<td class="row-actions">
|
||||
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
|
||||
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<p class="note"><a href="/_gm/catalog">← all products</a></p>
|
||||
<h1>{{.Title}} {{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</h1>
|
||||
<section class="panel"><h2>Edit</h2>
|
||||
<p class="note">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 <a href="/_gm/catalog">catalog list</a>.</p>
|
||||
<form class="form col" method="post" action="/_gm/catalog/{{.ID}}">
|
||||
<label>Title <input type="text" name="title" value="{{.Title}}" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; 0 = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0" value="{{.Chips}}"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="{{.Hints}}"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="{{.NoAds}}"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0" value="{{.Tournament}}"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; 0 = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0" value="{{.PriceRUB}}"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0" value="{{.PriceVote}}"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0" value="{{.PriceStar}}"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0" value="{{.PriceChip}}"></label>
|
||||
</fieldset>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user