feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s

The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored
by the caps; payout from config rewarded_payout_chips, default 0 = off) + the
wallet.reward edge op returning the updated wallet (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
This commit is contained in:
Ilia Denisov
2026-07-10 00:41:42 +02:00
parent 68c937f3b6
commit dbd76d53e8
38 changed files with 798 additions and 24 deletions
@@ -135,6 +135,36 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
}
// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
// them.
const providerVKAds = "vk_ads"
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
// VK-only, D28). The client uses it to gate the "watch for chips" button.
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return 0, nil
}
payout, _, _, err := s.store.rewardConfig(ctx)
return payout, err
}
// 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
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
// attached.
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return RewardOutcome{}, ErrUntrusted
}
return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, s.clock())
}
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
// still credits — see Fund).
+103
View File
@@ -375,6 +375,96 @@ func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, provide
return outcome, nil
}
// RewardOutcome reports a rewarded-video credit: the chips credited (0 when rewarded is unconfigured
// or the daily cap is reached), whether the daily cap blocked it, and whether it was a duplicate view
// (same client nonce) that credited nothing more.
type RewardOutcome struct {
AccountID uuid.UUID
Chips int
Capped bool
AlreadyCredited bool
}
// rewardConfig reads the rewarded payout (chips per view) and the per-day and per-hour caps. The
// caps are both anti-abuse (bounding a forger's free chips) and an economic conversion lever (free
// rewarded chips are limited so a player who wants more buys) — tuned in the admin.
func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
var cfg model.Config
if e := postgres.SELECT(table.Config.RewardedPayoutChips, table.Config.RewardDailyCap, table.Config.RewardHourlyCap).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); e != nil {
return 0, 0, 0, fmt.Errorf("payments: read reward config: %w", e)
}
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), 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
// (provider, provider_payment_id) index), so a retried view credits once, and order-less (a free
// credit, no order). The cap counts today's rewarded credits for this network (UTC day); a rare
// concurrent race may allow cap+1, which the per-user rate limiter bounds and the cap tolerates.
func (s *Store) creditReward(ctx context.Context, accountID uuid.UUID, source Source, provider, nonce string, now time.Time) (RewardOutcome, error) {
payout, dailyCap, hourlyCap, err := s.rewardConfig(ctx)
if err != nil {
return RewardOutcome{}, err
}
outcome := RewardOutcome{AccountID: accountID}
if payout <= 0 {
return outcome, nil // rewarded not configured (0 payout) — inert until the owner sets it
}
// Count this network's rewarded credits in the last day and last hour (one scan over the last
// 25 h covers both windows); either cap reached blocks the credit. A rare concurrent race may
// allow cap+1, which the per-user rate limiter bounds and the anti-abuse cap tolerates.
var today, lastHour int
if e := s.db.QueryRowContext(ctx,
`SELECT count(*) FILTER (WHERE created_at >= date_trunc('day', now())),
count(*) FILTER (WHERE created_at >= now() - interval '1 hour')
FROM payments.ledger
WHERE account_id = $1 AND kind = 'fund' AND provider = $2 AND created_at >= now() - interval '25 hours'`,
accountID, provider).Scan(&today, &lastHour); e != nil {
return RewardOutcome{}, fmt.Errorf("payments: count rewarded views: %w", e)
}
if today >= dailyCap || lastHour >= hourlyCap {
outcome.Capped = true
return outcome, nil
}
snapshot, err := marshalRewardSnapshot(payout)
if err != nil {
return RewardOutcome{}, err
}
src := source
pv, pp := provider, nonce
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if e := insertLedgerTx(ctx, tx, accountID, "fund", &src, &src, payout, nil, nil, &pv, &pp, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyCredited = true
return errAlreadyCredited
}
return e
}
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (account_id, source) DO UPDATE
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
accountID, string(src), payout); e != nil {
return fmt.Errorf("payments: credit rewarded balance: %w", e)
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyCredited) {
return outcome, nil
}
return RewardOutcome{}, err
}
outcome.Chips = payout
s.cache.invalidate(accountID)
return outcome, nil
}
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
@@ -498,6 +588,19 @@ func marshalRefundSnapshot(productID uuid.UUID, title string, chips, revoked, lo
return b, nil
}
// marshalRewardSnapshot records a rewarded-video credit on its ledger row: the marker distinguishing
// it from a paid fund, and the chips granted — so the report separates ad-earned chips from purchases.
func marshalRewardSnapshot(chips int) ([]byte, error) {
b, err := json.Marshal(struct {
Reward bool `json:"reward"`
Chips int `json:"chips"`
}{true, chips})
if err != nil {
return nil, fmt.Errorf("payments: marshal reward snapshot: %w", err)
}
return b, nil
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
func isUniqueViolation(err error) bool {