The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — 0 payout turns rewarded off. This is the shared reward config, not a product.
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index d39619d..c60e6f3 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -680,6 +680,12 @@ type CatalogView struct {
// ShowAll reports whether archived products are listed alongside active ones (the active/all
// toggle); it drives the toggle link and the list heading.
ShowAll bool
+ // RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
+ // view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
+ // rewarded-ads form. A 0 payout means rewarded is inert.
+ RewardPayout int
+ RewardDailyCap int
+ RewardHourlyCap int
}
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
diff --git a/backend/internal/inttest/catalog_editor_test.go b/backend/internal/inttest/catalog_editor_test.go
index 12c7666..187baf6 100644
--- a/backend/internal/inttest/catalog_editor_test.go
+++ b/backend/internal/inttest/catalog_editor_test.go
@@ -124,3 +124,30 @@ func TestConsoleCatalogActiveToggle(t *testing.T) {
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
}
}
+
+// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
+// payout and caps, the page pre-fills the current values, and a negative value is refused.
+func TestConsoleRewardConfig(t *testing.T) {
+ srv, _, pay := bannerServer(t)
+ h := srv.Handler()
+ const origin = "http://admin.test"
+ const reward = "http://admin.test/_gm/catalog/reward"
+
+ // Set the payout and caps.
+ if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
+ t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
+ }
+ if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
+ t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
+ }
+
+ // The catalog page renders the form pre-filled with the current payout.
+ if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
+ t.Error("catalog page did not render the reward form with the current payout")
+ }
+
+ // A negative value is refused (the service validates non-negativity).
+ if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
+ t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
+ }
+}
diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go
index 2f9bedc..cf65f3e 100644
--- a/backend/internal/payments/service_intake.go
+++ b/backend/internal/payments/service_intake.go
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
return payout, err
}
+// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
+// the per-day and per-hour caps.
+func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
+ return s.store.rewardConfig(ctx)
+}
+
+// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
+// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
+// offer stale.
+func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
+ if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
+ return errors.New("payments: reward payout and caps must be non-negative")
+ }
+ return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
+}
+
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go
index 3a219dd..b435226 100644
--- a/backend/internal/payments/store_intake.go
+++ b/backend/internal/payments/store_intake.go
@@ -413,6 +413,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
}
+// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
+// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
+// and also enforced by the config CHECK constraints.
+func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
+ if _, err := s.db.ExecContext(ctx,
+ `UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
+ payout, dailyCap, hourlyCap); err != nil {
+ return fmt.Errorf("payments: set reward config: %w", err)
+ }
+ return nil
+}
+
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go
index 732d72f..a0d7686 100644
--- a/backend/internal/server/handlers_admin_catalog.go
+++ b/backend/internal/server/handlers_admin_catalog.go
@@ -46,13 +46,32 @@ func (s *Server) consoleCatalog(c *gin.Context) {
// 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)
- view := adminconsole.CatalogView{ShowAll: showAll}
+ payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
+ if err != nil {
+ s.consoleError(c, err)
+ return
+ }
+ view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
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)
+}
+
// 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}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index cfb72f9..dcf06c6 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -112,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
if s.payments != nil {
gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct)
+ gm.POST("/catalog/reward", s.consoleSetReward)
gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md
index 5bbab57..089774e 100644
--- a/docs/PAYMENTS_DECISIONS_ru.md
+++ b/docs/PAYMENTS_DECISIONS_ru.md
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
- Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
+ Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
+ rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
+ секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;