feat(admin): admin grant — raw benefits and by-product reward bundles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
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 1m48s

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).
This commit is contained in:
Ilia Denisov
2026-07-10 05:35:55 +02:00
parent c3eecf16b3
commit d2d6955cbf
10 changed files with 308 additions and 5 deletions
+52
View File
@@ -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
}