From d2d6955cbffb596cc15fac3809d46e12909a6ce9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 05:35:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20admin=20grant=20=E2=80=94=20raw?= =?UTF-8?q?=20benefits=20and=20by-product=20reward=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /_gm user card gains a 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; the by-product grant records the source product_id + snapshot. Both refuse a chips atom (never grant currency) or a tournament atom (no credit target yet); chips/tournament products are also kept out of the by-product picker. Tests: the console grant end to end (raw, by-product, refuse a chips pack, CSRF-guarded). --- backend/README.md | 6 +- .../templates/pages/user_detail.gohtml | 20 ++++ backend/internal/adminconsole/views.go | 21 ++++ backend/internal/inttest/admin_grant_test.go | 79 +++++++++++++++ backend/internal/payments/service.go | 52 ++++++++++ backend/internal/payments/store_wallet.go | 24 +++++ .../internal/server/handlers_admin_catalog.go | 95 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 3 + docs/PAYMENTS.md | 6 +- docs/PAYMENTS_ru.md | 7 +- 10 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 backend/internal/inttest/admin_grant_test.go 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`,