diff --git a/backend/README.md b/backend/README.md
index bf3adcf..083dcc3 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -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`
diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
index d79888e..98a425a 100644
--- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
@@ -85,6 +85,26 @@
{{else}}
no ledger entries
{{end}}
{{else}}payments not enabled
{{end}}
+Grant benefits
+{{if .Grant.Present}}
+A zero-price admin sale of a value — never chips. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.
+
+{{if .Grant.Products}}
+Grant a product
+
+{{else}}no grantable products — create a value product in the catalog
{{end}}
+{{else}}payments not enabled
{{end}}
+
Roles
{{$id := .ID}}
{{if .Roles}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index c1d5e66..2a34c3e 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -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
+}
diff --git a/backend/internal/inttest/admin_grant_test.go b/backend/internal/inttest/admin_grant_test.go
new file mode 100644
index 0000000..b690b2f
--- /dev/null
+++ b/backend/internal/inttest/admin_grant_test.go
@@ -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"))
+ }
+}
diff --git a/backend/internal/payments/service.go b/backend/internal/payments/service.go
index 5a8b177..a0c55ee 100644
--- a/backend/internal/payments/service.go
+++ b/backend/internal/payments/service.go
@@ -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
+}
diff --git a/backend/internal/payments/store_wallet.go b/backend/internal/payments/store_wallet.go
index 59cff1b..d348cd3 100644
--- a/backend/internal/payments/store_wallet.go
+++ b/backend/internal/payments/store_wallet.go
@@ -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) {
diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go
index bae1644..aca135b 100644
--- a/backend/internal/server/handlers_admin_catalog.go
+++ b/backend/internal/server/handlers_admin_catalog.go
@@ -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, ", ")
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 1853fd7..ef997a2 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -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)
}
diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md
index 15e6744..eadad8f 100644
--- a/docs/PAYMENTS.md
+++ b/docs/PAYMENTS.md
@@ -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
diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md
index 10ae6b0..66d6363 100644
--- a/docs/PAYMENTS_ru.md
+++ b/docs/PAYMENTS_ru.md
@@ -318,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
-подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
+подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо
+сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт
+из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
-типа `admin_grant`, цена 0 Фишек — полный аудит наград.
+типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
+полный аудит наград.
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,