feat(admin): product catalog editor
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
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).
This commit is contained in:
+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
|
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
|
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
|
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.
|
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||||
|
|
||||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
**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/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/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/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/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/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
<a href="/_gm/grafana/">Grafana ↗</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
|
UserTZ string
|
||||||
Banned bool
|
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.GET("/banner-settings", s.consoleBannerSettings)
|
||||||
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
|
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
|
// consoleDashboard renders the landing page: the top-line counts and the resident
|
||||||
|
|||||||
Reference in New Issue
Block a user