From eb7fa984268bd560ec184cd919dd4ae4299bd698 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 14 Jul 2026 10:40:27 +0200 Subject: [PATCH] feat(payments): edit the rewarded-ads config from the admin catalog page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "watch for chips" rewarded payout and its per-day / per-hour caps live in the shared payments.config row and had no admin surface — they could only be changed by SQL, contrary to D32 (the rewarded rate is meant to be admin-configurable). Add a "Rewarded ads" form on the /_gm/catalog page: RewardConfig / SetRewardConfig on the service (non-negative validated), a setRewardConfig store writer (the singleton config row), the consoleSetReward handler + POST /_gm/catalog/reward route, and the form pre-filled from the current config. No migration — the columns already exist. The reward rate is a config value, not a sellable catalog atom (so a "no-ads forever" style product is still out of scope by D32/D33). Test: an integration test sets the config, checks the page pre-fill, and refuses a negative value. --- .../templates/pages/catalog.gohtml | 9 +++++++ backend/internal/adminconsole/views.go | 6 +++++ .../internal/inttest/catalog_editor_test.go | 27 +++++++++++++++++++ backend/internal/payments/service_intake.go | 16 +++++++++++ backend/internal/payments/store_intake.go | 12 +++++++++ .../internal/server/handlers_admin_catalog.go | 21 ++++++++++++++- .../internal/server/handlers_admin_console.go | 1 + docs/PAYMENTS_DECISIONS_ru.md | 4 ++- 8 files changed, 94 insertions(+), 2 deletions(-) diff --git a/backend/internal/adminconsole/templates/pages/catalog.gohtml b/backend/internal/adminconsole/templates/pages/catalog.gohtml index 42d081b..2c94452 100644 --- a/backend/internal/adminconsole/templates/pages/catalog.gohtml +++ b/backend/internal/adminconsole/templates/pages/catalog.gohtml @@ -21,6 +21,15 @@
+

Rewarded ads (watch for chips)

+

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.

+
+ + + +
+
+

Products

Showing {{if .ShowAll}}all products (active + archived) — active only{{else}}active products — show all (incl. archived){{end}}.

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)» — маркетинговая пометка владельца;