feat(admin): admin grant — raw benefits and by-product reward bundles #232
+5
-1
@@ -146,7 +146,11 @@ funding segment, benefits per origin, the recorded refund risk, and the append-o
|
||||
(`/_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
|
||||
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
|
||||
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
|
||||
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
|
||||
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
|
||||
atom (never grant currency; no tournament target yet). The shared wire
|
||||
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
|
||||
@@ -85,6 +85,26 @@
|
||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Grant benefits</h2>
|
||||
{{if .Grant.Present}}
|
||||
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
||||
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
||||
<div><button type="submit">Grant</button></div>
|
||||
</form>
|
||||
{{if .Grant.Products}}
|
||||
<h3>Grant a product</h3>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
||||
<div><button type="submit">Grant product</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Roles</h2>
|
||||
{{$id := .ID}}
|
||||
{{if .Roles}}
|
||||
|
||||
@@ -198,6 +198,9 @@ type UserDetailView struct {
|
||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||
// is false when the payments domain is unwired.
|
||||
Finance FinanceView
|
||||
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||
// payments domain is unwired.
|
||||
Grant GrantFormView
|
||||
}
|
||||
|
||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||
@@ -701,3 +704,21 @@ type ProductFormView struct {
|
||||
PriceChip int64
|
||||
Transacted bool
|
||||
}
|
||||
|
||||
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
|
||||
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
|
||||
// products are excluded). Present is false when the payments domain is unwired.
|
||||
type GrantFormView struct {
|
||||
Present bool
|
||||
Origins []string
|
||||
Products []GrantProductOption
|
||||
}
|
||||
|
||||
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
|
||||
// summary, and whether it is archived (the common case for a non-public reward bundle).
|
||||
type GrantProductOption struct {
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Archived bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// benefitFor returns the account's benefit on the given origin from its statement.
|
||||
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
|
||||
t.Helper()
|
||||
stmt, err := pay.AccountStatement(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
for _, b := range stmt.Benefits {
|
||||
if b.Origin == origin {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return payments.OriginBenefit{}
|
||||
}
|
||||
|
||||
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
|
||||
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
|
||||
func TestConsoleAdminGrant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
id := provisionAccount(t)
|
||||
const origin = "http://admin.test"
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
// CSRF: a grant without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("grant without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
|
||||
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
|
||||
}
|
||||
|
||||
// By-product grant: an archived reward bundle (3 hints) to vk.
|
||||
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("create reward: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
|
||||
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
|
||||
}
|
||||
|
||||
// A chips pack cannot be granted.
|
||||
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
|
||||
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create pack: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
|
||||
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source,
|
||||
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
|
||||
}
|
||||
|
||||
// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
|
||||
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
|
||||
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
|
||||
// yet), and one whose atoms yield no grantable benefit.
|
||||
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
|
||||
if !origin.Valid() {
|
||||
return fmt.Errorf("payments: invalid grant origin %q", origin)
|
||||
}
|
||||
in, err := s.store.productInput(ctx, productID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var d benefitDelta
|
||||
for _, a := range in.Atoms {
|
||||
switch a.Atom {
|
||||
case "hints":
|
||||
d.hintsAdd += a.Quantity
|
||||
case "noads_days":
|
||||
d.noAdsDays += a.Quantity
|
||||
case atomChips:
|
||||
return ErrCannotGrantChips
|
||||
case "tournament":
|
||||
return ErrCannotGrantTournament
|
||||
}
|
||||
}
|
||||
if d.zero() {
|
||||
return ErrNothingToGrant
|
||||
}
|
||||
snapshot, err := marshalGrantProduct(productID, in.Title, d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock())
|
||||
}
|
||||
|
||||
// MergeTx merges the secondary account's segments and benefits into the primary inside the
|
||||
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
||||
// after committing (Invalidate).
|
||||
@@ -244,3 +279,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) {
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
|
||||
// benefit atoms it granted (price 0).
|
||||
func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) {
|
||||
atoms := map[string]int{}
|
||||
if d.hintsAdd > 0 {
|
||||
atoms["hints"] = d.hintsAdd
|
||||
}
|
||||
if d.noAdsDays > 0 {
|
||||
atoms["noads_days"] = d.noAdsDays
|
||||
}
|
||||
b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ var (
|
||||
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
||||
// cannot be bought with chips.
|
||||
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
||||
// ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the
|
||||
// admin never grants currency (a gifted balance would bypass the cash desk, D16).
|
||||
ErrCannotGrantChips = errors.New("payments: cannot grant chips")
|
||||
// ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom,
|
||||
// which has no credit target until the tournament stage.
|
||||
ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet")
|
||||
// ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days).
|
||||
ErrNothingToGrant = errors.New("payments: product has nothing to grant")
|
||||
)
|
||||
|
||||
// withTx runs fn inside a transaction on db, rolling back on error or panic.
|
||||
@@ -312,6 +320,22 @@ func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
|
||||
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
|
||||
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.cache.invalidate(accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// consumeHint decrements one hint from the first applicable origin (in the given priority order)
|
||||
// that has one, with a guarded update. It returns whether a hint was spent.
|
||||
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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"
|
||||
@@ -181,3 +184,95 @@ func (s *Server) consoleDeleteProductAction(c *gin.Context) {
|
||||
}
|
||||
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)
|
||||
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, ", ")
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||
gm.POST("/users/:id/grant", s.consoleGrant)
|
||||
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||
gm.GET("/reasons", s.consoleReasons)
|
||||
gm.POST("/reasons", s.consoleCreateReason)
|
||||
@@ -451,6 +453,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
} else {
|
||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||
}
|
||||
view.Grant = s.grantForm(ctx)
|
||||
}
|
||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||
}
|
||||
|
||||
+4
-2
@@ -319,10 +319,12 @@ cache. Identity-presence (which segments are awake, §6) is supplied by the call
|
||||
here, so unlink/re-link takes effect immediately.
|
||||
|
||||
**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never
|
||||
chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the
|
||||
chips** (a gifted currency balance = a store cash-desk bypass). It grants either raw atoms or a
|
||||
**defined value product** (a reward bundle, which may be archived — hidden from the store but
|
||||
grantable); both refuse a `chips` or `tournament` atom. The admin **picks the
|
||||
origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk,
|
||||
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
||||
chips — full audit of rewards.
|
||||
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
|
||||
|
||||
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
||||
spends, grants, refunds, full history — as an extension of the existing user card
|
||||
|
||||
+5
-2
@@ -318,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
||||
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
|
||||
|
||||
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
|
||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо
|
||||
сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт
|
||||
из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ
|
||||
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
||||
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
||||
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
|
||||
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||
полный аудит наград.
|
||||
|
||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||
|
||||
Reference in New Issue
Block a user