1507ceb793
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or one account — and show the user a localized reason on their next attempt, so a provider outage or a misconfig is explained instead of a silent dead button. - rail kill switch (payments.rail_status, per rail direct:web / direct:android / vk / telegram): enabled + a per-language message, edited on the catalog page. Fail-open — a rail with no row stays enabled, so payments are never accidentally killed. The intake gate (CanPurchase in handleWalletOrder, before the order) returns payment_unavailable + the localized message, orthogonal to the security gates. - per-account override (payments.account_payment_override, a row only for non-default): allow / deny / default, edited on the user card. "allow" bypasses ONLY the ops rail switch, never the security gates (trusted platform, the email anchor, the VK-iOS freeze, the min client version). - wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe); the gateway forwards a backend domain-error message; the client shows it on a payment_unavailable buy attempt. - admin: rail toggles on the catalog page, the override control on the user card. - tests: the pure gate (unit, TDD), the store + gate + override end-to-end (integration, migration 00016), the client (svelte-check / vitest). - docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays cabinet-side (owner decision) — no itemized-receipt code. Contour-safe: additive migration (two new tables, no wipe), the wire add is additive, and fail-open so nothing is disabled until an operator acts.
329 lines
12 KiB
Go
329 lines
12 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
|
||
"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 the catalog products with their composition, prices, transacted flag, and the
|
||
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||
// active/all toggle).
|
||
func (s *Server) consoleCatalog(c *gin.Context) {
|
||
showAll := c.Query("all") == "1"
|
||
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||
if err != nil {
|
||
s.consoleError(c, err)
|
||
return
|
||
}
|
||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||
payments.SortAdminCatalog(products)
|
||
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||
if err != nil {
|
||
s.consoleError(c, err)
|
||
return
|
||
}
|
||
rails, err := s.payments.RailStatuses(c.Request.Context())
|
||
if err != nil {
|
||
s.consoleError(c, err)
|
||
return
|
||
}
|
||
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||
for _, rail := range payments.KnownRails {
|
||
a := rails[rail]
|
||
view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN})
|
||
}
|
||
for _, p := range products {
|
||
view.Products = append(view.Products, catalogRow(p))
|
||
}
|
||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||
}
|
||
|
||
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||
// refused by the service.
|
||
func (s *Server) consoleSetReward(c *gin.Context) {
|
||
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||
return
|
||
}
|
||
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
|
||
}
|
||
|
||
// consoleSetRailStatus toggles a payment rail's operational kill switch and its user-facing
|
||
// off-message (per language) from the catalog page's payment-availability form. An unchecked box
|
||
// disables the rail; an unknown rail is refused by the service.
|
||
func (s *Server) consoleSetRailStatus(c *gin.Context) {
|
||
rail := strings.TrimSpace(c.PostForm("rail"))
|
||
a := payments.RailAvailability{
|
||
Enabled: c.PostForm("enabled") == "on",
|
||
MessageRU: strings.TrimSpace(c.PostForm("message_ru")),
|
||
MessageEN: strings.TrimSpace(c.PostForm("message_en")),
|
||
}
|
||
if err := s.payments.SetRailStatus(c.Request.Context(), rail, a); err != nil {
|
||
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||
return
|
||
}
|
||
s.renderConsoleMessage(c, "Saved", "payment availability was updated", catalogBack)
|
||
}
|
||
|
||
// 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(), true) // include archived: the detail form edits any product
|
||
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)
|
||
}
|
||
|
||
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
|
||
// zero-price admin sale.
|
||
func (s *Server) consoleGrant(c *gin.Context) {
|
||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||
if !ok {
|
||
return
|
||
}
|
||
back := "/_gm/users/" + id.String()
|
||
if s.payments == nil {
|
||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||
return
|
||
}
|
||
origin := payments.Source(c.PostForm("origin"))
|
||
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
|
||
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
|
||
forever := c.PostForm("forever") != ""
|
||
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
|
||
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||
return
|
||
}
|
||
s.publishBannerChange(id)
|
||
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
|
||
}
|
||
|
||
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
|
||
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
|
||
func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||
if !ok {
|
||
return
|
||
}
|
||
back := "/_gm/users/" + id.String()
|
||
if s.payments == nil {
|
||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||
return
|
||
}
|
||
origin := payments.Source(c.PostForm("origin"))
|
||
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
|
||
if err != nil {
|
||
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
|
||
return
|
||
}
|
||
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
|
||
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||
return
|
||
}
|
||
s.publishBannerChange(id)
|
||
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
|
||
}
|
||
|
||
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
|
||
// bundles, including archived ones — chips and tournament products are excluded).
|
||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||
products, err := s.payments.AdminCatalog(ctx, true)
|
||
if err != nil {
|
||
return fv
|
||
}
|
||
for _, p := range products {
|
||
if grantableProduct(p) {
|
||
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
|
||
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
|
||
})
|
||
}
|
||
}
|
||
return fv
|
||
}
|
||
|
||
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
|
||
// atom (hints / no-ads days) and no chips or tournament atom.
|
||
func grantableProduct(p payments.AdminProduct) bool {
|
||
benefit := false
|
||
for _, a := range p.Atoms {
|
||
switch a.Atom {
|
||
case "chips", "tournament":
|
||
return false
|
||
case "hints", "noads_days":
|
||
benefit = true
|
||
}
|
||
}
|
||
return benefit
|
||
}
|
||
|
||
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
|
||
func atomSummary(atoms []payments.AtomLine) string {
|
||
parts := make([]string, 0, len(atoms))
|
||
for _, a := range atoms {
|
||
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|