Compare commits

..

38 Commits

Author SHA1 Message Date
developer 00d1bd33e3 Merge pull request 'feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account' (#229) from feature/ads-interstitial into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 01:48:10 +00:00
Ilia Denisov 7ce8101cfa test(ads): lock the hint-pushed ad restarting the vs_ai gap (no over-serving)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
2026-07-10 03:43:57 +02:00
Ilia Denisov b5c8a04f0b fix(ads): share one interstitial cooldown timer across kinds
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Cooldowns were tracked per kind ({move,hint}), so a hint ad and a move ad
did not see each other: after a hinted move's ad the move timer was still
zero, and the next plain move fired an ad immediately — two ads within a
minute, under the cooldown. Track a single shared last-shown time; the kind
only selects the required gap (hint 1m, move 5m, vs_ai 30m) measured from the
last interstitial of any kind. A hint can still fire on its shorter gap
(D30's "independent" hint cooldown), but never stacks a second ad within a
cooldown.

Tests: a hint uses the shorter shared gap; a move does not stack onto a
just-shown hint ad.
2026-07-10 03:36:18 +02:00
Ilia Denisov 2ce80c241d fix(ads): fire the hint interstitial on the confirmed move, not on the hint
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Taking a hint fired the post-move interstitial immediately, when the hint's
preview tiles landed — interrupting the turn and reverting the board to the
rack on the ad's close while the hint stayed spent. Move the trigger to the
move confirmation: a hint applied this turn marks it (hintUsedThisTurn), and
the confirmed play fires the hint-kind interstitial (its own cooldown)
instead of the plain move one; the marker clears on any turn boundary
(applyMoveResult) so a hint-then-pass does not leak into the next move.

e2e: taking a hint fires no ad; confirming a move does.
2026-07-10 03:12:30 +02:00
Ilia Denisov 13be7c3d9a feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline
banner-only. The gate is client-mirrored: the backend puts the config
cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the
no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor;
the client self-gates on a per-kind last-shown time in localStorage, with
the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass,
exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec
and gateway transcode carry the ads block.

Also retires the deprecated accounts.hint_balance / paid_account domain
usage (expand-contract, code only — the columns stay for a later DROP so
image rollback stays DB-safe): drop the Account fields and their scan, the
dead account.SpendHint, account.GrantHints and the admin grant-hints
action; the in-game hint display now comes wholly from the payments hint
benefit. Banner eligibility and account merge no longer read the legacy
flags.

Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip,
the gateway ads transcode test, and a profile ads-config integration test
(cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru)
interstitial, the decision amends, backend README, the plan.
2026-07-10 02:48:10 +02:00
developer e08d3301bd Merge pull request 'feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps' (#228) from feature/ads-rewarded into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-09 23:33:10 +00:00
Ilia Denisov 9acf6ab3b4 fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Testing the rewarded slice surfaced a contradiction: rewarded ads let a VK-iOS
user earn vk chips, but the blanket VK-iOS "spend freeze" then blocked spending
them (the wallet showed "5 (view-only)"). Apple's ToS forbids only BUYING in-app
values on VK-iOS (money -> chips), not spending or earning them — so the freeze is
corrected to purchase-only: vkFrozen() now gates only CreateOrder (the money-in
step), not spendableSources (spending). VK-wallet chips — earned via rewarded ads
or bought on the same account elsewhere (VK Android) — now spend on VK-iOS too.
(Owner's ToS finding.)

Also: the temporary contour diagnostic confirmed VK returns only {result:true} for
a rewarded view (no token/signature) — client-attested is final, no hardening
possible — so the diagnostic (the log and the diag wire field) is removed.

Docs: PAYMENTS(+ru) VK-iOS freeze + D17 amend + PLAN E6. Tests updated (gate /
wallet VK-iOS now spendable).
2026-07-10 01:27:26 +02:00
Ilia Denisov dbd76d53e8 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).
2026-07-10 00:41:42 +02:00
developer 68c937f3b6 Merge pull request 'feat(payments): refund engine (best-effort revoke, never negative)' (#227) from feature/payment-intake-refunds into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
2026-07-09 21:26:33 +00:00
Ilia Denisov 21fb14facf feat(payments): refund engine (best-effort revoke, never negative)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
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 1m45s
The last money-intake slice: reverse a paid order best-effort, exactly once. All
refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa
via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so
this ships the engine they all converge on, not a webhook.

The Refund method matches the paid order, appends a refund ledger row (idempotent on
(provider, provider_refund_id) — distinct from the fund's payment id, so both
coexist), and revokes the funded chips floored at 0 (never negative — D27,
balances_chips_chk). When the chips were already spent, the unrecoverable remainder
is recorded as a per-account loss + abuse flag in the new additive
payments.account_risk table (read by the E7 report). The refund ledger row's chip
delta is what was actually reclaimed (the ledger stays reconcilable); the full
reversal rides in the snapshot; the order stays paid.

Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa
refund-status polling is deferred (a worker not worth it at low chargeback volume);
failed events are not wired (no rail signals a hard post-charge server decline).

Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse;
duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
2026-07-09 23:21:10 +02:00
developer ee9924c313 Merge pull request 'feat(payments): Telegram Stars payment rail' (#226) from feature/payment-intake-tg-stars into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-09 20:44:06 +00:00
Ilia Denisov e10f3beed5 chore(ui): raise the app bundle budget 123->125 for the payment intake rails
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
The wallet order flow plus the Telegram Stars openInvoice / VK order-box launch
and the per-button in-flight state ride the always-loaded Wallet screen, which
crossed the 123 KB gzip ceiling. Bump to 125 (matching the established
feature-driven budget history in the comment) with ~2 KB of headroom.
2026-07-09 22:37:30 +02:00
Ilia Denisov 55dbb1005e fix(ui): scope the wallet buy-button busy dim to the tapped button
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Tapping one pack's Buy dimmed every buy button at once (the shared busy
flag drove disabled + the :disabled opacity on all of them), so it looked
like both packs reacted to one tap. Track the in-flight product id
(busyId): the concurrency guard stays global, but the pressed/dimmed look
is now scoped to the tapped button. Payments were never affected.
2026-07-09 22:26:28 +02:00
Ilia Denisov 219fc7c827 chore(deploy): re-roll the test contour to pick up the TEST_AWG_CONF DNS fix
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The contour VPN sidecar's netns resolv.conf was pinned to 1.1.1.1 by a DNS=
directive in TEST_AWG_CONF, so internal names (gateway, otelcol) did not resolve
and the bot-link had been down since ~2026-06-29. The directive is removed; this
empty commit re-rolls the contour so the vpn recreates with Docker's 127.0.0.11
resolver and the bot-link (and the Stars rail over it) comes up.
2026-07-09 21:55:53 +02:00
Ilia Denisov 6e03ce0131 feat(payments): Telegram Stars payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail
alongside Robokassa (direct) and VK Votes.

Only the bot reaches Telegram, so the rail funnels through the reverse
mTLS bot-link:
- the gateway mints the invoice on a CreateInvoice command (the bot
  calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice);
- the bot gates each pre_checkout_query via a ValidatePreCheckout unary
  (the order must exist, be still creditable and not already paid — the
  reusable-invoice double-pay guard; the decline reason is localised to
  the order account's language);
- a completed successful_payment is queued in a durable pure-Go SQLite
  outbox and forwarded via a ForwardPayment unary, credited once
  (idempotent on telegram_payment_charge_id, honours an expired order),
  re-driven on restart and every 30s.

The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays
inert until a chip pack carries an XTR price, so seeding a Stars price in
the admin is the go-live.

Tests: backend integration (order->forward->credit once, duplicate,
pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) +
executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the
platform/telegram README, PLAN.
2026-07-09 21:35:29 +02:00
developer 5612bb624d Merge pull request 'fix(ui): VK-iOS store link from the deployment origin' (#225) from fix/vk-ios-link-origin into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s
2026-07-09 18:30:54 +00:00
Ilia Denisov 8230253142 fix(ui): derive the VK-iOS store link from the deployment origin
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Replace the hardcoded erudit-game.ru host with location.origin, so the iOS
store note's "other version" link points at this deployment's own site root
(prod or the contour) without a hardcoded host or build-arg wiring.
2026-07-09 20:26:25 +02:00
developer 890a887257 Merge pull request 'feat(payments): VK Votes payment rail' (#224) from feature/payment-intake-vk into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-09 18:23:24 +00:00
Ilia Denisov e33b9864c8 fix(ui): point the VK-iOS "other version" link at the site root
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m23s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The landing at / lists every version's entry; /app/ dropped the player straight
into the web game. Link the iOS store note to the root instead.
2026-07-09 20:18:42 +02:00
Ilia Denisov f6aa0ba4b0 fix(ui): correct the VK-iOS purchase block + add an iOS store note
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
The VK-iOS detection used platformSubtype(), which is server-derived for VK and
so never reported iOS on the client — the pack CTA stayed active and a tap hit a
403 with a generic error. Read the device family from the signed vk_platform
launch param (normalizeSubtype(vkPlatform())) instead. Under the store header on
VK-iOS, add a note that purchases are prohibited by Apple policy, linking to the
web version (opened through VK's external-link route).
2026-07-09 20:11:35 +02:00
Ilia Denisov f48ebf2151 fix(ui): VK-iOS shows the pack CTA disabled with a toast, not a failed buy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Money purchases are not permitted in the VK iOS app (Apple ToS), and the backend
already refuses them (the CreateOrder VK-iOS freeze). Show the pack "Buy" muted
there and explain on tap ("purchases are not available on this platform"),
instead of letting the tap hit a 403 and a generic error toast. The storefront
still lists the packs.
2026-07-09 19:57:10 +02:00
Ilia Denisov 3bb9f10fad feat(payments): VK Votes payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Wire the VK Mini Apps ("голоса") rail end to end, reusing the intake engine. The
wallet.order endpoint branches by rail: a VK context opens a pending order
(provider vk) and returns its id, which the client passes to VKWebAppShowOrderBox.
VK's two-phase payment callback is verified at the gateway with the app protected
key (GATEWAY_VK_APP_SECRET) and proxied to a backend intake handler: get_item
returns the ordered pack's title and vote price; a chargeable order_status_change
credits the vk segment exactly once (the same Fund, idempotent on VK's own order
id) and records a succeeded event, so the dispatcher push refreshes the wallet.
Integration test for the VK order->credit path.
2026-07-09 19:27:57 +02:00
Ilia Denisov 3e0763463f feat(gateway): VK payment callback signature verifier
Add the vkpay package: verify a VK Mini Apps payment ("голоса") callback
signature — MD5 (mandated by VK's payment protocol) of the sig-excluded
parameters, sorted by name and concatenated key=value, with the app secret
appended; case-insensitive over the hex digest. Unit-tested (valid / tampered /
wrong-secret / missing). First piece of the VK rail; the two-phase callback
handler, the VK order branch and the client bridge follow.
2026-07-09 18:56:00 +02:00
developer 96adf98e1d Merge pull request 'feat(payments): Robokassa direct-rail payment intake' (#223) from feature/payment-intake-robokassa into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m50s
2026-07-09 16:33:12 +00:00
Ilia Denisov 3367cc2bf1 feat(payments): payment-event dispatcher + the provider-return UX
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Deliver payment_events to connected clients as an in-app wallet-refresh push: a
background dispatcher drains undispatched events and publishes a KindNotification
"payment" signal, marking each delivered; the client bumps a wallet-refresh
counter the open Wallet screen watches, re-fetching in place. A return-focus
refetch is the fallback. The Robokassa Success/Fail return now serves a
self-closing page (the payment opens in a separate window) so the customer drops
back into the live app instead of a cold start. Integration test for the event
drain/mark queue.
2026-07-09 18:26:06 +02:00
Ilia Denisov 04435a3283 docs(payments): bake the E5 direct-rail decisions + a /pay/ CI probe
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m40s
Record the E5 delivery and resolved decisions in PLAN.md (Shp_order matching,
order-id idempotency, cabinet-side receipt, never-negative → schema-free) and
mark the stage WIP. Add a CI probe asserting /pay/robokassa/result reaches the
gateway rather than the landing catch-all.
2026-07-09 17:48:29 +02:00
Ilia Denisov be0e4995f3 feat(deploy): wire the Robokassa direct rail into the contour
Map the Robokassa merchant login + Password1/Password2 into the backend
container env from the deploy secrets (TEST_BACKEND_ROBOKASSA_*), and force
IsTest on the test contour so it can never take real money. An empty login
leaves the direct order + callback endpoints unregistered.
2026-07-09 17:45:54 +02:00
Ilia Denisov 936a70ab94 feat(ui): wire the chip-pack purchase to Robokassa
Replace the disabled "Soon" pack action with a real purchase: the Wallet opens a
money order (wallet.order) and sends the player to the provider's hosted-payment
page (window.open via openExternalUrl); the chips are credited later by the
verified server callback. Add a public-offer link under the packs (paying
accepts the offer). Codec order round-trip unit test + a mock-e2e purchase test;
the Google Play stub and the chip-spend paths are unchanged.
2026-07-09 17:43:02 +02:00
Ilia Denisov 4f6c22d669 feat(gateway): the Robokassa /pay/ edge routes
Add the public /pay/robokassa/result callback proxy (rate-limited; forwards the
provider's form parameters to the backend intake, the single writer, and echoes
its "OK<InvId>" back to Robokassa) and the /pay/robokassa/{success,fail}
browser-return redirects into the app. Route /pay/* to the gateway in the
contour Caddyfile so the callback reaches the edge, not the landing catch-all.
The backend intake now returns the echo body as JSON for the gateway to relay.
2026-07-09 17:33:22 +02:00
Ilia Denisov 2a6dc5a304 feat(gateway): wire the wallet.order edge call for the direct rail
Add the WalletOrderRequest / WalletOrderResponse FlatBuffers messages and the
wallet.order Connect op: the gateway decodes the order request, forwards it to
the backend POST /wallet/order and returns the created order id plus the
provider launch URL the client opens. Regenerate the committed Go and TS
FlatBuffers code.
2026-07-09 17:30:39 +02:00
Ilia Denisov a66a5bfa08 test(payments): integration coverage for the intake credit path
Cover the order→callback→credit path over Postgres: a funded order credits its
segment exactly once; a replayed callback for the same order credits nothing
(the ledger idempotency index holds); a mismatched paid amount is refused with
no write; an expired pending order is still honoured by a late valid callback.
2026-07-09 17:04:14 +02:00
Ilia Denisov 36a5730e52 feat(payments): direct-rail order + intake handlers, config and reaper
Wire the Robokassa direct rail into the backend transport. POST
/api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the
direct rail) opens a pending order and returns the signed Robokassa payment
URL. The internal, gateway-only /payments/robokassa/result endpoint verifies
the Result signature, credits the matched order exactly once via Fund (honoured
even if expired), records a succeeded payment event, and answers Robokassa's
"OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check
(D36), the payment_events writer, and a periodic pending-order reaper. The
routes register only when a Robokassa merchant login is configured.
2026-07-09 16:59:34 +02:00
Ilia Denisov 7860efce48 feat(payments): order-flow and fund credit engine + the Robokassa adapter
Add the payment-intake write path (provider-agnostic) and the Robokassa
direct-rail glue, both unit-tested; transport, wire and UI follow.

- payments: extend the ledger insert to thread order_id/provider/
  provider_payment_id (spend/grant pass nil); add the order store
  (create/read/expire + a pack-price loader) and the fund credit — a
  fund ledger row + a guarded balance upsert + mark-paid in one tx,
  idempotent on the (provider, provider_payment_id) unique index, cache
  invalidated after commit. A valid callback is honoured even on an
  expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for
  the provider amount field.
- robokassa: build the signed hosted-payment URL (SHA-256, order id via
  Shp_order, InvId unused) and verify the Result callback signature
  (Password2), extracting the order and amount. Receipt/fiscalisation is
  configured shop-side, so no Receipt parameter is sent.
2026-07-09 16:48:48 +02:00
developer ca60025fbe Merge pull request 'feat(ui): public offer page at /offer/ with a landing footer link' (#222) from feature/public-offer-page into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m38s
2026-07-09 14:12:28 +00:00
Ilia Denisov 0bdc034f7a chore: offer formatting
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
2026-07-09 16:04:56 +02:00
Ilia Denisov 625fc3135a feat(ui): serve the public offer at /offer/ with a landing footer link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
The offer is the legal document a purchase accepts, needed before real-money
intake goes live. Convert the source PDF to an editable ui/legal/offer_ru.md and
render it to a standalone static dist/offer/index.html at build (a vite
emit-offer plugin using marked); the landing container serves it at /offer/,
with a bare /offer redirecting in. Add a small centered "Публичная оферта"
footer link on the landing (ru/en) and a CI probe asserting /offer/ serves the
page rather than silently falling through to the landing shell.
2026-07-09 16:03:50 +02:00
developer a118c63411 Merge pull request 'fix(deploy): run the base-backup timer's pgBackRest as the postgres role' (#221) from feature/pitr-timer-fix into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-09 00:07:52 +00:00
Ilia Denisov 7123ba439d fix(deploy): run the base-backup timer's pgBackRest as the postgres role
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
docker exec defaults to root, so the systemd base-backup timer connected to the
database as role "root" (then "postgres") — neither exists; the superuser role is
POSTGRES_USER (scrabble). Run the timer's pgBackRest as the postgres OS user (-u postgres,
for lock-dir/PGDATA consistency with archive-push) and connect with --pg1-user=scrabble.
archive_command (run by the postgres server process) was already correct.

Also record the point-in-time-recovery arming + restore drill in deploy/README.md
(correct the runbook commands to the same invocation, fill the drill log) and mark the
durability work done in PLAN.md.
2026-07-09 02:03:59 +02:00
116 changed files with 6503 additions and 439 deletions
+42
View File
@@ -366,6 +366,13 @@ jobs:
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
# (independent of the shop's own mode). Empty login leaves the direct rail off.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: "1"
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }} SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana # Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
@@ -400,6 +407,9 @@ jobs:
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below. # the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the
# contour (empty = real ads, for capturing the real VK ad result). Prod never sets it.
VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the # VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults. # compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
@@ -497,6 +507,38 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /offer/ is a static page baked into the landing image (rendered from
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
# silently falls through to the landing shell (also 200) — so assert offer-specific
# content, never just the status.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742"; then
echo "ok: /offer/ serves the public offer page"
else
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
docker logs --tail 50 scrabble-landing || true
exit 1
fi
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
# a 404 landing.html), which proves the edge route is wired.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
echo "ok: /pay/ reaches the gateway (non-landing response)"
else
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
docker logs --tail 50 scrabble-gateway || true
exit 1
fi
- name: Probe the /dict edge route reaches the gateway - name: Probe the /dict edge route reaches the gateway
run: | run: |
set -u set -u
+112 -29
View File
@@ -33,9 +33,9 @@ status — without re-deriving decisions.
| E1 | Trusted platform signal | 1 | DONE | | E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | DONE | | E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE | | E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | WIP | | E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | TODO | | E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | TODO | | E6 | Ads | 2 | DONE |
| E7 | Admin & reports | 2 | TODO | | E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO | | E8 | Guest limits | — | TODO |
| E9 | Tournament fee | future | TODO | | E9 | Tournament fee | future | TODO |
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
## E4 — Durability (PITR) ## E4 — Durability (PITR)
**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before **Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** ·
E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4). depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first **Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
real money** is accepted (E5 prod). Protects both money and game data. real money** is accepted (E5 prod). Protects both money and game data.
@@ -479,30 +479,73 @@ real money** is accepted (E5 prod). Protects both money and game data.
`-n backend`, silently excluding `payments`); manual-restore runbook updated. `-n backend`, silently excluding `payments`); manual-restore runbook updated.
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`. - Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.
**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates **Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3
the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`); bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps: Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
`deploy/README.md` (point-in-time recovery — arming). intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming).
**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp), **Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config` recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
valid + the custom PG image builds (archiving inert on the contour). valid + the custom PG image builds (archiving inert on the contour).
**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL **Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an
archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook isolated one-shot target; cost + perf assessment reviewed; runbook current in
current in `deploy/README.md`. `deploy/README.md`.
**Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart **Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart
from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble`
window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR. (docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
--- ---
## E5 — Payment intake ## E5 — Payment intake
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. **Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД
receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a
chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is
**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`:
the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the
`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36
confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public
`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the
`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a
self-closing provider-return page (the payment opens in a separate window) and a return-focus
refetch fallback. The **VK Votes rail** is delivered too: the client opens
`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack
title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent
on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`,
already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse
bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's
`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`**
unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox**
(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement).
**Goal.** Accept real money on all three rails into the payments domain: order-flow, **Goal.** Accept real money on all three rails into the payments domain: order-flow,
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
@@ -525,12 +568,14 @@ receipts, and refunds.
**TG bot outbox (`platform/telegram/`).** **TG bot outbox (`platform/telegram/`).**
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a - The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
to a backend payments-intake endpoint (internal, authenticated over the existing reverse mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`. mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed. `telegram_payment_charge_id`.
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
`CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.
**Events & notifications.** **Events & notifications.**
@@ -542,9 +587,13 @@ receipts, and refunds.
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK **Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
handles Votes tax itself; TG Stars — no receipt. handles Votes tax itself; TG Stars — no receipt.
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external **Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built). VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort,
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).
**Tests.** **Tests.**
@@ -569,9 +618,42 @@ force-recreate when the Caddyfile changes.
## E6 — Ads ## E6 — Ads
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · **Status:** DONE · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10. mechanics: PAYMENTS §10.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the 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
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on
`feature/ads-interstitial` (the **interstitial + D31** slice): the post-move fullscreen interstitial
as a **client-mirrored** gate — the backend `adsFor` puts the config cooldowns + a `suppressed` flag
(the no-ads / `no_banner` gate, same as the banner) on the profile (`Profile.ads`), and
`ui/src/lib/ads.ts` `maybeShowInterstitial` self-gates on the last-shown time per kind in
`localStorage`, showing a VK interstitial (`vkShowInterstitial`) after a **confirmed play or a hint
only** (never a pass / exchange / resign), VK-only, offline banner-only, with the same `VITE_ADS_STUB`
toast on the contour. The slice also lands **D31 step 1 (contract-code)**: the domain no longer reads
or writes the deprecated `accounts.hint_balance` / `paid_account` columns — the `Account` fields, the
dead `account.SpendHint`, `account.GrantHints` and the admin **grant-hints** action are removed, and
the in-game hint display now comes wholly from the payments benefit (`HintsAvailable`). The **columns
stay** (no migration → image rollback is DB-safe); a later contract-PR does the `DROP` once E6 is
stable on prod.
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video **Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
(credits chips via server verify), plus extending the existing banner suppression to (credits chips via server verify), plus extending the existing banner suppression to
per-origin. per-origin.
@@ -586,8 +668,9 @@ per-origin.
- **Interstitial** (post-move fullscreen), configurable server values (from `payments` - **Interstitial** (post-move fullscreen), configurable server values (from `payments`
config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint
application triggers a post-move interstitial independently with its own 1-min cooldown; application triggers a post-move interstitial independently with its own 1-min cooldown;
offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored**
(per user) or client-mirrored from a server value — pick and document at implementation. (the chosen option): the server sends the cooldowns + `suppressed` on the profile and the
client self-gates on a per-kind last-shown time in `localStorage` — no per-move round-trip.
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate - **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate
on the **origin benefit applicable in the current context** (E2 interface) instead of the on the **origin benefit applicable in the current context** (E2 interface) instead of the
single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed. single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
+8 -6
View File
@@ -135,21 +135,23 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional `/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs applicable in the current context and no **`no_banner`** role; the message language picked by
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire `preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place.
The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The shared wire
contracts live in the sibling [`../pkg`](../pkg) module. contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link` **Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
attached to the current account, and when the identity already has its own account attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint the two are merged in one transaction (`internal/accountmerge`) — stats summed,
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred, identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
shared finished game's foreign keys hold); a shared **active** game blocks the merge. shared finished game's foreign keys hold); a shared **active** game blocks the merge.
The current account is primary, except a guest initiator whose linked identity has a The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it. durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the The `accounts.merged_into`/`merged_at` columns back this. This supersedes the
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
Rate-limit observability: the gateway posts its periodic rejection Rate-limit observability: the gateway posts its periodic rejection
+54
View File
@@ -308,6 +308,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Notifier: hub, Notifier: hub,
ExportSignKey: cfg.ExportSignKey, ExportSignKey: cfg.ExportSignKey,
Renderer: renderer, Renderer: renderer,
Robokassa: cfg.Robokassa,
}) })
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -316,6 +317,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("servers starting", logger.Info("servers starting",
zap.String("http_addr", cfg.HTTPAddr), zap.String("http_addr", cfg.HTTPAddr),
zap.String("grpc_addr", cfg.GRPCAddr)) zap.String("grpc_addr", cfg.GRPCAddr))
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
// still credits). Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, logger)
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
errc := make(chan error, 2) errc := make(chan error, 2)
go func() { errc <- pushSrv.Run(ctx) }() go func() { errc <- pushSrv.Run(ctx) }()
go func() { errc <- srv.Run(ctx) }() go func() { errc <- srv.Run(ctx) }()
@@ -325,6 +333,52 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err return err
} }
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
// order.
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := p.ExpireOrders(ctx); err != nil {
log.Warn("order reaper: sweep failed", zap.Error(err))
} else if n > 0 {
log.Info("order reaper: expired pending orders", zap.Int("count", n))
}
}
}
}
// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh
// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit
// already committed to the ledger; this is only the in-app push so an open wallet updates in place.
func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
evs, err := p.UndispatchedEvents(ctx, 50)
if err != nil {
log.Warn("payment dispatcher: read failed", zap.Error(err))
continue
}
for _, e := range evs {
pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment))
if err := p.MarkEventDispatched(ctx, e.EventID); err != nil {
log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err))
}
}
}
}
}
// newMailer builds the confirm-code mailer: an SMTP relay when a host is // newMailer builds the confirm-code mailer: an SMTP relay when a host is
// configured, otherwise the development log mailer (the code is logged, not sent). // configured, otherwise the development log mailer (the code is logged, not sent).
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer { func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
+16 -56
View File
@@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found")
// local-time window (in TimeZone) during which the player is asleep, so the // local-time window (in TimeZone) during which the player is asleep, so the
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's // turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
// own sleep is anchored to its human opponent's timezone with a per-game drift, // own sleep is anchored to its human opponent's timezone with a per-game drift,
// computed in internal/robot, not from a robot account's away window.) HintBalance // computed in internal/robot, not from a robot account's away window.)
// is the player's wallet of purchasable hints, spent after a game's per-seat
// allowance.
type Account struct { type Account struct {
ID uuid.UUID ID uuid.UUID
DisplayName string DisplayName string
@@ -53,7 +51,6 @@ type Account struct {
TimeZone string TimeZone string
AwayStart time.Time AwayStart time.Time
AwayEnd time.Time AwayEnd time.Time
HintBalance int
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
// VariantPreferences is the set of game variants (engine.Variant stable labels: // VariantPreferences is the set of game variants (engine.Variant stable labels:
@@ -69,10 +66,6 @@ type Account struct {
// true (the default): the platform side-service skips out-of-app push for the // true (the default): the platform side-service skips out-of-app push for the
// account. // account.
NotificationsInAppOnly bool NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated.
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or // MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid. // foreign keys of a shared finished game stay valid.
@@ -393,6 +386,21 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
return out, nil return out, nil
} }
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
}
}
return false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated // ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset. // by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
@@ -548,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
return modelToAccount(row), nil return modelToAccount(row), nil
} }
// SpendHint atomically decrements the account's hint wallet by one, returning
// true when a hint was spent and false when the balance was already empty. The
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(id)).
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
}
return n > 0, nil
}
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
// positive: the additive update can only raise the balance, never lower it, so it enforces the
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
// It returns ErrNotFound when no account matches.
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
if n <= 0 {
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
}
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.HintBalance)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("account: grant hints %s: %w", id, err)
}
return int(row.HintBalance), nil
}
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when // FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
// the account is not already flagged — the first sustained episode wins, and a // the account is not already flagged — the first sustained episode wins, and a
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not // re-flag after an operator clear starts a fresh timestamp. An infra marker, not
@@ -649,12 +611,10 @@ func modelToAccount(row model.Accounts) Account {
TimeZone: row.TimeZone, TimeZone: row.TimeZone,
AwayStart: row.AwayStart, AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd, AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat, BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests, BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest, IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly, NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto, MergedInto: mergedInto,
FlaggedHighRateAt: flaggedHighRateAt, FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
@@ -10,8 +10,6 @@
<li><b>Timezone</b> {{.TimeZone}}</li> <li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li> <li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li> <li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}} {{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}} {{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li> <li><b>Created</b> {{.CreatedAt}}</li>
@@ -21,10 +19,6 @@
<button type="submit">Clear high-rate flag</button> <button type="submit">Clear high-rate flag</button>
</form> </form>
{{end}} {{end}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
<button type="submit">Grant</button>
</form>
</section> </section>
<section class="panel"><h2>Statistics</h2> <section class="panel"><h2>Statistics</h2>
{{if .HasStats}} {{if .HasStats}}
+4 -9
View File
@@ -149,7 +149,6 @@ type UserDetailView struct {
TimeZone string TimeZone string
Guest bool Guest bool
NotificationsInAppOnly bool NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a // MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account. // merge, or empty for a live account.
MergedInto string MergedInto string
@@ -165,14 +164,10 @@ type UserDetailView struct {
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action. // empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string FlaggedHighRateAt string
HintBalance int CreatedAt string
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the HasStats bool
// server's maxHintGrant), passed through so the policy value lives in one place. Stats StatsRow
HintGrantMax int Identities []IdentityRow
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
// HasEmail gates the "Erase email" action; set when the account carries an email identity. // HasEmail gates the "Erase email" action; set when the account carries an email identity.
HasEmail bool HasEmail bool
Games []GameRow Games []GameRow
+15
View File
@@ -14,6 +14,7 @@ import (
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
) )
@@ -64,6 +65,9 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g. // RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact. // http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
// leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
} }
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
@@ -153,6 +157,13 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
} }
robo := robokassa.Config{
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
}
c := Config{ c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
@@ -170,6 +181,7 @@ func Load() (Config, error) {
GuestRetention: guestRetention, GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: robo,
} }
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
@@ -222,6 +234,9 @@ func (c Config) validate() error {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL) return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
} }
} }
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
}
return nil return nil
} }
+9 -10
View File
@@ -1272,10 +1272,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if !ok { if !ok {
return StateView{}, ErrNotAPlayer return StateView{}, ErrNotAPlayer
} }
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID) unlock := svc.locks.lock(gameID)
defer unlock() defer unlock()
@@ -1290,12 +1286,15 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
} }
} }
return StateView{ return StateView{
Game: pre, Game: pre,
Seat: seat, Seat: seat,
Rack: g.Hand(seat), Rack: g.Hand(seat),
BagLen: g.BagLen(), BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), // The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance
WalletBalance: acc.HintBalance, // is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance.
// The client adds the profile's payments hint balance on top (lib/hints.hintsLeft).
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0),
WalletBalance: 0,
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn). // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, nil }, nil
-71
View File
@@ -4,7 +4,6 @@ package inttest
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -280,76 +279,6 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
} }
} }
// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form,
// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again
// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or
// non-numeric amount is refused without changing the balance.
func TestConsoleGrantHints(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
id := provisionAccount(t)
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(),
})
h := srv.Handler()
base := "http://admin.test/_gm/users/" + id.String()
if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") {
t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints"))
}
// The grant POST is CSRF-guarded like every console action.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden {
t.Fatalf("grant without origin = %d, want 403", code)
}
// A same-origin grant adds to the wallet.
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") {
t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5"))
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 {
t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err)
}
// A second grant adds again rather than replacing.
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") {
t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8"))
}
// An out-of-range or non-numeric amount is refused; the balance is left untouched.
for _, bad := range []string{"0", "-1", "101", "x", ""} {
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") {
t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount"))
}
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 {
t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err)
}
// The inclusive per-grant cap (100) is accepted.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK {
t.Fatalf("grant 100 = %d, want 200", code)
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 {
t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err)
}
}
// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the
// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound.
func TestGrantHintsStore(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
id := provisionAccount(t)
if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 {
t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err)
}
if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 {
t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err)
}
if _, err := accounts.GrantHints(ctx, id, 0); err == nil {
t.Error("grant 0 should be rejected (non-positive)")
}
if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) {
t.Errorf("grant unknown account = %v, want ErrNotFound", err)
}
}
// consoleDo issues a request to h, optionally with an Origin header, and returns // consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded. // the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
@@ -191,7 +191,7 @@ func TestBannerMessageOwnership(t *testing.T) {
} }
} }
// profileBanner is the banner block of the profile.get JSON response. // profileBanner is the banner (and interstitial-ad) block of the profile.get JSON response.
type profileBanner struct { type profileBanner struct {
Banner *struct { Banner *struct {
Campaigns []struct { Campaigns []struct {
@@ -203,6 +203,12 @@ type profileBanner struct {
HoldMs int `json:"hold_ms"` HoldMs int `json:"hold_ms"`
} `json:"timings"` } `json:"timings"`
} `json:"banner"` } `json:"banner"`
Ads *struct {
CooldownGlobalS int `json:"cooldown_global_s"`
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
CooldownHintS int `json:"cooldown_hint_s"`
Suppressed bool `json:"suppressed"`
} `json:"ads"`
} }
// TestBannerProfileEligibility checks the profile.get banner block follows // TestBannerProfileEligibility checks the profile.get banner block follows
@@ -283,6 +289,81 @@ func TestBannerProfileEligibility(t *testing.T) {
} }
} }
// TestProfileAdsConfig checks the profile.get interstitial-ad block: the seeded config cooldowns are
// carried through, and Suppressed follows the same no-ads / no_banner gate as the banner (the client
// self-gates VK-only + online on top). The no_banner role is context-independent; the no-ads benefit
// applies only in a trusted context where its segment is present.
func TestProfileAdsConfig(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
get := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
getTG := func() profileBanner {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
req.Header.Set("X-User-ID", id.String())
req.Header.Set("X-Platform", "telegram/android")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
// A free account: the ads block carries the seeded config cooldowns and is not suppressed.
p := get()
if p.Ads == nil {
t.Fatal("free account: no ads block")
}
if p.Ads.CooldownGlobalS != 300 || p.Ads.CooldownVsAiS != 1800 || p.Ads.CooldownHintS != 60 {
t.Fatalf("cooldowns = %d/%d/%d, want 300/1800/60", p.Ads.CooldownGlobalS, p.Ads.CooldownVsAiS, p.Ads.CooldownHintS)
}
if p.Ads.Suppressed {
t.Fatal("free account: interstitials must not be suppressed")
}
// The no_banner role suppresses interstitials too (context-independent).
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
if p := get(); p.Ads == nil || !p.Ads.Suppressed {
t.Fatalf("no_banner role: ads=%v, want suppressed", p.Ads)
}
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// An active no-ads benefit suppresses interstitials in a trusted context; an untrusted context
// does not apply it (fail-closed to eligible, as for the banner).
if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil {
t.Fatalf("grant no-ads: %v", err)
}
if p := getTG(); p.Ads == nil || !p.Ads.Suppressed {
t.Fatalf("no-ads benefit (trusted): ads=%v, want suppressed", p.Ads)
}
if p := get(); p.Ads == nil || p.Ads.Suppressed {
t.Fatalf("no-ads benefit (untrusted): ads=%v, want NOT suppressed", p.Ads)
}
}
// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the // TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
// banner block too, so the client's profile keeps the banner instead of losing it until reload. // banner block too, so the client's profile keeps the banner instead of losing it until reload.
func TestBannerSurvivesProfileUpdate(t *testing.T) { func TestBannerSurvivesProfileUpdate(t *testing.T) {
@@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) {
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err) t.Fatalf("revoke role: %v", err)
} }
// A non-empty hint wallet no longer suppresses it either.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err)
}
assertUrgentOnly("hints", get())
} }
@@ -0,0 +1,563 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// orderStatus reads an order's status.
func orderStatus(t *testing.T, orderID uuid.UUID) string {
t.Helper()
var status string
if err := testDB.QueryRowContext(context.Background(),
`SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil {
t.Fatalf("read order status: %v", err)
}
return status
}
// readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when
// none exists.
func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) {
t.Helper()
err := testDB.QueryRowContext(context.Background(),
`SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss)
if errors.Is(err, sql.ErrNoRows) {
return false, 0
}
if err != nil {
t.Fatalf("read risk: %v", err)
}
return abuse, loss
}
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
// credits nothing more — the ledger idempotency index holds.
func TestPaymentsOrderFundCreditsOnce(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB {
t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount)
}
if orderStatus(t, res.OrderID) != "pending" {
t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect {
t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after fund = %d, want 100", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID))
}
// A replayed callback for the same order is rejected by the unique index: no second credit.
out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("duplicate fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate = %d, want 100 (credited once)", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Error("duplicate callback wrote a second fund ledger row")
}
}
// TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the
// pack in votes; the get_item lookup returns its title and vote price; a chargeable
// order_status_change credits the vk segment exactly once (idempotent on VK's own order id).
func TestPaymentsVKOrderFundCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk")
if err != nil {
t.Fatalf("create vk order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 {
t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount)
}
// get_item phase: title + vote price.
title, amount, err := svc.OrderItem(ctx, res.OrderID)
if err != nil {
t.Fatalf("order item: %v", err)
}
if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote {
t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount)
}
// order_status_change phase: VK's own order id is the idempotency key.
paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote)
out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("vk fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK {
t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out)
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after fund = %d, want 200", got)
}
// A duplicate VK callback (same VK order id) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("duplicate vk fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate VK callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got)
}
}
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
func TestPaymentsTelegramStarsRail(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
}
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout: %v", err)
}
if !pc.OK || pc.AccountID != acc {
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
}
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("telegram fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after fund = %d, want 50", got)
}
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("duplicate telegram fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried forward not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
}
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout after paid: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
}
}
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
// and an amount that no longer matches.
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
// An unknown order id declines as gone, with no account to localise against.
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
if err != nil {
t.Fatalf("pre_checkout unknown: %v", err)
}
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
}
// A pending order validated at the wrong amount declines as price-changed.
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
if err != nil {
t.Fatalf("pre_checkout mismatch: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
}
}
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
// after the test (the row is a singleton shared across the sequential suite).
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
payout, dailyCap, hourlyCap); err != nil {
t.Fatalf("set reward config: %v", err)
}
t.Cleanup(func() {
_, _ = testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
})
}
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
// third distinct view.
func TestPaymentsRewardCredit(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
vk := payments.NewContext("vk", "web")
present := []payments.Source{payments.SourceVK}
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("credit: %v", err)
}
if out.Chips != 5 || out.Capped {
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance = %d, want 5", got)
}
// A retried view (same nonce) credits nothing more.
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("retry: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried view not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
}
// A second distinct view credits again (2 total).
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
t.Fatalf("credit 2: %v", err)
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance = %d, want 10", got)
}
// The third distinct view hits the hourly cap (2) — capped, no credit.
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
if err != nil {
t.Fatalf("credit 3: %v", err)
}
if !out3.Capped {
t.Error("third view not capped (hourly cap 2)")
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
}
}
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
// refused outside a VK context (rewarded is VK-only, D28).
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
vk := payments.NewContext("vk", "web")
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
if err != nil {
t.Fatalf("credit (disabled): %v", err)
}
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
}
// A direct (non-VK) context is refused — rewarded is VK-only.
direct := payments.NewContext("direct", "web")
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
}
}
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
// refused and credits nothing (§9: verify the amount after matching by order id).
func TestPaymentsFundAmountMismatch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) {
t.Fatalf("fund = %v, want ErrAmountMismatch", err)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got)
}
if ledgerRows(t, acc, "fund") != 0 {
t.Error("fund ledger row written on an amount mismatch")
}
}
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
func TestPaymentsEventDispatchDrain(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
t.Fatalf("record event: %v", err)
}
// testDB is shared, so filter the queue to our account.
find := func() *payments.PaymentEvent {
evs, err := svc.UndispatchedEvents(ctx, 100)
if err != nil {
t.Fatalf("undispatched: %v", err)
}
for i := range evs {
if evs[i].AccountID == acc {
return &evs[i]
}
}
return nil
}
mine := find()
if mine == nil {
t.Fatal("recorded event not in the undispatched queue")
}
if mine.Type != "succeeded" {
t.Errorf("event type = %s, want succeeded", mine.Type)
}
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
t.Fatalf("mark dispatched: %v", err)
}
if find() != nil {
t.Error("event still undispatched after MarkEventDispatched")
}
}
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
// Age the order well past the configured TTL, then sweep it to expired.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 {
t.Fatalf("expire orders = %d (err %v), want at least 1", n, err)
}
if orderStatus(t, res.OrderID) != "expired" {
t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund after expiry: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 {
t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 (expired order honoured)", got)
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
}
}
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
acc := uuid.New()
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
return res.OrderID, acc
}
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
func TestPaymentsRefundFull(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
}
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
}
}
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
func TestPaymentsRefundAfterSpend(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
// Simulate 70 chips already spent, leaving 30 in the funded segment.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("simulate spend: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.Revoked != 30 || out.Loss != 70 {
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
}
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
}
}
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
// more — the ledger idempotency index holds.
func TestPaymentsRefundIdempotent(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
t.Fatalf("first refund: %v", err)
}
// Re-credit the segment to prove the duplicate does not revoke again.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("re-credit: %v", err)
}
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
if err != nil {
t.Fatalf("duplicate refund: %v", err)
}
if !out2.AlreadyRefunded {
t.Error("duplicate refund not flagged AlreadyRefunded")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
}
}
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
}
}
+4
View File
@@ -73,6 +73,10 @@ const (
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser), // (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only. // so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile" NotifyProfile = "profile"
// NotifyPayment tells the client that the viewer's wallet changed from a payment-intake event
// (a credit or a refund), so it re-fetches the wallet in place. It carries no payload. In-app
// only; the payment_events dispatcher emits it after the crediting transaction commits.
NotifyPayment = "payment"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect, // NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the // carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered // in-game block/add-friend controls and the struck name in place. It is delivered
+13 -10
View File
@@ -89,8 +89,10 @@ func NewContext(kind, subtype string) Context {
// every spend/purchase and the application of any foreign origin. // every spend/purchase and the application of any foreign origin.
func (c Context) Trusted() bool { return c.Kind.Valid() } func (c Context) Trusted() bool { return c.Kind.Valid() }
// vkFrozen reports whether this is the VK-iOS spend freeze: VK context on the trusted iOS // vkFrozen reports whether this is the VK-iOS purchase freeze: VK context on the trusted iOS
// subtype. A previously bought benefit still applies there, but no spend or purchase is possible. // subtype. Apple's ToS forbids only BUYING in-app values there, so a purchase (money -> chips) is
// refused; earning chips (rewarded ads) and SPENDING chips already in the VK wallet — earned or
// bought on the same account elsewhere (e.g. VK Android) — are legal and stay allowed.
func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS } func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS }
// spendPriority is the fixed draw order when several segments are spendable in one context // spendPriority is the fixed draw order when several segments are spendable in one context
@@ -103,11 +105,12 @@ func has(present []Source, s Source) bool {
} }
// spendableSources returns the chip segments that may be SPENT in the context, in draw-priority // spendableSources returns the chip segments that may be SPENT in the context, in draw-priority
// order, restricted to the sources the account actually has (present). It is empty when the // order, restricted to the sources the account actually has (present). It is empty only when the
// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named // platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so
// segment is spendable; on web/native all attached segments are, drained direct→vk→tg. // spending VK-wallet chips there is allowed. Inside VK/TG only the same-named segment is spendable;
// on web/native all attached segments are, drained direct→vk→tg.
func spendableSources(c Context, present []Source) []Source { func spendableSources(c Context, present []Source) []Source {
if !c.Trusted() || c.vkFrozen() { if !c.Trusted() {
return nil return nil
} }
switch c.Kind { switch c.Kind {
@@ -128,10 +131,10 @@ func spendableSources(c Context, present []Source) []Source {
} }
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority // applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is // order, restricted to present sources. It mirrors spendableSources (both gate only on a trusted
// NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG // platform now that the VK-iOS freeze is purchase-only). Inside VK/TG only the same-named origin
// only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a // applies (a foreign, e.g. direct, origin never activates inside a store — the compliance wall);
// store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg. // on web/native direct+vk+tg all apply, drained direct→vk→tg.
func applicableOrigins(c Context, present []Source) []Source { func applicableOrigins(c Context, present []Source) []Source {
if !c.Trusted() { if !c.Trusted() {
return nil return nil
+4 -3
View File
@@ -16,7 +16,8 @@ func TestSpendableSources(t *testing.T) {
want []Source want []Source
}{ }{
{"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}}, {"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"vk ios frozen", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, nil}, // VK-iOS spends its own vk segment: the freeze is purchase-only, spending is allowed.
{"vk ios spends vk", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil}, {"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil},
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}}, {"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil}, {"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
@@ -41,8 +42,8 @@ func TestApplicableOrigins(t *testing.T) {
present []Source present []Source
want []Source want []Source
}{ }{
// A benefit still APPLIES on VK-iOS while spending is frozen. // A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only).
{"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}}, {"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}}, {"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}}, {"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}},
{"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}}, {"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
+11 -5
View File
@@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) {
} }
} }
// String renders the amount as "<value> <currency>", with the currency's // Major renders the amount as a decimal string without the currency, with the currency's
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR"). // fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount
func (m Money) String() string { // field (Robokassa OutSum) takes.
func (m Money) Major() string {
scale := m.currency.minorPerUnit() scale := m.currency.minorPerUnit()
if scale == 1 { if scale == 1 {
return fmt.Sprintf("%d %s", m.minor, m.currency) return fmt.Sprintf("%d", m.minor)
} }
neg := m.minor < 0 neg := m.minor < 0
abs := m.minor abs := m.minor
@@ -167,5 +168,10 @@ func (m Money) String() string {
if neg { if neg {
sign = "-" sign = "-"
} }
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency) return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale)
}
// String renders the amount as "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
func (m Money) String() string {
return m.Major() + " " + string(m.currency)
} }
+200
View File
@@ -0,0 +1,200 @@
package payments
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
)
// OrderResult is what CreateOrder returns to the transport: the created order id and the details a
// provider launch payload needs — the amount to charge and a human title for the payment.
type OrderResult struct {
OrderID uuid.UUID
Amount Money
Title string
}
// CreateOrder opens a pending order to fund a chip pack in the execution context's payment method,
// tagged with the provider that will settle it. It gate-checks the context (trusted, not the
// VK-iOS spend freeze) and that the method's funding segment is attached, prices the pack in the
// method's currency, then writes the order. The caller enforces any account-level precondition
// (e.g. the direct email anchor, D36) before calling — payments holds no cross-schema identity
// knowledge.
func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID, provider string) (OrderResult, error) {
if !cxt.Trusted() || cxt.vkFrozen() {
return OrderResult{}, ErrUntrusted
}
method := cxt.Kind
if !has(present, method) {
return OrderResult{}, ErrUntrusted // the funding segment is not attached to the account
}
pack, err := s.store.loadPackForOrder(ctx, productID, method)
if err != nil {
return OrderResult{}, err
}
orderID, err := uuid.NewV7()
if err != nil {
return OrderResult{}, fmt.Errorf("payments: order id: %w", err)
}
o := newOrder{
orderID: orderID,
accountID: accountID,
platform: string(method),
productID: productID,
amount: pack.price,
origin: method,
provider: provider,
}
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
return OrderResult{}, err
}
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
}
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
func (s *Service) OrderItem(ctx context.Context, orderID uuid.UUID) (title string, amount Money, err error) {
ord, err := s.store.orderByID(ctx, orderID)
if err != nil {
return "", Money{}, err
}
_, title, err = s.store.packForCredit(ctx, ord.productID)
if err != nil {
return "", Money{}, err
}
amount, err = MoneyFromMinor(ord.expectedAmount, Currency(ord.currency))
if err != nil {
return "", Money{}, err
}
return title, amount, nil
}
// Fund credits a paid order into its funded segment exactly once, from a verified provider callback
// — the single writer for every rail. It matches the order, verifies the paid amount, appends the
// fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks
// the order paid. A duplicate callback returns AlreadyCredited without a second credit; a valid
// callback is honoured even on an expired order (§9/D23).
func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money) (FundOutcome, error) {
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
}
// Refund reverses a paid order's credit best-effort, exactly once — for an external refund or an
// admin-initiated one (E7). It revokes the funded chips floored at 0 (never negative, D27), records
// any unrecoverable remainder as a per-account loss and abuse flag, and appends a refund ledger row
// idempotent on (provider, providerRefundID) — distinct from the fund's payment id. A duplicate
// refund returns AlreadyRefunded. The caller records the refunded payment event and performs any
// provider-side money-back (the rails have no unsolicited refund push: Robokassa via its refund API
// / cabinet, VK via support, Telegram via refundStarPayment — all admin-triggered).
func (s *Service) Refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money) (RefundOutcome, error) {
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
}
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
// to the order account's preferred language before showing the payer (the reason is displayed in the
// Telegram payment sheet).
const (
// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
PreCheckoutGone = "order_gone"
// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
PreCheckoutAlreadyPaid = "already_paid"
// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
PreCheckoutPriceChanged = "price_changed"
)
// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
type PreCheckoutOutcome struct {
OK bool
Reason string
AccountID uuid.UUID
}
// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
// approved, before any star is charged. It approves an order that exists, is not already paid (a
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
ord, err := s.store.orderByID(ctx, orderID)
if errors.Is(err, ErrOrderNotFound) {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
}
if err != nil {
return PreCheckoutOutcome{}, err
}
if ord.status == "paid" {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
}
if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
}
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"
// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and
// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30).
func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
return s.store.interstitialCooldowns(ctx)
}
// 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).
func (s *Service) ExpireOrders(ctx context.Context) (int, error) {
ttl, err := s.store.orderTTL(ctx)
if err != nil {
return 0, err
}
return s.store.expirePending(ctx, ttl, s.clock())
}
// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver to the user (live stream, botlink or email).
func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error {
return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock())
}
// UndispatchedEvents returns up to limit payment events awaiting delivery. The dispatcher drains
// them and marks each delivered via MarkEventDispatched.
func (s *Service) UndispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
return s.store.undispatchedEvents(ctx, limit)
}
// MarkEventDispatched stamps a payment event as delivered so it is not re-sent.
func (s *Service) MarkEventDispatched(ctx context.Context, eventID uuid.UUID) error {
return s.store.markEventDispatched(ctx, eventID, s.clock())
}
@@ -0,0 +1,42 @@
package payments
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
// gateOnlyService builds a Service with a fixed clock and no store, usable only for the CreateOrder
// gate rejections that return before any store access.
func gateOnlyService() *Service {
return &Service{clock: func() time.Time { return time.Unix(0, 0).UTC() }}
}
// TestCreateOrderGateRejections checks that CreateOrder fails closed — before touching the store —
// on an untrusted platform, the VK-iOS spend freeze, and a method whose funding segment the account
// does not hold.
func TestCreateOrderGateRejections(t *testing.T) {
ctx := context.Background()
acc, prod := uuid.New(), uuid.New()
present := []Source{SourceDirect, SourceVK}
cases := []struct {
name string
cxt Context
}{
{"untrusted context", Context{}},
{"vk-ios purchase freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
{"method segment not attached", Context{Kind: SourceTelegram}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := gateOnlyService().CreateOrder(ctx, acc, tc.cxt, present, prod, "robokassa")
if !errors.Is(err, ErrUntrusted) {
t.Fatalf("CreateOrder(%s) = %v, want ErrUntrusted", tc.name, err)
}
})
}
}
+623
View File
@@ -0,0 +1,623 @@
package payments
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// Intake errors surfaced by the order-flow and external-credit (fund) path.
var (
// ErrNotAPack means the product is not a fundable chip pack in the requested method: it
// carries no chips atom, or no price for that payment method.
ErrNotAPack = errors.New("payments: product is not a chip pack for this method")
// ErrOrderNotFound means no order matches the id (an unknown or forged callback reference).
ErrOrderNotFound = errors.New("payments: order not found")
// ErrAmountMismatch means the callback's paid amount or currency does not match the order's
// expected amount — the credit is refused (§9: verify amount after matching by order id).
ErrAmountMismatch = errors.New("payments: paid amount does not match the order")
// ErrOrderNotPaid means a refund targets an order that was never funded — there is nothing to
// reverse (guards against a spurious loss/abuse record on an unpaid order).
ErrOrderNotPaid = errors.New("payments: order is not paid")
)
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
// (provider, provider_payment_id) unique index rejects a duplicate callback. It is not surfaced:
// a replayed callback is a success that credits nothing.
var errAlreadyCredited = errors.New("payments: already credited")
// errAlreadyRefunded unwinds the refund transaction when the ledger idempotency index rejects a
// duplicate refund (same provider refund id). It is not surfaced: a replayed refund reverses nothing.
var errAlreadyRefunded = errors.New("payments: already refunded")
// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in
// the requested payment method's currency.
type packInfo struct {
productID uuid.UUID
title string
chips int
price Money
}
// packChips returns the quantity of the chips atom a product carries, or 0 if it has none (which
// marks it as a value, not a fundable pack).
func (s *Store) packChips(ctx context.Context, productID uuid.UUID) (int, error) {
var item model.ProductItem
err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID)).
AND(table.ProductItem.AtomType.EQ(postgres.String(atomChips)))).
LIMIT(1).
QueryContext(ctx, s.db, &item)
if errors.Is(err, qrm.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("payments: load pack chips %s: %w", productID, err)
}
return int(item.Quantity), nil
}
// loadPackForOrder resolves an active chip pack for a new order in the given payment method: an
// active product carrying a chips atom and a price row for the method (its currency and amount are
// the order's expected amount). It rejects a missing/deactivated product (ErrProductNotFound) and
// a product that is not a pack for the method (ErrNotAPack).
func (s *Store) loadPackForOrder(ctx context.Context, productID uuid.UUID, method Source) (packInfo, error) {
var p model.Product
err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) {
return packInfo{}, ErrProductNotFound
}
if err != nil {
return packInfo{}, fmt.Errorf("payments: load product %s: %w", productID, err)
}
chips, err := s.packChips(ctx, productID)
if err != nil {
return packInfo{}, err
}
if chips <= 0 {
return packInfo{}, ErrNotAPack
}
var price model.ProductPrice
err = postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)).
AND(table.ProductPrice.Method.EQ(postgres.String(string(method))))).
LIMIT(1).
QueryContext(ctx, s.db, &price)
if errors.Is(err, qrm.ErrNoRows) {
return packInfo{}, ErrNotAPack
}
if err != nil {
return packInfo{}, fmt.Errorf("payments: load pack price %s: %w", productID, err)
}
money, err := MoneyFromMinor(price.Amount, Currency(price.Currency))
if err != nil {
return packInfo{}, err
}
return packInfo{productID: productID, title: p.Title, chips: chips, price: money}, nil
}
// packForCredit resolves the chips and title of an ordered pack at credit time, ignoring the
// product's active flag: the money is real, so an order is honoured even if the pack was
// deactivated after it was placed (§9/D23).
func (s *Store) packForCredit(ctx context.Context, productID uuid.UUID) (chips int, title string, err error) {
var p model.Product
e := postgres.SELECT(table.Product.Title).
FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(e, qrm.ErrNoRows) {
return 0, "", ErrProductNotFound
}
if e != nil {
return 0, "", fmt.Errorf("payments: load product %s: %w", productID, e)
}
chips, err = s.packChips(ctx, productID)
if err != nil {
return 0, "", err
}
if chips <= 0 {
return 0, "", ErrNotAPack
}
return chips, p.Title, nil
}
// newOrder is the intent a CreateOrder writes: a pending order for a pack, priced in the method's
// currency, tagged with the provider that will settle it.
type newOrder struct {
orderID uuid.UUID
accountID uuid.UUID
platform string
productID uuid.UUID
amount Money
origin Source
provider string
}
// createOrder inserts a pending order.
func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) error {
stmt := table.Orders.INSERT(
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
table.Orders.CreatedAt, table.Orders.UpdatedAt,
).VALUES(
o.orderID, o.accountID, o.platform,
o.productID, o.amount.Minor(), string(o.amount.Currency()),
string(o.origin), "pending", o.provider,
now, now,
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: create order: %w", err)
}
return nil
}
// orderRow is a stored order read back for the intake path.
type orderRow struct {
orderID uuid.UUID
accountID uuid.UUID
productID uuid.UUID
expectedAmount int64
currency string
origin string
status string
}
// orderByID reads an order, or ErrOrderNotFound.
func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, error) {
var o model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
LIMIT(1).
QueryContext(ctx, s.db, &o)
if errors.Is(err, qrm.ErrNoRows) {
return orderRow{}, ErrOrderNotFound
}
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
}
return orderRow{
orderID: o.OrderID,
accountID: o.AccountID,
productID: o.ProductID,
expectedAmount: o.ExpectedAmount,
currency: o.Currency,
origin: o.Origin,
status: o.Status,
}, nil
}
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
// chips were credited, and whether the callback was a duplicate that credited nothing.
type FundOutcome struct {
AccountID uuid.UUID
Source Source
Chips int
AlreadyCredited bool
}
// fund credits a paid order exactly once: it matches the order, verifies the amount, then in one
// transaction appends a fund ledger row (idempotent on the (provider, provider_payment_id) unique
// index), credits the funded segment's balance and marks the order paid. A duplicate callback is
// rejected by the unique index and returns AlreadyCredited with no error and no second credit. A
// valid callback is honoured even on an expired order (§9/D23). The read cache is invalidated after
// the commit, since the credit runs outside any request the payments package owns.
func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money, now time.Time) (FundOutcome, error) {
ord, err := s.orderByID(ctx, orderID)
if err != nil {
return FundOutcome{}, err
}
if paid.Currency() != Currency(ord.currency) || paid.Minor() != ord.expectedAmount {
return FundOutcome{}, ErrAmountMismatch
}
chips, title, err := s.packForCredit(ctx, ord.productID)
if err != nil {
return FundOutcome{}, err
}
snapshot, err := marshalFundSnapshot(ord.productID, title, chips, paid)
if err != nil {
return FundOutcome{}, err
}
src := Source(ord.origin)
outcome := FundOutcome{AccountID: ord.accountID, Source: src, Chips: chips}
pv, pp := provider, providerPaymentID
productID := ord.productID
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if e := insertLedgerTx(ctx, tx, ord.accountID, "fund", &src, &src, chips, &productID, &orderID, &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()`,
ord.accountID, string(src), chips); e != nil {
return fmt.Errorf("payments: credit balance %s: %w", src, e)
}
if _, e := tx.ExecContext(ctx,
`UPDATE payments.orders SET status = 'paid', provider = $2, provider_payment_id = $3, updated_at = now()
WHERE order_id = $1`,
orderID, provider, providerPaymentID); e != nil {
return fmt.Errorf("payments: mark order paid: %w", e)
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyCredited) {
return outcome, nil
}
return FundOutcome{}, err
}
s.cache.invalidate(ord.accountID)
return outcome, nil
}
// RefundOutcome reports a refund's result: whose funded segment was reversed, the chips actually
// clawed back (floored at 0), the unrecoverable remainder (a loss, when the chips were already
// spent), and whether the refund was a duplicate that reversed nothing.
type RefundOutcome struct {
AccountID uuid.UUID
Source Source
Revoked int
Loss int
AlreadyRefunded bool
}
// refund reverses a paid order's credit best-effort, exactly once. It matches the order (which must
// be paid), verifies the refunded amount, then in one transaction appends a refund ledger row
// (idempotent on the (provider, provider_payment_id) index — the refund id is distinct from the
// fund's payment id, so the two rows coexist), revokes the funded chips floored at 0 (never
// negative, D27/balances_chips_chk) and, when chips were already spent, records the unrecoverable
// remainder as a per-account loss and flips the abuse flag. A duplicate refund returns
// AlreadyRefunded with no second reversal. The ledger row's chipsDelta is what is actually
// reclaimed; the full reversal (money, original chips, loss) rides in its snapshot for the report.
func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money, now time.Time) (RefundOutcome, error) {
ord, err := s.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
}
if ord.status != "paid" {
return RefundOutcome{}, ErrOrderNotPaid
}
if refunded.Currency() != Currency(ord.currency) || refunded.Minor() != ord.expectedAmount {
return RefundOutcome{}, ErrAmountMismatch
}
chips, title, err := s.packForCredit(ctx, ord.productID)
if err != nil {
return RefundOutcome{}, err
}
src := Source(ord.origin)
outcome := RefundOutcome{AccountID: ord.accountID, Source: src}
pv, pr := provider, providerRefundID
productID := ord.productID
oid := orderID
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Lock the funded segment and read what is left; a spent balance floors the reversal at 0.
var avail int
e := tx.QueryRowContext(ctx,
`SELECT chips FROM payments.balances WHERE account_id = $1 AND source = $2 FOR UPDATE`,
ord.accountID, string(src)).Scan(&avail)
switch {
case errors.Is(e, sql.ErrNoRows):
avail = 0
case e != nil:
return fmt.Errorf("payments: read balance for refund: %w", e)
}
revoked := min(chips, avail)
loss := chips - revoked
outcome.Revoked, outcome.Loss = revoked, loss
snapshot, e := marshalRefundSnapshot(ord.productID, title, chips, revoked, loss, refunded, providerRefundID)
if e != nil {
return e
}
if e := insertLedgerTx(ctx, tx, ord.accountID, "refund", &src, &src, -revoked, &productID, &oid, &pv, &pr, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyRefunded = true
return errAlreadyRefunded
}
return e
}
if revoked > 0 {
if _, e := tx.ExecContext(ctx,
`UPDATE payments.balances SET chips = chips - $3, updated_at = now()
WHERE account_id = $1 AND source = $2`,
ord.accountID, string(src), revoked); e != nil {
return fmt.Errorf("payments: revoke chips %s: %w", src, e)
}
}
if loss > 0 {
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.account_risk (account_id, abuse, loss_chips, updated_at)
VALUES ($1, true, $2, now())
ON CONFLICT (account_id) DO UPDATE
SET abuse = true, loss_chips = payments.account_risk.loss_chips + EXCLUDED.loss_chips, updated_at = now()`,
ord.accountID, int64(loss)); e != nil {
return fmt.Errorf("payments: record refund loss: %w", e)
}
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyRefunded) {
return outcome, nil
}
return RefundOutcome{}, err
}
s.cache.invalidate(ord.accountID)
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
}
// interstitialCooldowns reads the post-move interstitial-ad cooldowns (seconds): the global
// per-user cooldown, the longer vs_ai one, and the independent hint-triggered one. The client mirrors
// them and self-gates (E6/D30).
func (s *Store) interstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
var cfg model.Config
if e := postgres.SELECT(table.Config.CooldownGlobalSeconds, table.Config.CooldownVsAiSeconds, table.Config.CooldownHintSeconds).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); e != nil {
return 0, 0, 0, fmt.Errorf("payments: read interstitial cooldowns: %w", e)
}
return int(cfg.CooldownGlobalSeconds), int(cfg.CooldownVsAiSeconds), int(cfg.CooldownHintSeconds), nil
}
// 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 {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("payments: event id: %w", err)
}
var pl any = postgres.NULL
if payload != nil {
pl = string(payload)
}
stmt := table.PaymentEvents.INSERT(
table.PaymentEvents.EventID, table.PaymentEvents.AccountID, table.PaymentEvents.OrderID,
table.PaymentEvents.Type, table.PaymentEvents.Payload, table.PaymentEvents.CreatedAt,
).VALUES(id, accountID, uuidOrNull(orderID), eventType, pl, now)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: insert %s event: %w", eventType, err)
}
return nil
}
// PaymentEvent is an undispatched lifecycle event the dispatcher delivers to an account.
type PaymentEvent struct {
EventID uuid.UUID
AccountID uuid.UUID
Type string
}
// undispatchedEvents reads up to limit payment events not yet delivered, oldest first.
func (s *Store) undispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT event_id, account_id, type FROM payments.payment_events
WHERE dispatched_at IS NULL ORDER BY created_at LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("payments: read undispatched events: %w", err)
}
defer rows.Close()
var out []PaymentEvent
for rows.Next() {
var e PaymentEvent
if err := rows.Scan(&e.EventID, &e.AccountID, &e.Type); err != nil {
return nil, fmt.Errorf("payments: scan event: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// markEventDispatched stamps an event as delivered so it is not re-sent.
func (s *Store) markEventDispatched(ctx context.Context, eventID uuid.UUID, now time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE payments.payment_events SET dispatched_at = $2 WHERE event_id = $1`, eventID, now); err != nil {
return fmt.Errorf("payments: mark event dispatched: %w", err)
}
return nil
}
// orderTTL reads the configured pending-order lifetime in whole seconds.
func (s *Store) orderTTL(ctx context.Context) (int, error) {
var cfg model.Config
if err := postgres.SELECT(table.Config.OrderTTLSeconds).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); err != nil {
return 0, fmt.Errorf("payments: read order ttl: %w", err)
}
return int(cfg.OrderTTLSeconds), nil
}
// expirePending marks every pending order older than ttlSeconds as expired, returning how many.
// Expiry is cosmetic DB hygiene: a later valid callback still credits an expired order (§9/D23).
func (s *Store) expirePending(ctx context.Context, ttlSeconds int, now time.Time) (int, error) {
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
res, err := table.Orders.
UPDATE(table.Orders.Status, table.Orders.UpdatedAt).
SET(postgres.String("expired"), postgres.TimestampzT(now)).
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff)))).
ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("payments: expire pending orders: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}
// marshalFundSnapshot records what a fund credited (the pack, chips and paid amount) on the ledger
// row, so history stays independent of later catalog edits (§7/D34).
func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Money) ([]byte, error) {
b, err := json.Marshal(struct {
ProductID string `json:"product_id"`
Title string `json:"title,omitempty"`
Chips int `json:"chips"`
Amount int64 `json:"amount_minor"`
Currency string `json:"currency"`
}{productID.String(), title, chips, paid.Minor(), string(paid.Currency())})
if err != nil {
return nil, fmt.Errorf("payments: marshal fund snapshot: %w", err)
}
return b, nil
}
// marshalRefundSnapshot records the full reversal on the refund ledger row: the pack, the original
// funded chips, how many were actually reclaimed, the unrecoverable loss (already spent), the money
// refunded and the provider refund id — so the ledger stays reconcilable against the balance
// (chipsDelta = revoked) while the report still sees the whole reversal (§7/D27/D34).
func marshalRefundSnapshot(productID uuid.UUID, title string, chips, revoked, loss int, refunded Money, refundID string) ([]byte, error) {
b, err := json.Marshal(struct {
ProductID string `json:"product_id"`
Title string `json:"title,omitempty"`
Chips int `json:"chips"`
Revoked int `json:"revoked"`
Loss int `json:"loss"`
Amount int64 `json:"amount_minor"`
Currency string `json:"currency"`
RefundID string `json:"refund_id"`
}{productID.String(), title, chips, revoked, loss, refunded.Minor(), string(refunded.Currency()), refundID})
if err != nil {
return nil, fmt.Errorf("payments: marshal refund snapshot: %w", err)
}
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 {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+19 -6
View File
@@ -230,8 +230,11 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin
return nil return nil
} }
// insertLedgerTx appends one append-only ledger row inside tx. // insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID *uuid.UUID, snapshot []byte, now time.Time) error { // providerPaymentID are set only on an intake credit (fund/refund) and are nil for a
// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial
// unique index, so a duplicate provider callback fails here (the idempotency key).
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return fmt.Errorf("payments: ledger id: %w", err) return fmt.Errorf("payments: ledger id: %w", err)
@@ -246,11 +249,13 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s
stmt := table.Ledger.INSERT( stmt := table.Ledger.INSERT(
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind, table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta, table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt, table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider,
table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
).VALUES( ).VALUES(
id, accountID, kind, id, accountID, kind,
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta), sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
uuidOrNull(productID), snap, now, uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider),
stringOrNull(providerPaymentID), snap, now,
) )
if _, err := stmt.ExecContext(ctx, tx); err != nil { if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("payments: insert %s ledger: %w", kind, err) return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
@@ -278,7 +283,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
return ErrInsufficientChips return ErrInsufficientChips
} }
src := dr.source src := dr.source
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, snapshot, now); err != nil { if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil {
return err return err
} }
} }
@@ -295,7 +300,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
// the chosen origin, in one transaction — a zero-price sale of a value. // the chosen origin, in one transaction — a zero-price sale of a value.
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error { func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error {
err := withTx(ctx, s.db, func(tx *sql.Tx) error { err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, snapshot, now); err != nil { if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil {
return err return err
} }
return applyBenefitTx(ctx, tx, accountID, origin, d, now) return applyBenefitTx(ctx, tx, accountID, origin, d, now)
@@ -419,3 +424,11 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression {
} }
return postgres.UUID(*id) return postgres.UUID(*id)
} }
// stringOrNull renders an optional string as a SQL string or NULL.
func stringOrNull(s *string) postgres.Expression {
if s == nil {
return postgres.NULL
}
return postgres.String(*s)
}
+4 -3
View File
@@ -102,10 +102,11 @@ func TestWalletSegments(t *testing.T) {
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable { if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
t.Errorf("vk-android wallet = %+v", got.Segments) t.Errorf("vk-android wallet = %+v", got.Segments)
} }
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible. // VK iOS: only vk shown, and spendable — the freeze is purchase-only, so VK-wallet chips still
// spend there (only buying more chips for money is blocked).
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present) got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable { if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments) t.Errorf("vk-ios wallet = %+v (want vk 50 spendable)", got.Segments)
} }
} }
@@ -0,0 +1,20 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type AccountRisk struct {
AccountID uuid.UUID `sql:"primary_key"`
Abuse bool
LossChips int64
UpdatedAt time.Time
}
@@ -14,4 +14,6 @@ type Config struct {
CooldownVsAiSeconds int32 CooldownVsAiSeconds int32
CooldownHintSeconds int32 CooldownHintSeconds int32
OrderTTLSeconds int32 OrderTTLSeconds int32
RewardDailyCap int32
RewardHourlyCap int32
} }
@@ -0,0 +1,87 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var AccountRisk = newAccountRiskTable("payments", "account_risk", "")
type accountRiskTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Abuse postgres.ColumnBool
LossChips postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountRiskTable struct {
accountRiskTable
EXCLUDED accountRiskTable
}
// AS creates new AccountRiskTable with assigned alias
func (a AccountRiskTable) AS(alias string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountRiskTable with assigned schema name
func (a AccountRiskTable) FromSchema(schemaName string) *AccountRiskTable {
return newAccountRiskTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountRiskTable with assigned table prefix
func (a AccountRiskTable) WithPrefix(prefix string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountRiskTable with assigned table suffix
func (a AccountRiskTable) WithSuffix(suffix string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountRiskTable(schemaName, tableName, alias string) *AccountRiskTable {
return &AccountRiskTable{
accountRiskTable: newAccountRiskTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountRiskTableImpl("", "excluded", ""),
}
}
func newAccountRiskTableImpl(schemaName, tableName, alias string) accountRiskTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
AbuseColumn = postgres.BoolColumn("abuse")
LossChipsColumn = postgres.IntegerColumn("loss_chips")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, AbuseColumn, LossChipsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
)
return accountRiskTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Abuse: AbuseColumn,
LossChips: LossChipsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -23,6 +23,8 @@ type configTable struct {
CooldownVsAiSeconds postgres.ColumnInteger CooldownVsAiSeconds postgres.ColumnInteger
CooldownHintSeconds postgres.ColumnInteger CooldownHintSeconds postgres.ColumnInteger
OrderTTLSeconds postgres.ColumnInteger OrderTTLSeconds postgres.ColumnInteger
RewardDailyCap postgres.ColumnInteger
RewardHourlyCap postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds") CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds") CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds") OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn} RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn} RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn} allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
) )
return configTable{ return configTable{
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSeconds: CooldownVsAiSecondsColumn, CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
CooldownHintSeconds: CooldownHintSecondsColumn, CooldownHintSeconds: CooldownHintSecondsColumn,
OrderTTLSeconds: OrderTTLSecondsColumn, OrderTTLSeconds: OrderTTLSecondsColumn,
RewardDailyCap: RewardDailyCapColumn,
RewardHourlyCap: RewardHourlyCapColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -10,6 +10,7 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke // UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program. // this method only once at the beginning of the program.
func UseSchema(schema string) { func UseSchema(schema string) {
AccountRisk = AccountRisk.FromSchema(schema)
Balances = Balances.FromSchema(schema) Balances = Balances.FromSchema(schema)
Benefits = Benefits.FromSchema(schema) Benefits = Benefits.FromSchema(schema)
CatalogAtom = CatalogAtom.FromSchema(schema) CatalogAtom = CatalogAtom.FromSchema(schema)
@@ -0,0 +1,25 @@
-- Per-account payment risk: the loss and abuse signal an external/admin refund leaves
-- behind when the refunded chips were already spent. A refund revokes chips best-effort
-- and never drives a balance negative (D27, balances_chips_chk); the unrecoverable
-- remainder is a recorded loss and flips an abuse flag the /_gm financial report reads
-- (D40, E7). Mutable per-account state (upserted on each such refund), so — unlike the
-- ledger — it carries no append-only trigger. Additive: a new table only, so goose
-- applies it forward with no rewrite of existing data (the contour is not wiped). The
-- payments role inherits ALL on it via the schema default privileges set in 00010.
-- +goose Up
CREATE TABLE payments.account_risk (
account_id uuid NOT NULL,
-- abuse flips true the first time a refund cannot fully reclaim its chips (spent).
abuse boolean DEFAULT false NOT NULL,
-- loss_chips accumulates the unrecoverable chips across such refunds (bigint: an
-- accumulator, mapped to int64 by go-jet — not the numeric->float64 trap).
loss_chips bigint DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_risk_pkey PRIMARY KEY (account_id),
CONSTRAINT account_risk_loss_chips_chk CHECK ((loss_chips >= 0))
);
-- +goose Down
DROP TABLE IF EXISTS payments.account_risk;
@@ -0,0 +1,22 @@
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
-- total; the hourly cap smooths a burst). Chips-per-view already exists
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
-- config, tuned in the admin without a release. Additive columns only — applies forward via
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
-- +goose Up
ALTER TABLE payments.config
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
ALTER TABLE payments.config
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
-- +goose Down
ALTER TABLE payments.config
DROP COLUMN reward_daily_cap,
DROP COLUMN reward_hourly_cap;
+104
View File
@@ -0,0 +1,104 @@
// Package robokassa builds and verifies Robokassa direct-rail (RUB) payments: it forms the signed
// hosted-payment URL a client is sent to, and verifies the Result-URL server callback that credits
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
// domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
// Signatures use SHA-256 (configured to match the shop's technical settings); the shop's test mode
// is carried by IsTest.
package robokassa
import (
"crypto/sha256"
"encoding/hex"
"net/url"
"sort"
"strings"
"github.com/google/uuid"
)
// payEndpoint is Robokassa's hosted payment page; the signed query sends the client there.
const payEndpoint = "https://auth.robokassa.ru/Merchant/Index.aspx"
// Config is a Robokassa shop's credentials. Password1 signs the outgoing payment request;
// Password2 signs (and so verifies) the incoming Result callback. IsTest adds IsTest=1 so the shop's
// test mode simulates payments without money movement.
type Config struct {
MerchantLogin string
Password1 string
Password2 string
IsTest bool
}
// PaymentURL builds the signed hosted-payment URL for an order: amount is the OutSum decimal string
// (roubles, e.g. "149.00"), description is the human payment purpose. The order id rides as
// Shp_order and is bound into the SHA-256 signature; InvId is 0 (unused).
func (c Config) PaymentURL(orderID uuid.UUID, amount, description string) string {
q := url.Values{}
q.Set("Shp_order", orderID.String())
// Signature base: MerchantLogin:OutSum:InvId:Password1[:Shp_key=value...sorted].
base := c.MerchantLogin + ":" + amount + ":0:" + c.Password1 + shpSuffix(q)
q.Set("MerchantLogin", c.MerchantLogin)
q.Set("OutSum", amount)
q.Set("InvId", "0")
q.Set("Description", description)
q.Set("SignatureValue", sign(base))
if c.IsTest {
q.Set("IsTest", "1")
}
return payEndpoint + "?" + q.Encode()
}
// VerifyResult verifies a Result-URL callback and extracts the order it credits. It recomputes the
// SHA-256 signature OutSum:InvId:Password2[:Shp_...sorted] over the callback's own fields and
// compares it (case-insensitively) with SignatureValue. On success it returns the order id (from
// Shp_order) and the raw OutSum string the caller re-checks against the order amount; on any
// missing field, a signature mismatch or an unparseable order id it returns ok=false.
func (c Config) VerifyResult(v url.Values) (orderID uuid.UUID, outSum string, ok bool) {
outSum = v.Get("OutSum")
sig := v.Get("SignatureValue")
order := v.Get("Shp_order")
if outSum == "" || sig == "" || order == "" {
return uuid.Nil, "", false
}
// Robokassa signs with the InvId it returns in the callback; recompute with that same value.
base := outSum + ":" + v.Get("InvId") + ":" + c.Password2 + shpSuffix(v)
if !strings.EqualFold(sign(base), sig) {
return uuid.Nil, "", false
}
id, err := uuid.Parse(order)
if err != nil {
return uuid.Nil, "", false
}
return id, outSum, true
}
// shpSuffix renders the Shp_ custom parameters of v as Robokassa binds them into a signature:
// every Shp_-prefixed key, sorted alphabetically, appended as ":key=value".
func shpSuffix(v url.Values) string {
var keys []string
for k := range v {
if strings.HasPrefix(k, "Shp_") {
keys = append(keys, k)
}
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(":")
b.WriteString(k)
b.WriteString("=")
b.WriteString(v.Get(k))
}
return b.String()
}
// sign returns the lowercase hex SHA-256 of s, the hash Robokassa compares against SignatureValue.
func sign(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,85 @@
package robokassa
import (
"net/url"
"strings"
"testing"
"github.com/google/uuid"
)
func testCfg() Config {
return Config{MerchantLogin: "shop", Password1: "p1", Password2: "p2", IsTest: true}
}
// resultSig computes the Pass2 Result signature Robokassa would send for a callback carrying only
// Shp_order — the fixture the verifier must accept.
func resultSig(pass2, outSum, invID string, orderID uuid.UUID) string {
return sign(outSum + ":" + invID + ":" + pass2 + ":Shp_order=" + orderID.String())
}
func TestPaymentURL(t *testing.T) {
cfg := testCfg()
id := uuid.New()
u, err := url.Parse(cfg.PaymentURL(id, "149.00", "10 chips"))
if err != nil {
t.Fatalf("parse payment url: %v", err)
}
q := u.Query()
if got := q.Get("OutSum"); got != "149.00" {
t.Errorf("OutSum = %q, want 149.00", got)
}
if got := q.Get("InvId"); got != "0" {
t.Errorf("InvId = %q, want 0 (unused)", got)
}
if got := q.Get("Shp_order"); got != id.String() {
t.Errorf("Shp_order = %q, want the order id", got)
}
if got := q.Get("IsTest"); got != "1" {
t.Errorf("IsTest = %q, want 1 (test shop)", got)
}
want := sign("shop:149.00:0:p1:Shp_order=" + id.String())
if got := q.Get("SignatureValue"); got != want {
t.Errorf("SignatureValue = %q, want %q", got, want)
}
}
func TestVerifyResult(t *testing.T) {
cfg := testCfg()
id := uuid.New()
valid := func() url.Values {
v := url.Values{}
v.Set("OutSum", "149.00")
v.Set("InvId", "0")
v.Set("Shp_order", id.String())
// Robokassa sends the hash uppercase; the verifier must be case-insensitive.
v.Set("SignatureValue", strings.ToUpper(resultSig("p2", "149.00", "0", id)))
return v
}
gotID, gotSum, ok := cfg.VerifyResult(valid())
if !ok || gotID != id || gotSum != "149.00" {
t.Fatalf("VerifyResult(valid) = %v/%q/%v, want %v/149.00/true", gotID, gotSum, ok, id)
}
// A tampered amount breaks the signature.
tampered := valid()
tampered.Set("OutSum", "1.00")
if _, _, ok := cfg.VerifyResult(tampered); ok {
t.Error("VerifyResult accepted a tampered amount")
}
// The wrong Password2 (a forged callback) does not verify.
wrongPass := Config{MerchantLogin: "shop", Password1: "p1", Password2: "other"}
if _, _, ok := wrongPass.VerifyResult(valid()); ok {
t.Error("VerifyResult accepted a signature under the wrong password")
}
// A missing Shp_order is rejected (no order to credit).
noOrder := valid()
noOrder.Del("Shp_order")
if _, _, ok := cfg.VerifyResult(noOrder); ok {
t.Error("VerifyResult accepted a callback with no order")
}
}
+42 -3
View File
@@ -57,9 +57,9 @@ type bannerTimingsDTO struct {
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc) r := profileResponseFor(acc)
// Resolve the payments gate once (execution context + present sources) and feed it to both // Resolve the payments gate once (execution context + present sources) and feed it to both
// the hint count and the banner. The profile hint balance now comes from the payments benefit // the hint count and the banner. The profile hint balance comes from the payments benefit
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy // (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any
// value from profileResponseFor (zeroed in production) stands. // failure the fallback from profileResponseFor is a plain 0.
cxt, present, err := s.walletGate(ctx, acc.ID) cxt, present, err := s.walletGate(ctx, acc.ID)
if err != nil { if err != nil {
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err)) s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
@@ -71,6 +71,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
} }
} }
r.Banner = s.bannerFor(ctx, acc, cxt, present) r.Banner = s.bannerFor(ctx, acc, cxt, present)
r.Ads = s.adsFor(ctx, acc, cxt, present)
s.fillLinkedIdentities(ctx, &r, acc.ID) s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions() r.DictVersions = s.currentDictVersions()
return r return r
@@ -177,6 +178,44 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payment
} }
} }
// adsDTO is the post-move interstitial config in the profile: the client-mirrored cooldowns
// (seconds) and whether ads are suppressed in the context (a no-ads benefit applicable here, or the
// no_banner role). The client shows a VK interstitial after a confirmed move / hint only when not
// suppressed and the mirrored cooldown has elapsed.
type adsDTO struct {
CooldownGlobalS int `json:"cooldown_global_s"`
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
CooldownHintS int `json:"cooldown_hint_s"`
Suppressed bool `json:"suppressed"`
}
// adsFor builds the profile interstitial-ad config: the cooldowns and whether ads are suppressed
// here (the same no-ads / no_banner gate as the banner). A read failure logs and yields a suppressed
// block (fail-safe: no interstitial), so the profile still succeeds.
func (s *Server) adsFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *adsDTO {
if s.payments == nil {
return nil
}
global, vsAi, hint, err := s.payments.InterstitialCooldowns(ctx)
if err != nil {
s.log.Warn("profile: ad cooldowns read failed", zap.String("account", acc.ID.String()), zap.Error(err))
return &adsDTO{Suppressed: true}
}
suppressed := false
if adFree, aerr := s.payments.AdFree(ctx, acc.ID, cxt, present); aerr != nil {
s.log.Warn("profile: ad-free read failed", zap.String("account", acc.ID.String()), zap.Error(aerr))
suppressed = true // fail-safe: suppress the interstitial when eligibility is unknown
} else {
suppressed = adFree
}
if !suppressed {
if noBanner, berr := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner); berr == nil {
suppressed = noBanner
}
}
return &adsDTO{CooldownGlobalS: global, CooldownVsAiS: vsAi, CooldownHintS: hint, Suppressed: suppressed}
}
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO, // bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
// projecting each optional colour set into its three "#rrggbb" fields (empty when // projecting each optional colour set into its three "#rrggbb" fields (empty when
// the set is absent, so JSON omitempty drops them). // the set is absent, so JSON omitempty drops them).
+14 -7
View File
@@ -60,6 +60,11 @@ type profileResponse struct {
// see the banner (a free account with an empty hint wallet and without the // see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go. // no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"` Banner *bannerDTO `json:"banner,omitempty"`
// Ads carries the post-move interstitial config for the client's client-mirrored gate: the
// cooldowns (seconds) and whether ads are suppressed in this context (no-ads / no_banner role).
// The client shows a VK interstitial after a confirmed move / hint when not suppressed and the
// cooldown has elapsed. Always present (the client also gates VK-only + online itself).
Ads *adsDTO `json:"ads,omitempty"`
// Email is the account's confirmed email address ("" when none); TelegramLinked and // Email is the account's confirmed email address ("" when none); TelegramLinked and
// VkLinked report whether a platform identity is attached. They drive the profile's // VkLinked report whether a platform identity is attached. They drive the profile's
// link / unlink / change-email controls, and are filled outside the pure projection // link / unlink / change-email controls, and are filled outside the pure projection
@@ -218,13 +223,15 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse {
// profileResponseFor projects an account into its profile DTO. // profileResponseFor projects an account into its profile DTO.
func profileResponseFor(acc account.Account) profileResponse { func profileResponseFor(acc account.Account) profileResponse {
return profileResponse{ return profileResponse{
UserID: acc.ID.String(), UserID: acc.ID.String(),
DisplayName: acc.DisplayName, DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage, PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone, TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout), AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout), AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
HintBalance: acc.HintBalance, // The hint balance comes from the payments benefit; profileResponse overrides this
// with the context-aware count. This zero is the fallback when that read fails.
HintBalance: 0,
BlockChat: acc.BlockChat, BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests, BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest, IsGuest: acc.IsGuest,
+19
View File
@@ -72,6 +72,23 @@ func (s *Server) registerRoutes() {
u.GET("/wallet", s.handleWallet) u.GET("/wallet", s.handleWallet)
u.GET("/wallet/catalog", s.handleWalletCatalog) u.GET("/wallet/catalog", s.handleWalletCatalog)
u.POST("/wallet/buy", s.handleWalletBuy) u.POST("/wallet/buy", s.handleWalletBuy)
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
u.POST("/wallet/reward", s.handleWalletReward)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
// the Robokassa Result callback when a merchant is configured.
u.POST("/wallet/order", s.handleWalletOrder)
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
} }
if s.links != nil { if s.links != nil {
// Account linking & merge. The request step always mails a code; // Account linking & merge. The request step always mails a code;
@@ -266,6 +283,8 @@ func statusForError(err error) (int, string) {
return http.StatusNotFound, "product_not_found" return http.StatusNotFound, "product_not_found"
case errors.Is(err, payments.ErrNotAValue): case errors.Is(err, payments.ErrNotAValue):
return http.StatusBadRequest, "not_a_value" return http.StatusBadRequest, "not_a_value"
case errors.Is(err, payments.ErrNotAPack):
return http.StatusBadRequest, "not_a_pack"
case errors.Is(err, account.ErrInvalidEmail): case errors.Is(err, account.ErrInvalidEmail):
return http.StatusBadRequest, "invalid_email" return http.StatusBadRequest, "invalid_email"
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
@@ -29,10 +29,6 @@ import (
// adminPageSize is the page size of the admin console's paginated lists. // adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50 const adminPageSize = 50
// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a
// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake.
const maxHintGrant = 100
// registerConsole mounts the server-rendered admin console under /_gm. The gateway // registerConsole mounts the server-rendered admin console under /_gm. The gateway
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the // puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard // backend trusts the gateway (as for all of /api) and adds only a same-origin guard
@@ -56,7 +52,6 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/users/:id", s.consoleUserDetail) gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
gm.POST("/users/:id/block", s.consoleBlockUser) gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser) gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole) gm.POST("/users/:id/grant-role", s.consoleGrantRole)
@@ -354,7 +349,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view := adminconsole.UserDetailView{ view := adminconsole.UserDetailView{
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant,
CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
} }
if acc.MergedInto != uuid.Nil { if acc.MergedInto != uuid.Nil {
@@ -963,30 +957,6 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
} }
// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a
// player up and can never lower what they already hold, so blocking a reduction is inherent rather
// than a separate guard. A single grant is bounded by maxHintGrant.
func (s *Server) consoleGrantHints(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
n, err := strconv.Atoi(trimForm(c, "amount"))
if err != nil || n < 1 || n > maxHintGrant {
s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back)
return
}
balance, err := s.accounts.GrantHints(c.Request.Context(), id, n)
if err != nil {
s.consoleError(c, err)
return
}
// A non-empty hint wallet removes the banner: nudge an open client to re-check.
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
}
// consoleRemoveEmail deletes the account's bound email identity (and any pending // consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only // confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable. // identity, which would leave it unreachable.
+399
View File
@@ -0,0 +1,399 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// Ledger/order provider tags per rail.
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
type walletOrderRequest struct {
ProductID string `json:"product_id"`
}
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
type walletOrderResponse struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title,omitempty"`
InvoiceAmount int64 `json:"invoice_amount,omitempty"`
}
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
// provider callback.
func (s *Server) handleWalletOrder(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
return
}
productID, err := uuid.Parse(req.ProductID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
switch cxt.Kind {
case payments.SourceDirect:
if s.robokassa.MerchantLogin == "" {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
// D36: a direct purchase requires a confirmed email anchor.
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
if !hasEmail {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
return
}
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
if err != nil {
s.abortErr(c, err)
return
}
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
case payments.SourceTelegram:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
if err != nil {
s.abortErr(c, err)
return
}
// The gateway mints the Stars invoice link from the title and amount via the bot (only
// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
// whole stars (the XTR minor unit is the star). No chips are credited until the verified
// successful_payment is forwarded back through the bot.
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
Rail: providerTelegram,
InvoiceTitle: res.Title,
InvoiceAmount: res.Amount.Minor(),
})
default:
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
}
}
// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
func (s *Server) handleRobokassaResult(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.String(http.StatusBadRequest, "bad request")
return
}
v := url.Values{}
for k, val := range params {
v.Set(k, val)
}
orderID, outSum, ok := s.robokassa.VerifyResult(v)
if !ok {
s.log.Warn("robokassa result: bad signature")
c.String(http.StatusBadRequest, "bad sign")
return
}
paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
if err != nil {
c.String(http.StatusBadRequest, "bad amount")
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusBadRequest, "rejected")
return
}
s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
}
// vkErrorResponse builds VK's error envelope for a payment callback.
func vkErrorResponse(code int, msg string, critical bool) gin.H {
return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
}
// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
// the sandbox notifications and are handled identically.
func (s *Server) handleVKCallback(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
return
}
ctx := c.Request.Context()
switch params["notification_type"] {
case "get_item", "get_item_test":
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
title, amount, err := s.payments.OrderItem(ctx, orderID)
if err != nil {
s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
c.JSON(http.StatusOK, gin.H{"response": gin.H{
"item_id": orderID.String(),
"title": title,
"price": amount.Minor(),
}})
case "order_status_change", "order_status_change_test":
if params["status"] != "chargeable" {
c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
return
}
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
price, err := strconv.ParseInt(params["item_price"], 10, 64)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
vkOrderID := params["order_id"]
outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
return
}
s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
default:
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
}
}
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
// the order in the invoice payload and the amount and currency Telegram is about to charge.
type telegramPreCheckoutRequest struct {
OrderID string `json:"order_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
// short message the bot surfaces to the payer on a decline.
type telegramPreCheckoutResponse struct {
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
}
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
// is refused here, before any star moves) and whose amount and currency match. A malformed
// reference is a decline, not an error.
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
var req telegramPreCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
if err != nil {
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
reason := ""
if !out.OK {
// Localise the decline to the order account's preferred language (the reason shows in the
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
lang := ""
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
lang = acc.PreferredLanguage
}
}
reason = telegramDeclineText(out.Reason, lang)
}
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
}
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
// anything else falls back to English), for display in the Telegram payment sheet.
func telegramDeclineText(code, lang string) string {
ru := lang == "ru"
switch code {
case payments.PreCheckoutAlreadyPaid:
if ru {
return "Этот заказ уже оплачен."
}
return "This order has already been paid."
case payments.PreCheckoutPriceChanged:
if ru {
return "Цена изменилась — начните покупку заново."
}
return "The price has changed; please start the purchase again."
default:
if ru {
return "Этот заказ больше недоступен."
}
return "This order is no longer available."
}
}
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
type telegramPaymentRequest struct {
OrderID string `json:"order_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
Amount int64 `json:"amount"`
TelegramUserID int64 `json:"telegram_user_id"`
}
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
// (the bot drops it); a 5xx means a transient failure the bot retries.
type telegramPaymentResponse struct {
Credited bool `json:"credited"`
}
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
// the bot retries.
func (s *Server) handleTelegramPayment(c *gin.Context) {
var req telegramPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
if err != nil {
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
// The star charge already happened but the order cannot be placed; record it loudly for
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
s.log.Error("telegram payment rejected (charge taken, not credited)",
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
}
+65 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments" "scrabble/backend/internal/payments"
) )
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
// walletDTO is the user-facing wallet: the context-visible chip segments and the // walletDTO is the user-facing wallet: the context-visible chip segments and the
// context-applicable benefits (the no-ads term or forever flag, and the available hints). // context-applicable benefits (the no-ads term or forever flag, and the available hints).
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
// only when it is positive.
type walletDTO struct { type walletDTO struct {
Segments []walletSegmentDTO `json:"segments"` Segments []walletSegmentDTO `json:"segments"`
AdsForever bool `json:"ads_forever"` AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
Hints int `json:"hints"` Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
} }
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips. // walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
@@ -108,7 +113,8 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
} }
// handleWallet returns the caller's wallet — the segments and benefits visible in the current // handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context. // trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
// unconfigured), which gates the client's "watch for chips" button.
func (s *Server) handleWallet(c *gin.Context) { func (s *Server) handleWallet(c *gin.Context) {
uid, ok := userID(c) uid, ok := userID(c)
if !ok { if !ok {
@@ -125,7 +131,13 @@ func (s *Server) handleWallet(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
c.JSON(http.StatusOK, walletDTOFrom(view)) dto := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
} else {
dto.RewardChips = payout
}
c.JSON(http.StatusOK, dto)
} }
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is // handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
@@ -162,3 +174,54 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
} }
c.JSON(http.StatusOK, walletDTOFrom(view)) c.JSON(http.StatusOK, walletDTOFrom(view))
} }
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce, the idempotency
// key for a single watched view (a retry credits once).
type walletRewardRequest struct {
Nonce string `json:"nonce"`
}
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
// updated wallet (like a spend).
func (s *Server) handleWalletReward(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletRewardRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
abortBadRequest(c, "nonce is required")
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
if err != nil {
s.abortErr(c, err)
return
}
if outcome.Capped {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
return
}
if outcome.Chips == 0 && !outcome.AlreadyCredited {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
view2 := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
view2.RewardChips = payout
}
c.JSON(http.StatusOK, view2)
}
+6
View File
@@ -31,6 +31,7 @@ import (
"scrabble/backend/internal/payments" "scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render" "scrabble/backend/internal/render"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
@@ -109,6 +110,9 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A // Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works). // nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
// order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
} }
// Server owns the gin engine, the underlying HTTP server and the readiness // Server owns the gin engine, the underlying HTTP server and the readiness
@@ -136,6 +140,7 @@ type Server struct {
banview *banview.View banview *banview.View
ads *ads.Service ads *ads.Service
payments *payments.Service payments *payments.Service
robokassa robokassa.Config
notifier notify.Publisher notifier notify.Publisher
console *adminconsole.Renderer console *adminconsole.Renderer
exportKey []byte exportKey []byte
@@ -189,6 +194,7 @@ func New(addr string, deps Deps) *Server {
banview: deps.BanView, banview: deps.BanView,
ads: deps.Ads, ads: deps.Ads,
payments: deps.Payments, payments: deps.Payments,
robokassa: deps.Robokassa,
notifier: notifier, notifier: notifier,
renderer: deps.Renderer, renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine}, http: &http.Server{Addr: addr, Handler: engine},
+31 -12
View File
@@ -236,6 +236,7 @@ The main host archives Postgres continuously with **pgBackRest** to **Selectel S
(encrypted at rest, path-style addressing) so the database can be restored to any moment — (encrypted at rest, path-style addressing) so the database can be restored to any moment —
protecting the money ledger and the game state against corruption or host loss. It is the protecting the money ledger and the game state against corruption or host loss. It is the
primary recovery path; the migration-window `pg_dump` above is the secondary net. primary recovery path; the migration-window `pg_dump` above is the secondary net.
**Live on the prod main host since v1.13.0** (armed + restore-drilled 2026-07-09).
**Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus **Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus
**continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every **continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every
@@ -258,8 +259,9 @@ absent/NaN-safe, so they stay quiet until archiving is armed.
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At `pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under 30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under
1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is 1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup **negligible**: archive-push moves tiny compressed segments, and the daily full base backup is
is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and checkpoint-bound
(~1.5 min wall, minimal CPU/I-O) on the 2 vCPU host. Revisit if traffic grows ~100× (watch
`node_exporter` during a base backup). `node_exporter` during a base backup).
**Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on: **Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on:
@@ -276,14 +278,22 @@ is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~1
2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres 2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres
with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the
minute until step 3 creates the repository — the WAL is retained, not lost.) minute until step 3 creates the repository — the WAL is retained, not lost.)
3. On the main host, create the repository, take the first base backup, and verify: 3. On the main host, create the repository, verify archiving, take the first base backup. Run
pgBackRest **as the `postgres` OS user** (`-u postgres` — lock-dir/PGDATA consistency with
`archive-push`) and connect to the DB **as the superuser role** (`--pg1-user=scrabble`, the
`POSTGRES_USER`, not `postgres`); it inherits the container's `PGBACKREST_*` env:
```sh ```sh
docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create
docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check
docker exec scrabble-postgres pgbackrest --stanza=scrabble check docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble info # (info takes no --pg1-user)
``` ```
4. Enable the daily timer: `ansible-playbook site.yml -e pitr_enabled=true` (installs + starts The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the
`pgbackrest-backup.timer` on the main host). expected transient (WAL retained, not lost); clear the counter afterwards with
`docker exec -u postgres scrabble-postgres psql -U scrabble -d scrabble -c "SELECT pg_stat_reset_shared('archiver');"`
so it does not trip the failing-archive alert.
4. Enable the daily timer: `ansible-playbook site.yml --limit main -e pitr_enabled=true`
(installs + starts `pgbackrest-backup.timer` on the main host).
5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a 5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a
real number, `failed_count` flat). real number, `failed_count` flat).
@@ -292,17 +302,26 @@ an **isolated, one-shot** target (a throwaway VM or a container with no ingress)
the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment: the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment:
```sh ```sh
pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore # Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an
# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail # empty PGDATA and no app network; restore, recover, verify, then wipe (-v drops the data).
IMG=$(docker inspect -f '{{.Config.Image}}' scrabble-postgres)
docker exec scrabble-postgres env | grep '^PGBACKREST_' > /tmp/drill.env; echo POSTGRES_PASSWORD=drill >> /tmp/drill.env
docker run -d --name pitr-drill --env-file /tmp/drill.env --entrypoint sleep "$IMG" infinity
docker exec pitr-drill sh -c 'rm -rf /var/lib/postgresql/data/*; chown -R postgres:postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data'
# --type=immediate = to the base backup's consistency point; --type=time "--target=<ts>" for PITR
docker exec -u postgres pitr-drill pgbackrest --stanza=scrabble --pg1-path=/var/lib/postgresql/data --type=immediate restore
docker exec -u postgres pitr-drill pg_ctl -D /var/lib/postgresql/data -w start
docker exec -u postgres pitr-drill psql -U scrabble -d scrabble -c "SELECT count(*) FROM backend.accounts;" # verify data intact
docker rm -fv pitr-drill; rm -f /tmp/drill.env # wipe the restored real data
``` ```
The target holds **real money + personal data** while it exists — keep it network-isolated and The target holds **real money + personal data** while it exists — keep it network-isolated and
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target **destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
timestamp, outcome) here: timestamp, outcome) here:
| Date | Target timestamp | Result | | Date | Target | Result |
| --- | --- | --- | | --- | --- | --- |
| _pending first arming_ | — | — | | 2026-07-09 | latest (`--type=immediate`) | PASS — v1.13.0 arming: 31.9 MB cluster restored from the encrypted S3 repo (3.7 MB compressed), recovered to consistency, `backend.accounts` intact; drill instance wiped. |
**bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the **bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
@@ -10,6 +10,8 @@ Requires=docker.service
[Service] [Service]
Type=oneshot Type=oneshot
# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_* # docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and
# environment (the S3 repository + cipher), so no repository config is needed on the host. # PGDATA consistency with archive-push, and connect to the database as the superuser role via
ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup # --pg1-user: that role is the POSTGRES_USER (scrabble), not "postgres". It inherits the
# container's PGBACKREST_* environment (the S3 repository + cipher), so no host-side config.
ExecStart=/usr/bin/docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
+1 -1
View File
@@ -86,7 +86,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any # The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request). # tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
handle @gateway { handle @gateway {
reverse_proxy gateway:8081 { reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot header_up -X-Scrabble-Honeypot
+4
View File
@@ -28,6 +28,10 @@ services:
# the manage-topics and delete-messages rights. # the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail; empty disables it. The rail stays inert until a chip pack carries an XTR (Stars)
# price, so it is safe to leave on — seeding a Stars price in the admin is the real go-live.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
+14
View File
@@ -161,6 +161,13 @@ services:
# recipient(s) (comma-separated allowed). Both empty disables the alert worker. # recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-} BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-} BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
# direct order and Result-callback endpoints unregistered (the rail is off).
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
# The dictionary lives on a named volume seeded from the image on first boot # The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the # inherits). The admin console writes new version subdirectories here, and the
@@ -200,6 +207,9 @@ services:
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-} VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
# elsewhere so production shows real ads).
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag). # Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
VERSION: ${APP_VERSION:-dev} VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
@@ -378,6 +388,10 @@ services:
# set the bot must be an admin there with the manage-topics and delete-messages rights. # set the bot must be an admin there with the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
# The optional standalone promo bot (its own token) answering /start with a button # The optional standalone promo bot (its own token) answering /start with a button
# into the main bot's app. Empty disables it; when set it needs the main bot's # into the main bot's app. Empty disables it; when set it needs the main bot's
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK). # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
+18 -3
View File
@@ -18,10 +18,25 @@
@shell not path /assets/* @shell not path /assets/*
header @shell Cache-Control "no-cache" header @shell Cache-Control "no-cache"
# The static public offer page, rendered from ui/legal/offer_ru.md at build
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
# landing shell below (whose index is landing.html). A bare /offer redirects in.
handle /offer {
redir * /offer/ permanent
}
handle /offer/* {
file_server {
index index.html
}
}
# An unknown path falls back to the landing shell (the gateway's old "/" # An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below. # behaviour); "/" itself resolves through the index below.
try_files {path} /landing.html handle {
file_server { try_files {path} /landing.html
index landing.html file_server {
index landing.html
}
} }
} }
+9 -2
View File
@@ -971,7 +971,13 @@ the console; the backend calls them on the **gateway's bot-link relay**, which f
to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). It also
carries the **Telegram Stars payment path** (§payments): a `CreateInvoice` command has the bot mint a
`createInvoiceLink` (XTR) and return it in the Ack; the bot's unary `ValidatePreCheckout` gates a
`pre_checkout_query` against the intake (declining an already-paid reusable invoice before the
charge); and its unary `ForwardPayment` delivers a completed `successful_payment` — durably queued in
a bot-side **SQLite outbox** (`platform/telegram/internal/outbox`, re-driven on restart) — which the
gateway proxies to the backend intake, credited once (idempotent on `telegram_payment_charge_id`). An optional
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot **standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app` answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway. button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
@@ -1328,7 +1334,8 @@ in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth a
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the **admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
catch-all — notably the landing at `/` — goes to the landing container. The catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
**Telegram validator** runs as a separate container with **no public ingress**, **Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
+59 -24
View File
@@ -82,7 +82,7 @@ leaking out to the open web) is allowed.
| Execution context | Spendable chip segments | Spend priority | | Execution context | Spendable chip segments | Spend priority |
|---------------------|-------------------------|--------------------| |---------------------|-------------------------|--------------------|
| Inside VK (Android) | `vk` | — | | Inside VK (Android) | `vk` | — |
| Inside VK (iOS) | none — frozen (view only)| — | | Inside VK (iOS) | `vk` (purchase-frozen) | — |
| Inside Telegram | `telegram` | — | | Inside Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | | Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
@@ -90,9 +90,11 @@ leaking out to the open web) is allowed.
invisible-as-spendable there. invisible-as-spendable there.
- On the web the store has no jurisdiction, so all attached segments are spendable, drained - On the web the store has no jurisdiction, so all attached segments are spendable, drained
by priority direct → vk → tg. by priority direct → vk → tg.
- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital - **VK iOS is a PURCHASE freeze, not a spend freeze.** Apple's ToS forbids only **buying** in-app
goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or values there (for any currency) — so a purchase (money → chips) is refused. **Spending** VK-wallet
spend is possible. A previously bought benefit still *applies* there. chips (earned via rewarded ads or bought on the same VK account elsewhere, e.g. VK Android) and
earning them are legal and stay allowed on VK iOS; a bought benefit also *applies* there. Only the
money-in step is blocked.
The account is **single** (identities merge, one profile/friends/stats). The gate is The account is **single** (identities merge, one profile/friends/stats). The gate is
**logical**: in a VK/TG context the server activates only the same-named segment. It rests **logical**: in a VK/TG context the server activates only the same-named segment. It rests
@@ -219,12 +221,21 @@ the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provide
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation — valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
the money is real, the chips are owed). The user sees only successful purchases. the money is real, the chips are owed). The user sees only successful purchases.
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and **TG Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and- mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`. `createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
intake = credited exactly once. (backed by the intake): approve only if the order exists, is still creditable and is **not already
paid** — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
before the charge; the decline reason is localised to the order account's language.
`successful_payment` reaches the bot only (Bot API, not the Mini App), and the bot host is weak and
can lose connectivity, so the bot is a durable link. Store-and-forward on **SQLite** on the bot's
disk (`internal/outbox`): persist on receipt (idempotent on `telegram_payment_charge_id`) → forward
over the bot-link (a `ForwardPayment` unary; the gateway proxies to the intake) → on a durable
response, mark `forwarded`. Re-drives undelivered on restart and on a periodic tick. At-least-once
delivery + idempotent intake (dedup by `telegram_payment_charge_id`) = credited exactly once.
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a **Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
@@ -232,14 +243,22 @@ existing `botlink` push / email relay. "Payment failed" (an **active** provider
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
message). message).
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may **Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their **admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa
account — tie into `accountdelete`, which already preserves messages). **External** refunds refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event, deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and
**best-effort** revokes the benefit (never going negative; if chips were already spent, it Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine —
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready** the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's
yet; the schema stays compatible). payment id, so the two rows coexist under the same partial-unique index), and **best-effort revokes
the funded chips floored at 0** (never negative — D27, `balances_chips_chk`). When the chips were
already spent, the unrecoverable remainder is recorded as a per-account **loss + abuse flag**
(`payments.account_risk`, read by the E7 report). The refund ledger row's chip delta is what was
actually reclaimed, so the ledger stays reconcilable against the balance; the **full** reversal
(money, original chips, loss) rides in the row's snapshot. The order stays `paid` — the refund lives
in the ledger + a `refunded` payment event, not in the order status. A duplicate refund reverses
nothing. The ledger is **export-ready** for future tax reporting and reconciliation (the reconciler
itself is not built yet; the schema stays compatible).
## 10. Ads ## 10. Ads
@@ -249,16 +268,32 @@ ruble-paying in-app network exists. The ad provider is behind an **abstraction**
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob) network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
are rejected — no legal ruble income for a self-employed (НПД) developer. are rejected — no legal ruble income for a self-employed (НПД) developer.
**Rewarded** (voluntary video for chips) credits chips through payments on the network's **Rewarded** (voluntary video for chips) credits chips through payments. **VK Mini App ads expose
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is only a client-side watch result** (`VKWebAppShowNativeAds``data.result`) — there is no
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later). server-to-server verify — so a rewarded credit is **client-attested** (D29 amended: server verify is
not possible on VK). The anti-abuse (and economic) guard is a **server-side daily and hourly cap**
(config `reward_daily_cap` / `reward_hourly_cap`, default 50 / 10), which bounds a forger who skips
the ad and calls the credit endpoint directly and, as importantly, limits free rewarded chips so a
player who wants more **buys**. The credit is idempotent on a client nonce and order-less; the payout
is config (`rewarded_payout_chips`, default 0 = rewarded off until set). Rewarded is VK-only and is
not suppressed by no-ads (D9). A future network that offers a server verify slots in behind the ads
abstraction. On the test contour a build flag (`VITE_ADS_STUB`) swaps a toast for the real ad; prod
always shows real ads (a failed real ad must not credit).
**Interstitial** (post-move fullscreen), configurable server-side values: **Interstitial** (fullscreen after a confirmed play), **VK-only**, configurable server-side
values. The gate is **client-mirrored**: the profile carries the cooldowns and a `suppressed`
flag (the same no-ads / `no_banner` gate as the banner, resolved server-side in `adsFor`), and
the client self-gates on a **single shared** last-shown time in `localStorage` — the kind only
picks the required gap, so a hint ad and a move ad never fire within a cooldown of each other
(one shared timer, not one per kind) — no per-move server round-trip. The contour
`VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad. Values:
- Global cooldown **per user, across all games**, default **5 min**. - Global cooldown **per user, across all games**, default **5 min**.
- **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players). - **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players).
- Applying a **hint** triggers a post-move interstitial **independently** of the main - Applying a **hint** triggers an interstitial **independently** of the main cooldown, with
cooldown, with its own **1-min** cooldown. its own **1-min** cooldown.
- Fires **only after a confirmed play or a hint** — never after a pass, exchange or resign
(an ad for a non-scoring action would only annoy, so those are never "rewarded" with one).
- Offline — banner only. - Offline — banner only.
- Respect VK's own frequency caps. - Respect VK's own frequency caps.
+27 -2
View File
@@ -110,6 +110,11 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) — сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) —
подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть
(`platform/telegram/internal/initdata`). (`platform/telegram/internal/initdata`).
**АМЕНД (E6, находка владельца по ToS):** VK iOS — **заморозка только ПОКУПОК** (деньги→Фишки),
а не траты. Apple запрещает там лишь **покупать** внутриигровые ценности за любую валюту; а
**тратить** Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте
в Android) и зарабатывать их — легально и на iOS. Код: `vkFrozen()` гейтит только `CreateOrder`
(покупку), не `spendableSources` (трату).
- **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без - **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без
валидной подписи на старте; старая сессия без записанной платформы) → запрет валидной подписи на старте; старая сессия без записанной платформы) → запрет
трат/покупок/применения чужого origin, только просмотр. трат/покупок/применения чужого origin, только просмотр.
@@ -158,20 +163,40 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
закладываем **абстракцией** (будущая крутилка для других платформ встроится без закладываем **абстракцией** (будущая крутилка для других платформ встроится без
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
дохода самозанятому (санкции + НПД не учитывает крипту). дохода самозанятому (санкции + НПД не учитывает крипту).
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments по - **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments. Не
**серверному verify-колбэку** рекламной сети (клиенту не верим, как платёж). Не
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики. гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
**АМЕНД (E6, по факту реализации):** у VK Mini App серверного verify-колбэка **нет**
`VKWebAppShowNativeAds` отдаёт только клиентский `data.result`. Поэтому начисление
**client-attested**, а защита (и экономический рычаг) — **серверный дневной и часовой кап**
(config `reward_daily_cap` / `reward_hourly_cap`, дефолт 50 / 10) + идемпотентность по клиентскому
nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения): - **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин** глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода (соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
**независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только **независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только
баннер. Уважать собственные лимиты частоты VK. баннер. Уважать собственные лимиты частоты VK.
**АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed`
в профиле (`adsFor`), клиент сам гейтит по **единому** времени последнего показа в `localStorage`
(без раунд-трипа на ход). Таймер **общий на все виды** — вид (`hint`/`move`/`vs_ai`) лишь выбирает
нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь **более короткий
интервал от последнего показа**, а не отдельный таймер: hint-ролик и move-ролик не встают подряд
(баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу
крутил рекламу). Ролик показывается **только после подтверждённого хода или подсказки****не**
после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial —
**только VK** (как и rewarded). Частота — глобальная на все игры (localStorage на устройство).
- **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в - **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в
пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible` пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible`
(`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту, (`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту,
**применимому в текущем контексте**, а не по одному глобальному флагу. Legacy **применимому в текущем контексте**, а не по одному глобальному флагу. Legacy
`paid_account`/`hint_balance` в проде никем не выставлены (потока покупки не было) → `paid_account`/`hint_balance` в проде никем не выставлены (потока покупки не было) →
обнуляем/игнорируем, после релиза платежей дропаем. обнуляем/игнорируем, после релиза платежей дропаем.
**АМЕНД (E6, по факту реализации — expand-contract, шаг 1 «contract-код»):** доменное
использование обеих колонок **убрано** — поля `Account.HintBalance` / `Account.PaidAccount`,
их скан, мёртвый `account.SpendHint`, `account.GrantHints` и админ-действие
«grant-hints» (роут `/_gm/users/:id/grant-hints`, форма, `UserDetailView.HintBalance`/
`PaidAccount`); отображение подсказок в игре теперь всегда из payments (`HintsAvailable`),
профильный баланс — из payments-бенефита. **Колонки БД пока оставлены** (без миграции —
откат образа DB-safe); их `DROP` — отдельным contract-PR, когда E6 стабилен на проде.
- **D32. Каталог — конфигурируемый (БД + админка).** Базовые ценности (атомы - **D32. Каталог — конфигурируемый (БД + админка).** Базовые ценности (атомы
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод** атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
+57 -25
View File
@@ -83,7 +83,7 @@
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты | | Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|----------------------|---------------------------|--------------------| |----------------------|---------------------------|--------------------|
| Внутри VK (Android) | `vk` | — | | Внутри VK (Android) | `vk` | — |
| Внутри VK (iOS) | нет — заморожено (только просмотр) | — | | Внутри VK (iOS) | `vk` (заморожена покупка) | — |
| Внутри Telegram | `telegram` | — | | Внутри Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | | Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
@@ -91,9 +91,11 @@
`direct`) там невидимо как тратимое. `direct`) там невидимо как тратимое.
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по - В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
приоритету direct → vk → tg. приоритету direct → vk → tg.
- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые - **VK iOS заморозка ПОКУПОК, а не траты.** ToS Apple запрещает там только **покупать**
товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А
невозможны. Ранее купленный бенефит там всё равно *действует*. **тратить** Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в
VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже
*действует*. Блокируется только шаг «деньги внутрь».
Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на **логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
@@ -220,12 +222,22 @@ provider_payment_id)`.
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена — Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки. деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а **TG Stars.** До Telegram дотягивается только **бот**, поэтому весь рельс идёт через обратный
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на mTLS **bot-link** (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в заказа gateway шлёт команду `CreateInvoice`, а бот возвращает `createInvoiceLink` (XTR) в Ack,
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил который Mini App открывает через `WebApp.openInvoice`. До списания звёзд бот отвечает на
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка `pre_checkout_query` через унарный вызов бот→gateway `ValidatePreCheckout` (за ним — приём): одобрить,
at-least-once + идемпотентный приём = начисление ровно один раз. только если заказ существует, ещё оплачиваем и **не оплачен ранее** — ссылка Stars-инвойса
переиспользуема, так что этот гейт — единственное место, где повторная оплата отсекается **до**
списания; текст отказа локализован в язык аккаунта заказа.
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а хост бота
слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на **SQLite** на диске
бота (`internal/outbox`): сохранил при получении (идемпотентно по `telegram_payment_charge_id`) →
форвардит по bot-link (унарный `ForwardPayment`; gateway проксирует в приём) → при durable-ответе
пометил `forwarded`. Дореталивает недоставленное при рестарте и по периодическому тику. Доставка
at-least-once + идемпотентный приём (дедуп по `telegram_payment_charge_id`) = начисление ровно один
раз.
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded); **События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
@@ -233,14 +245,20 @@ at-least-once + идемпотентный приём = начисление р
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
бота). бота).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать **Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты (авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`, поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены — (`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций `(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
(саму сверку пока не строим; схема остаётся совместимой). полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный
остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не
отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности
и сверки (саму сверку пока не строим; схема остаётся совместимой).
## 10. Реклама ## 10. Реклама
@@ -250,17 +268,31 @@ web/native/TG держат только существующий наш **тек
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
рублёвого дохода самозанятому (НПД). рублёвого дохода самозанятому (НПД).
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по **Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен. **VK Mini
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только App отдаёт только клиентский результат просмотра** (`VKWebAppShowNativeAds``data.result`) —
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты серверной проверки нет — поэтому начисление **client-attested** (D29 амендим: server-verify у VK
позже). невозможен). Защита — **серверный дневной и часовой кап** (config `reward_daily_cap` /
`reward_hourly_cap`, дефолт 50 / 10): он ограничивает читера, который пропускает ролик и дёргает
эндпоинт напрямую, и — не менее важно — лимитирует бесплатные Фишки, чтобы желающий больше **покупал**.
Начисление идемпотентно по клиентскому nonce и order-less; выплата — config
(`rewarded_payout_chips`, дефолт 0 = ролик выключен, пока не задан). Rewarded только в VK и не
гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре
build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения: **Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные
значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт
«без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам
гейтит по **единому** времени последнего показа в `localStorage` — вид лишь выбирает нужный
интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер,
не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный `VITE_ADS_STUB` подменяет
ролик тем же тостом «ad fired». Значения:
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**. - Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов). - **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна, - Применение **подсказки** триггерит ролик **независимо** от основного кулдауна, со своим
со своим кулдауном **1 мин**. кулдауном **1 мин**.
- Показывается **только после подтверждённого хода или подсказки** — никогда после пропуска,
обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»).
- Оффлайн — только баннер. - Оффлайн — только баннер.
- Уважать собственные лимиты частоты VK. - Уважать собственные лимиты частоты VK.
+5 -1
View File
@@ -29,6 +29,9 @@ ARG VITE_VK_APP_ID=
ARG VITE_VK_ID_REDIRECT_URL= ARG VITE_VK_ID_REDIRECT_URL=
ARG VITE_GATEWAY_URL= ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION= ARG VITE_APP_VERSION=
# VITE_ADS_STUB=1 substitutes a toast stub for real ads (the test contour only); production leaves it
# empty so it always shows real ads (a failed real ad must not credit).
ARG VITE_ADS_STUB=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \ VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
@@ -36,7 +39,8 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_VK_APP_ID=$VITE_VK_APP_ID \ VITE_VK_APP_ID=$VITE_VK_APP_ID \
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \ VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script # Install with the lockfile first (the workspace file carries pnpm's build-script
# approval for esbuild), then build. Committed src/gen/ means no codegen here. # approval for esbuild), then build. Committed src/gen/ means no codegen here.
+17 -1
View File
@@ -153,6 +153,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
r, rerr := backend.ChatEligibility(ctx, externalID) r, rerr := backend.ChatEligibility(ctx, externalID)
return r.Registered, r.Eligible, rerr return r.Registered, r.Eligible, rerr
}) })
// The Telegram Stars payment bridge rides the same bot-link: the bot validates each
// pre_checkout and forwards each completed payment through these, backed by the backend intake.
botHub.SetPaymentBridge(
func(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
return backend.ValidatePreCheckout(ctx, orderID, amount, currency)
},
func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
return backend.TelegramPayment(ctx, orderID, chargeID, amount, telegramUserID)
},
)
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile) tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
if terr != nil { if terr != nil {
return terr return terr
@@ -199,7 +209,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI) vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
logger.Info("vk id web login enabled") logger.Info("vk id web login enabled")
} }
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)) regOpts := []transcode.Option{transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)}
if botHub != nil {
// A Telegram-context wallet order mints its Stars invoice link through the connected bot.
regOpts = append(regOpts, transcode.WithTelegramStars(botHub))
}
registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
@@ -208,6 +223,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Tracker: tracker, Tracker: tracker,
Banlist: banlist, Banlist: banlist,
Honeytoken: cfg.Abuse.Honeytoken, Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub, Hub: hub,
RateLimit: cfg.RateLimit, RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval, Heartbeat: cfg.PushHeartbeatInterval,
+110 -1
View File
@@ -37,6 +37,8 @@ type ProfileResp struct {
// Banner is the advertising-banner block, present only for a viewer eligible to // Banner is the advertising-banner block, present only for a viewer eligible to
// see the banner. The gateway forwards it verbatim into the Profile payload. // see the banner. The gateway forwards it verbatim into the Profile payload.
Banner *BannerResp `json:"banner,omitempty"` Banner *BannerResp `json:"banner,omitempty"`
// Ads is the post-move interstitial config (cooldowns + suppressed) for the client-mirrored gate.
Ads *AdsResp `json:"ads,omitempty"`
// Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an // Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an
// attached platform identity — they drive the profile's link/unlink/change controls. // attached platform identity — they drive the profile's link/unlink/change controls.
Email string `json:"email"` Email string `json:"email"`
@@ -47,6 +49,15 @@ type ProfileResp struct {
DictVersions []DictVersion `json:"dict_versions,omitempty"` DictVersions []DictVersion `json:"dict_versions,omitempty"`
} }
// AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns
// (seconds) and whether ads are suppressed in the caller's context.
type AdsResp struct {
CooldownGlobalS int `json:"cooldown_global_s"`
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
CooldownHintS int `json:"cooldown_hint_s"`
Suppressed bool `json:"suppressed"`
}
// DictVersion pairs a game variant's stable label with its current dictionary version. // DictVersion pairs a game variant's stable label with its current dictionary version.
type DictVersion struct { type DictVersion struct {
Variant string `json:"variant"` Variant string `json:"variant"`
@@ -369,12 +380,14 @@ type WalletSegmentResp struct {
} }
// WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable // WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable
// benefits (no-ads term end as unix millis / forever flag, and the available hints). // benefits (no-ads term end as unix millis / forever flag, and the available hints), plus the
// rewarded-video payout available in the current context (0 = unavailable — outside VK/unconfigured).
type WalletResp struct { type WalletResp struct {
Segments []WalletSegmentResp `json:"segments"` Segments []WalletSegmentResp `json:"segments"`
AdsForever bool `json:"ads_forever"` AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` AdsPaidUntil int64 `json:"ads_paid_until_ms"`
Hints int `json:"hints"` Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
} }
// walletBuyBody is the chip-spend request body. // walletBuyBody is the chip-spend request body.
@@ -382,6 +395,19 @@ type walletBuyBody struct {
ProductID string `json:"product_id"` ProductID string `json:"product_id"`
} }
// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce.
type walletRewardBody struct {
Nonce string `json:"nonce"`
}
// WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a
// backend daily/hourly cap). A reached cap or unconfigured payout surfaces as an APIError domain code.
func (c *Client) WalletReward(ctx context.Context, userID, nonce string) (WalletResp, error) {
var out WalletResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce}, &out)
return out, err
}
// Wallet fetches the caller's wallet in their current execution context. // Wallet fetches the caller's wallet in their current execution context.
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) { func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
var out WalletResp var out WalletResp
@@ -396,6 +422,89 @@ func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (Walle
return out, err return out, err
} }
// walletOrderBody is the POST body of an order: the chip pack to fund.
type walletOrderBody struct {
ProductID string `json:"product_id"`
}
// WalletOrderResp is a created order: its id and the rail's launch details. RedirectURL is the
// provider's hosted-payment URL (direct); Rail names the settling rail; for the Telegram Stars rail
// InvoiceTitle and InvoiceAmount (whole stars) are the invoice the gateway mints via the bot.
type WalletOrderResp struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title"`
InvoiceAmount int64 `json:"invoice_amount"`
}
// WalletOrder opens a pending order to fund a chip pack and returns the rail's launch details.
func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) {
var out WalletOrderResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
return out, err
}
// preCheckoutResp is the backend intake's pre_checkout answer.
type preCheckoutResp struct {
OK bool `json:"ok"`
Reason string `json:"reason"`
}
// ValidatePreCheckout asks the backend intake whether a Telegram Stars pre_checkout_query for
// orderID paying amount in currency may be approved, before the charge. It returns the approval and
// a short decline reason for the payer. It backs the bot's pre_checkout gate through the bot-link.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
var out preCheckoutResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/precheckout", "", "",
map[string]any{"order_id": orderID, "amount": amount, "currency": currency}, &out)
return out.OK, out.Reason, err
}
// telegramPaymentResp is the backend intake's credit outcome.
type telegramPaymentResp struct {
Credited bool `json:"credited"`
}
// TelegramPayment forwards a completed Telegram Stars payment from the bot's outbox to the backend
// intake, which credits the order idempotently on chargeID. It reports whether the order was
// credited (or already had been); a transport error is a transient failure the bot retries.
func (c *Client) TelegramPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
var out telegramPaymentResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/payment", "", "",
map[string]any{
"order_id": orderID,
"telegram_payment_charge_id": chargeID,
"amount": amount,
"telegram_user_id": telegramUserID,
}, &out)
return out.Credited, err
}
// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa.
type robokassaResultResp struct {
Response string `json:"response"`
}
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
// single writer, which verifies the signature and credits) and returns the body to echo to
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
var out robokassaResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
return out.Response, err
}
// VKCallback forwards a gateway-verified VK payment callback's parameters to the backend intake and
// returns the backend's raw VK response envelope (`{"response":…}` or `{"error":…}`) for the gateway
// to relay to VK verbatim. The backend answers 200 in every case (VK's protocol), so a transport
// error here is a genuine proxy failure.
func (c *Client) VKCallback(ctx context.Context, params map[string]string) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/vk/callback", "", "", params, &out)
return out, err
}
// CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity.
type CatalogAtomResp struct { type CatalogAtomResp struct {
AtomType string `json:"atom_type"` AtomType string `json:"atom_type"`
+15
View File
@@ -48,3 +48,18 @@ func ChatGateCommand(externalID string, allow bool) *botlinkv1.Command {
}}, }},
} }
} }
// CreateInvoiceCommand builds a Telegram Stars invoice-mint command: the bot calls
// createInvoiceLink (in XTR) with the given title and description, the order id as the
// payload (echoed by Telegram in pre_checkout and successful_payment) and amountStars whole
// stars, and returns the link in its Ack result.
func CreateInvoiceCommand(title, description, orderID string, amountStars int64) *botlinkv1.Command {
return &botlinkv1.Command{
Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: title,
Description: description,
Payload: orderID,
Amount: amountStars,
}},
}
}
+120
View File
@@ -13,6 +13,7 @@ import (
"strconv" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
@@ -31,6 +32,11 @@ var ErrNoBot = errors.New("botlink: no bot connected")
// (at-most-once under backpressure). // (at-most-once under backpressure).
const outboundBuffer = 64 const outboundBuffer = 64
// invoiceMintTimeout bounds one synchronous invoice-mint round-trip (the gateway commands the bot
// and awaits the Ack carrying the createInvoiceLink result), so a hung or slow bot cannot stall the
// caller's wallet-order request indefinitely.
const invoiceMintTimeout = 15 * time.Second
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility // EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
// for the bot's join-time ResolveChatEligibility query: registered reports whether the // for the bot's join-time ResolveChatEligibility query: registered reports whether the
// identity maps to an account, eligible is the final gate the bot acts on (registered // identity maps to an account, eligible is the final gate the bot acts on (registered
@@ -38,6 +44,17 @@ const outboundBuffer = 64
// chat-access endpoint. // chat-access endpoint.
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error) type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
// PreCheckoutResolver validates a Telegram Stars pre_checkout_query for the bot's
// ValidatePreCheckout query: it answers whether the order may still be charged (ok) and, when not,
// a short reason to show the payer. The gateway backs it with the backend intake.
type PreCheckoutResolver func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
// PaymentForwarder delivers a completed Telegram Stars payment for the bot's ForwardPayment call:
// it credits the order through the backend intake and reports whether it was credited (or already
// had been). A non-nil error is a transient failure the bot retries. The gateway backs it with the
// backend intake.
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
// Hub registers connected bots and routes send commands to them. A single bot is // Hub registers connected bots and routes send commands to them. A single bot is
// expected today; the registry already holds a set so adding more later needs no // expected today; the registry already holds a set so adding more later needs no
// rewrite. // rewrite.
@@ -46,6 +63,10 @@ type Hub struct {
log *zap.Logger log *zap.Logger
eligibility EligibilityResolver eligibility EligibilityResolver
// precheck and forward back the Telegram Stars payment RPCs; nil until SetPaymentBridge wires
// them, in which case the RPCs report Unavailable. Set once before the gRPC server serves.
precheck PreCheckoutResolver
forward PaymentForwarder
mu sync.Mutex mu sync.Mutex
links map[*link]struct{} links map[*link]struct{}
@@ -147,6 +168,105 @@ func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEli
return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil
} }
// SetPaymentBridge wires the Telegram Stars payment resolvers, backing ValidatePreCheckout and
// ForwardPayment. It must be called before the gRPC server starts serving (the fields are read
// without a lock, relying on that happens-before). A nil resolver leaves its RPC reporting
// Unavailable.
func (h *Hub) SetPaymentBridge(precheck PreCheckoutResolver, forward PaymentForwarder) {
h.precheck = precheck
h.forward = forward
}
// ValidatePreCheckout serves the bot's pre_checkout validation over the same mTLS channel: it
// delegates to the configured resolver (the backend intake) and returns whether the order may be
// charged. A resolver failure maps to Internal, which the bot treats as a decline (fail-closed).
func (h *Hub) ValidatePreCheckout(ctx context.Context, req *botlinkv1.PreCheckoutRequest) (*botlinkv1.PreCheckoutResponse, error) {
if h.precheck == nil {
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
}
ok, reason, err := h.precheck(ctx, req.GetOrderId(), req.GetAmount(), req.GetCurrency())
if err != nil {
h.log.Warn("validate pre_checkout failed", zap.String("order", req.GetOrderId()), zap.Error(err))
return nil, status.Error(codes.Internal, "validate pre_checkout")
}
return &botlinkv1.PreCheckoutResponse{Ok: ok, Reason: reason}, nil
}
// ForwardPayment serves the bot's completed-payment delivery: it credits the order through the
// configured forwarder (the backend intake) and reports the durable outcome. A forwarder failure
// maps to Internal, which the bot treats as transient and retries; a clean response (credited true
// or false) lets the bot forget the outbox row.
func (h *Hub) ForwardPayment(ctx context.Context, req *botlinkv1.ForwardPaymentRequest) (*botlinkv1.ForwardPaymentResponse, error) {
if h.forward == nil {
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
}
credited, err := h.forward(ctx, req.GetOrderId(), req.GetTelegramPaymentChargeId(), req.GetAmount(), req.GetTelegramUserId())
if err != nil {
h.log.Warn("forward telegram payment failed", zap.String("order", req.GetOrderId()), zap.Error(err))
return nil, status.Error(codes.Internal, "forward payment")
}
return &botlinkv1.ForwardPaymentResponse{Credited: credited}, nil
}
// MintInvoice commands the connected bot to mint a Telegram Stars invoice link for the order and
// returns the link. It is a bounded, synchronous round-trip (invoiceMintTimeout): no bot connected
// returns ErrNoBot, and a bot error or an empty link is an error the caller surfaces as a failed
// order launch.
func (h *Hub) MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error) {
cctx, cancel := context.WithTimeout(ctx, invoiceMintTimeout)
defer cancel()
ack, err := h.sendAwaitAck(cctx, CreateInvoiceCommand(title, description, orderID, amountStars))
if err != nil {
return "", err
}
if ack.GetResult() == "" {
return "", errors.New("botlink: bot returned an empty invoice link")
}
return ack.GetResult(), nil
}
// sendAwaitAck enqueues a command and waits for the bot's full Ack (or ctx). It mirrors SendAwait
// but returns the Ack — the invoice-mint path needs its result field — and reports ctx expiry as an
// error rather than a not-delivered, since a timed-out mint has no link to return.
func (h *Hub) sendAwaitAck(ctx context.Context, cmd *botlinkv1.Command) (*botlinkv1.Ack, error) {
l, ok := h.pick()
if !ok {
h.count("dropped")
return nil, ErrNoBot
}
id := h.nextID()
cmd.CommandId = id
ackc := make(chan *botlinkv1.Ack, 1)
h.mu.Lock()
h.pending[id] = ackc
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}()
select {
case l.out <- &botlinkv1.ToBot{Command: cmd}:
case <-ctx.Done():
h.count("dropped")
return nil, ctx.Err()
}
select {
case ack := <-ackc:
if e := ack.GetError(); e != "" {
h.count("error")
return nil, errors.New(e)
}
h.count(deliveredLabel(ack.GetDelivered()))
return ack, nil
case <-ctx.Done():
h.count("error")
return nil, ctx.Err()
}
}
// register adds a connected bot. // register adds a connected bot.
func (h *Hub) register(l *link) { func (h *Hub) register(l *link) {
h.mu.Lock() h.mu.Lock()
+126 -18
View File
@@ -31,6 +31,7 @@ import (
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session" "scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode" "scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkpay"
"scrabble/gateway/internal/webui" "scrabble/gateway/internal/webui"
edgev1 "scrabble/gateway/proto/edge/v1" edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect" "scrabble/gateway/proto/edge/v1/edgev1connect"
@@ -70,18 +71,19 @@ const (
// Server implements edgev1connect.GatewayHandler. // Server implements edgev1connect.GatewayHandler.
type Server struct { type Server struct {
registry *transcode.Registry registry *transcode.Registry
sessions *session.Cache sessions *session.Cache
backend *backendclient.Client backend *backendclient.Client
limiter *ratelimit.Limiter limiter *ratelimit.Limiter
tracker *ratelimit.Tracker tracker *ratelimit.Tracker
banlist *ratelimit.Banlist banlist *ratelimit.Banlist
honeytoken string honeytoken string
hub *push.Hub vkAppSecret string
heartbeat time.Duration hub *push.Hub
log *zap.Logger heartbeat time.Duration
adminProxy http.Handler log *zap.Logger
metrics *serverMetrics adminProxy http.Handler
metrics *serverMetrics
maxBodyBytes int maxBodyBytes int
@@ -110,12 +112,15 @@ type Deps struct {
// Honeytoken, when non-empty, is the planted bearer value whose presentation // Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm. // bans the caller and raises a high-severity alarm.
Honeytoken string Honeytoken string
Hub *push.Hub // VKAppSecret is the VK Mini App protected key; it verifies the VK payment callback signature
RateLimit config.RateLimitConfig // (the same key the launch-signature auth uses). Empty leaves the VK callback rejecting.
Heartbeat time.Duration VKAppSecret string
Logger *zap.Logger Hub *push.Hub
AdminProxy http.Handler RateLimit config.RateLimitConfig
Meter metric.Meter Heartbeat time.Duration
Logger *zap.Logger
AdminProxy http.Handler
Meter metric.Meter
// MaxBodyBytes caps one inbound request body and one Connect message read; // MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes. // zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int MaxBodyBytes int
@@ -151,6 +156,7 @@ func NewServer(d Deps) *Server {
registry: d.Registry, registry: d.Registry,
sessions: d.Sessions, sessions: d.Sessions,
backend: d.Backend, backend: d.Backend,
vkAppSecret: d.VKAppSecret,
limiter: limiter, limiter: limiter,
tracker: tracker, tracker: tracker,
banlist: banlist, banlist: banlist,
@@ -196,6 +202,12 @@ func (s *Server) HTTPHandler() http.Handler {
// through this session-gated route (not public); see dictBytesHandler. // through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler()) mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
// The client posts its local-move-preview adoption telemetry here (session-gated). // The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine // The index.html boot guard beacons here when it turns a client away on the unsupported-engine
@@ -482,6 +494,102 @@ func (s *Server) exportDownloadHandler() http.Handler {
}) })
} }
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
// so it retries. The gateway does not verify the signature — the backend holds the secret.
func (s *Server) robokassaResultHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
resp, err := s.backend.RobokassaResult(r.Context(), params)
if err != nil {
s.log.Warn("robokassa result proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(resp))
})
}
// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits
// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret),
// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or
// bad signature is rejected before reaching the backend.
func (s *Server) vkCallbackHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "vk-callback")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
if !vkpay.Verify(params, s.vkAppSecret) {
s.log.Warn("vk callback: bad signature")
http.Error(w, "bad signature", http.StatusForbidden)
return
}
resp, err := s.backend.VKCallback(r.Context(), params)
if err != nil {
s.log.Warn("vk callback proxy failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(resp)
})
}
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
// return. The payment opens in a separate window, so on return this closes that window and drops the
// customer back into the live app (never a cold app start); a link is the fallback if the window
// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet
// updates in place from the payment push, with a return-focus refetch as the fallback.
func (s *Server) robokassaReturnHandler(message string) http.Handler {
page := []byte(`<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">
<p style="font-size: 1.1rem">` + message + `</p>
<p style="color: #5b6472">Можно закрыть это окно и вернуться в приложение.</p>
<p><a href="/app/">Открыть приложение</a></p>
<script>setTimeout(function () { try { window.close(); } catch (e) {} }, 1200);</script>
</body></html>`)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(page)
})
}
func (s *Server) dictBytesHandler() http.Handler { func (s *Server) dictBytesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil { if s.backend == nil {
+27
View File
@@ -37,6 +37,19 @@ func encodeAck(ok bool) []byte {
return b.FinishedBytes() return b.FinishedBytes()
} }
// encodeWalletOrder builds a WalletOrderResponse payload: the created order id and the provider
// launch URL the client opens.
func encodeWalletOrder(o backendclient.WalletOrderResp) []byte {
b := flatbuffers.NewBuilder(128)
oid := b.CreateString(o.OrderID)
url := b.CreateString(o.RedirectURL)
fb.WalletOrderResponseStart(b)
fb.WalletOrderResponseAddOrderId(b, oid)
fb.WalletOrderResponseAddRedirectUrl(b, url)
b.Finish(fb.WalletOrderResponseEnd(b))
return b.FinishedBytes()
}
// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which // encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which
// deletion step-up the account uses ("email" | "phrase"). // deletion step-up the account uses ("email" | "phrase").
func encodeDeleteRequestResult(method string) []byte { func encodeDeleteRequestResult(method string) []byte {
@@ -103,6 +116,7 @@ func encodeWallet(w backendclient.WalletResp) []byte {
fb.WalletAddAdsForever(b, w.AdsForever) fb.WalletAddAdsForever(b, w.AdsForever)
fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil) fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil)
fb.WalletAddHints(b, int32(w.Hints)) fb.WalletAddHints(b, int32(w.Hints))
fb.WalletAddRewardChips(b, int32(w.RewardChips))
b.Finish(fb.WalletEnd(b)) b.Finish(fb.WalletEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
@@ -170,6 +184,16 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
} }
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
dictVersions := encodeDictVersions(b, p.DictVersions) dictVersions := encodeDictVersions(b, p.DictVersions)
// The interstitial-ad config table (scalars only), built before Profile is opened.
var ads flatbuffers.UOffsetT
if p.Ads != nil {
fb.AdsInfoStart(b)
fb.AdsInfoAddCooldownGlobalS(b, int32(p.Ads.CooldownGlobalS))
fb.AdsInfoAddCooldownVsAiS(b, int32(p.Ads.CooldownVsAiS))
fb.AdsInfoAddCooldownHintS(b, int32(p.Ads.CooldownHintS))
fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
ads = fb.AdsInfoEnd(b)
}
fb.ProfileStart(b) fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid) fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name) fb.ProfileAddDisplayName(b, name)
@@ -192,6 +216,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
if p.Banner != nil { if p.Banner != nil {
fb.ProfileAddBanner(b, banner) fb.ProfileAddBanner(b, banner)
} }
if p.Ads != nil {
fb.ProfileAddAds(b, ads)
}
b.Finish(fb.ProfileEnd(b)) b.Finish(fb.ProfileEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
+63
View File
@@ -55,6 +55,8 @@ const (
MsgWalletGet = "wallet.get" MsgWalletGet = "wallet.get"
MsgWalletCatalog = "wallet.catalog" MsgWalletCatalog = "wallet.catalog"
MsgWalletBuy = "wallet.buy" MsgWalletBuy = "wallet.buy"
MsgWalletOrder = "wallet.order"
MsgWalletReward = "wallet.reward"
) )
// Request is one decoded Execute call. // Request is one decoded Execute call.
@@ -111,6 +113,8 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true} r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true} r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true} r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
r.ops[MsgWalletReward] = Op{Handler: walletRewardHandler(backend), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
@@ -168,6 +172,17 @@ func WithVKLink(ex VKIDExchanger) Option {
} }
} }
// WithTelegramStars re-registers the wallet.order op with a Telegram Stars invoice minter (the
// bot-link), so a Telegram-context order mints its Stars invoice link through the bot. A nil minter
// is a no-op, leaving the default (Stars-unavailable) handler in place.
func WithTelegramStars(minter InvoiceMinter) Option {
return func(r *Registry, backend *backendclient.Client) {
if minter != nil {
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, minter), Auth: true}
}
}
}
// Lookup returns the operation for messageType, and whether it is registered. // Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) { func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType] op, ok := r.ops[messageType]
@@ -331,6 +346,54 @@ func walletBuyHandler(backend *backendclient.Client) Handler {
} }
} }
// InvoiceMinter mints a Telegram Stars invoice link for a created order via the bot (only the bot
// reaches Telegram). The gateway bot-link Hub implements it; it is nil where the bot-link is off,
// which leaves the Telegram rail unavailable.
type InvoiceMinter interface {
MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error)
}
// errTelegramStarsUnavailable is returned when a Telegram Stars order is requested but no invoice
// minter (the bot-link) is wired — the rail is not available on this gateway.
var errTelegramStarsUnavailable = errors.New("telegram stars rail unavailable")
func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletOrderRequest(req.Payload, 0)
o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId()))
if err != nil {
return nil, err
}
// Telegram Stars: mint the invoice link here via the bot-link and return it to the client
// as the redirect URL it opens with WebApp.openInvoice. The title doubles as the invoice
// description (both are required and the pack title is the whole product).
if o.Rail == "telegram" {
if minter == nil {
return nil, errTelegramStarsUnavailable
}
link, merr := minter.MintInvoice(ctx, o.InvoiceTitle, o.InvoiceTitle, o.OrderID, o.InvoiceAmount)
if merr != nil {
return nil, merr
}
o.RedirectURL = link
}
return encodeWalletOrder(o), nil
}
}
// walletRewardHandler credits a watched rewarded video and returns the updated wallet. The nonce is
// the client's per-view idempotency key.
func walletRewardHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletRewardRequest(req.Payload, 0)
w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()))
if err != nil {
return nil, err
}
return encodeWallet(w), nil
}
}
func blockStatusHandler(backend *backendclient.Client) Handler { func blockStatusHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
bs, err := backend.BlockStatus(ctx, req.UserID) bs, err := backend.BlockStatus(ctx, req.UserID)
@@ -0,0 +1,65 @@
package transcode_test
import (
"context"
"net/http"
"testing"
"scrabble/gateway/internal/transcode"
fb "scrabble/pkg/fbs/scrabblefb"
)
// TestProfileGetEncodesAds verifies the gateway forwards the backend's interstitial-ad block
// into the Profile payload: the three cooldowns and the suppressed flag the client-mirrored gate
// reads. The encode is not exercised by the mock e2e (it bypasses the codec), so a dropped field
// would only surface here.
func TestProfileGetEncodesAds(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" {
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
}
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
`"ads":{"cooldown_global_s":300,"cooldown_vs_ai_s":1800,"cooldown_hint_s":60,"suppressed":true}}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, ok := reg.Lookup(transcode.MsgProfileGet)
if !ok {
t.Fatal("profile.get not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
ads := fb.GetRootAsProfile(payload, 0).Ads(nil)
if ads == nil {
t.Fatal("profile carries no ads block")
}
if ads.CooldownGlobalS() != 300 || ads.CooldownVsAiS() != 1800 || ads.CooldownHintS() != 60 {
t.Errorf("cooldowns = %d/%d/%d, want 300/1800/60", ads.CooldownGlobalS(), ads.CooldownVsAiS(), ads.CooldownHintS())
}
if !ads.Suppressed() {
t.Error("suppressed = false, want true")
}
}
// TestProfileGetNoAds verifies a profile without an ads block encodes none (the backend omits it
// only on an internal failure; normally the block is always present).
func TestProfileGetNoAds(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgProfileGet)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
if p := fb.GetRootAsProfile(payload, 0); p.Ads(nil) != nil {
t.Error("profile without an ads block unexpectedly carries one")
}
}
+40
View File
@@ -0,0 +1,40 @@
// Package vkpay verifies VK Mini Apps payment ("голоса") callback signatures. VK signs each
// payment notification (get_item / order_status_change) and expects the receiving server to verify
// it with the app's secret before acting. The signature is MD5 — mandated by VK's payment protocol,
// not a security choice on our part — so this package uses crypto/md5 deliberately.
package vkpay
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
)
// Verify reports whether params carry a valid VK payment signature under secret. VK computes the
// signature as the MD5 of the sig-excluded parameters, sorted alphabetically by name and
// concatenated as key=value with no separators, with the app secret appended. The comparison is
// case-insensitive over the hex digest.
func Verify(params map[string]string, secret string) bool {
got := params["sig"]
if got == "" || secret == "" {
return false
}
keys := make([]string, 0, len(params))
for k := range params {
if k == "sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteString("=")
b.WriteString(params[k])
}
b.WriteString(secret)
sum := md5.Sum([]byte(b.String()))
return strings.EqualFold(hex.EncodeToString(sum[:]), got)
}
+74
View File
@@ -0,0 +1,74 @@
package vkpay
import (
"crypto/md5"
"encoding/hex"
"maps"
"sort"
"strings"
"testing"
)
// vkSig computes a valid VK signature the way VK does, for the fixtures (sig excluded, sorted
// key=value concatenation, secret appended, MD5 hex).
func vkSig(params map[string]string, secret string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k + "=" + params[k])
}
b.WriteString(secret)
sum := md5.Sum([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
func clone(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
maps.Copy(out, m)
return out
}
func TestVerify(t *testing.T) {
const secret = "app_secret"
base := map[string]string{
"notification_type": "order_status_change",
"app_id": "123",
"user_id": "456",
"receiver_id": "456",
"order_id": "789",
"date": "1700000000",
"status": "chargeable",
"item": "019f47a0-6f9e-7000-8000-000000000000",
"item_price": "10",
}
valid := clone(base)
// VK sends the digest uppercase; the verifier must accept it case-insensitively.
valid["sig"] = strings.ToUpper(vkSig(base, secret))
if !Verify(valid, secret) {
t.Fatal("valid VK signature rejected")
}
tampered := clone(valid)
tampered["item_price"] = "1"
if Verify(tampered, secret) {
t.Error("accepted a tampered amount")
}
if Verify(valid, "wrong-secret") {
t.Error("accepted under the wrong secret")
}
noSig := clone(valid)
delete(noSig, "sig")
if Verify(noSig, secret) {
t.Error("accepted a callback with no signature")
}
}
+32 -3
View File
@@ -73,10 +73,13 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk= github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk=
github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw= github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw=
github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4= github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4=
@@ -90,11 +93,13 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
@@ -109,6 +114,7 @@ github.com/moby/sys/reexec v0.1.0 h1:RrBi8e0EBTLEgfruBOFcxtElzRGTEUkeIFaVXgU7wok
github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8=
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
@@ -146,8 +152,6 @@ github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5s
github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY= github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY=
github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk= github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg= github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -176,6 +180,7 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
@@ -183,10 +188,14 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
@@ -200,6 +209,26 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/ccorpus2 v1.6.0/go.mod h1:Wifvo4Q/qS/h1aRoC2TffcHsnxwTikmi1AuLANuucJQ=
modernc.org/ebnf v1.1.0/go.mod h1:CNIo7vuji3SyjIP/VhEumIKlAguC1g64mcdk/+VJW/w=
modernc.org/ebnfutil v1.1.0/go.mod h1:hdAyhM1jZSq9ygKhEeYgerbagyuLxyxzXcakBPyNqUI=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/lex v1.1.1/go.mod h1:6r8o8DLJkAnOsQaGi8fMoi+Vt6LTbDaCrkUK729D8xM=
modernc.org/lexer v1.0.4/go.mod h1:tOajb8S4sdfOYitzCgXDFmbVJ/LE0v1fNJ7annTw36U=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/scannertest v1.0.2/go.mod h1:RzTm5RwglF/6shsKoEivo8N91nQIoWtcWI7ns+zPyGA=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
+33
View File
@@ -264,6 +264,18 @@ table Profile {
// preload the right dictionary and pin a new local game without a separate request (added // preload the right dictionary and pin a new local game without a separate request (added
// trailing — backward-compatible). // trailing — backward-compatible).
dict_versions:[DictVersion]; dict_versions:[DictVersion];
// ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored
// gate (added trailing — backward-compatible).
ads:AdsInfo;
}
// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether
// ads are suppressed in the caller's context (a no-ads benefit here, or the no_banner role).
table AdsInfo {
cooldown_global_s:int;
cooldown_vs_ai_s:int;
cooldown_hint_s:int;
suppressed:bool;
} }
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation // BlockStatus reports the caller's current manual block. The UI fetches it after any operation
@@ -866,6 +878,9 @@ table Wallet {
ads_forever:bool; ads_forever:bool;
ads_paid_until_ms:long; ads_paid_until_ms:long;
hints:int; hints:int;
// reward_chips is the chips a rewarded-video view earns in the current context (0 = unavailable
// here — outside VK, or unconfigured); the client shows the "watch for chips" button only when > 0.
reward_chips:int;
} }
// WalletBuyRequest buys a chip-priced value with chips: the product to buy. // WalletBuyRequest buys a chip-priced value with chips: the product to buy.
@@ -873,6 +888,24 @@ table WalletBuyRequest {
product_id:string; product_id:string;
} }
// WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a
// retry credits once).
table WalletRewardRequest {
nonce:string;
}
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
table WalletOrderRequest {
product_id:string;
}
// WalletOrderResponse returns the created order id and the provider launch URL the client opens
// (the Robokassa hosted-payment page); chips are credited later, by the verified server callback.
table WalletOrderResponse {
order_id:string;
redirect_url:string;
}
// CatalogAtom is one atom line of a storefront product: the base value type it grants // CatalogAtom is one atom line of a storefront product: the base value type it grants
// ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries. // ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries.
table CatalogAtom { table CatalogAtom {
+109
View File
@@ -0,0 +1,109 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type AdsInfo struct {
_tab flatbuffers.Table
}
func GetRootAsAdsInfo(buf []byte, offset flatbuffers.UOffsetT) *AdsInfo {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &AdsInfo{}
x.Init(buf, n+offset)
return x
}
func FinishAdsInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsAdsInfo(buf []byte, offset flatbuffers.UOffsetT) *AdsInfo {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &AdsInfo{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedAdsInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *AdsInfo) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *AdsInfo) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *AdsInfo) CooldownGlobalS() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *AdsInfo) MutateCooldownGlobalS(n int32) bool {
return rcv._tab.MutateInt32Slot(4, n)
}
func (rcv *AdsInfo) CooldownVsAiS() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *AdsInfo) MutateCooldownVsAiS(n int32) bool {
return rcv._tab.MutateInt32Slot(6, n)
}
func (rcv *AdsInfo) CooldownHintS() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *AdsInfo) MutateCooldownHintS(n int32) bool {
return rcv._tab.MutateInt32Slot(8, n)
}
func (rcv *AdsInfo) Suppressed() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *AdsInfo) MutateSuppressed(n bool) bool {
return rcv._tab.MutateBoolSlot(10, n)
}
func AdsInfoStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
}
func AdsInfoAddCooldownGlobalS(builder *flatbuffers.Builder, cooldownGlobalS int32) {
builder.PrependInt32Slot(0, cooldownGlobalS, 0)
}
func AdsInfoAddCooldownVsAiS(builder *flatbuffers.Builder, cooldownVsAiS int32) {
builder.PrependInt32Slot(1, cooldownVsAiS, 0)
}
func AdsInfoAddCooldownHintS(builder *flatbuffers.Builder, cooldownHintS int32) {
builder.PrependInt32Slot(2, cooldownHintS, 0)
}
func AdsInfoAddSuppressed(builder *flatbuffers.Builder, suppressed bool) {
builder.PrependBoolSlot(3, suppressed, false)
}
func AdsInfoEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+17 -1
View File
@@ -231,8 +231,21 @@ func (rcv *Profile) DictVersionsLength() int {
return 0 return 0
} }
func (rcv *Profile) Ads(obj *AdsInfo) *AdsInfo {
o := flatbuffers.UOffsetT(rcv._tab.Offset(38))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(AdsInfo)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func ProfileStart(builder *flatbuffers.Builder) { func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(17) builder.StartObject(18)
} }
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
@@ -291,6 +304,9 @@ func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffe
func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) return builder.StartVector(4, numElems, 4)
} }
func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0)
}
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+16 -1
View File
@@ -97,8 +97,20 @@ func (rcv *Wallet) MutateHints(n int32) bool {
return rcv._tab.MutateInt32Slot(10, n) return rcv._tab.MutateInt32Slot(10, n)
} }
func (rcv *Wallet) RewardChips() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *Wallet) MutateRewardChips(n int32) bool {
return rcv._tab.MutateInt32Slot(12, n)
}
func WalletStart(builder *flatbuffers.Builder) { func WalletStart(builder *flatbuffers.Builder) {
builder.StartObject(4) builder.StartObject(5)
} }
func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) { func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(segments), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(segments), 0)
@@ -115,6 +127,9 @@ func WalletAddAdsPaidUntilMs(builder *flatbuffers.Builder, adsPaidUntilMs int64)
func WalletAddHints(builder *flatbuffers.Builder, hints int32) { func WalletAddHints(builder *flatbuffers.Builder, hints int32) {
builder.PrependInt32Slot(3, hints, 0) builder.PrependInt32Slot(3, hints, 0)
} }
func WalletAddRewardChips(builder *flatbuffers.Builder, rewardChips int32) {
builder.PrependInt32Slot(4, rewardChips, 0)
}
func WalletEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func WalletEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletOrderRequest struct {
_tab flatbuffers.Table
}
func GetRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletOrderRequest{}
x.Init(buf, n+offset)
return x
}
func FinishWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletOrderRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletOrderRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletOrderRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletOrderRequest) ProductId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletOrderRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func WalletOrderRequestAddProductId(builder *flatbuffers.Builder, productId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(productId), 0)
}
func WalletOrderRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+71
View File
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletOrderResponse struct {
_tab flatbuffers.Table
}
func GetRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletOrderResponse{}
x.Init(buf, n+offset)
return x
}
func FinishWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletOrderResponse{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletOrderResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletOrderResponse) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletOrderResponse) OrderId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *WalletOrderResponse) RedirectUrl() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletOrderResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func WalletOrderResponseAddOrderId(builder *flatbuffers.Builder, orderId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(orderId), 0)
}
func WalletOrderResponseAddRedirectUrl(builder *flatbuffers.Builder, redirectUrl flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(redirectUrl), 0)
}
func WalletOrderResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletRewardRequest struct {
_tab flatbuffers.Table
}
func GetRootAsWalletRewardRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletRewardRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletRewardRequest{}
x.Init(buf, n+offset)
return x
}
func FinishWalletRewardRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletRewardRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletRewardRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletRewardRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletRewardRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletRewardRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletRewardRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletRewardRequest) Nonce() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletRewardRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func WalletRewardRequestAddNonce(builder *flatbuffers.Builder, nonce flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(nonce), 0)
}
func WalletRewardRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+392 -24
View File
@@ -225,6 +225,7 @@ type Command struct {
// *Command_SendToUser // *Command_SendToUser
// *Command_SendToChannel // *Command_SendToChannel
// *Command_ChatGate // *Command_ChatGate
// *Command_CreateInvoice
Payload isCommand_Payload `protobuf_oneof:"payload"` Payload isCommand_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -310,6 +311,15 @@ func (x *Command) GetChatGate() *ChatGateCommand {
return nil return nil
} }
func (x *Command) GetCreateInvoice() *CreateInvoiceCommand {
if x != nil {
if x, ok := x.Payload.(*Command_CreateInvoice); ok {
return x.CreateInvoice
}
}
return nil
}
type isCommand_Payload interface { type isCommand_Payload interface {
isCommand_Payload() isCommand_Payload()
} }
@@ -330,6 +340,10 @@ type Command_ChatGate struct {
ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"` ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"`
} }
type Command_CreateInvoice struct {
CreateInvoice *CreateInvoiceCommand `protobuf:"bytes,6,opt,name=create_invoice,json=createInvoice,proto3,oneof"`
}
func (*Command_Notify) isCommand_Payload() {} func (*Command_Notify) isCommand_Payload() {}
func (*Command_SendToUser) isCommand_Payload() {} func (*Command_SendToUser) isCommand_Payload() {}
@@ -338,15 +352,20 @@ func (*Command_SendToChannel) isCommand_Payload() {}
func (*Command_ChatGate) isCommand_Payload() {} func (*Command_ChatGate) isCommand_Payload() {}
func (*Command_CreateInvoice) isCommand_Payload() {}
// Ack reports the outcome of the Command with command_id. delivered mirrors the // Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the // connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an // user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered. // unexpected transport/render failure, distinct from a clean not-delivered. result
// carries a command's return value when it has one (the created invoice link for a
// create_invoice command); it is empty otherwise.
type Ack struct { type Ack struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"` Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"`
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
Result string `protobuf:"bytes,4,opt,name=result,proto3" json:"result,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -402,6 +421,13 @@ func (x *Ack) GetError() string {
return "" return ""
} }
func (x *Ack) GetResult() string {
if x != nil {
return x.Result
}
return ""
}
// ChatGateCommand sets a Telegram user's write access in the moderated discussion // ChatGateCommand sets a Telegram user's write access in the moderated discussion
// chat. external_id is the user's Telegram identity (as in the backend identities // chat. external_id is the user's Telegram identity (as in the backend identities
// table); allow grants the right to write when true and revokes it when false. The // table); allow grants the right to write when true and revokes it when false. The
@@ -562,6 +588,314 @@ func (x *ChatEligibilityResponse) GetEligible() bool {
return false return false
} }
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
// echoes back in the pre_checkout_query and the successful_payment; amount is the
// price in whole stars; title and description are shown on the invoice. The bot
// returns the link in its Ack result.
type CreateInvoiceCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
Payload string `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateInvoiceCommand) Reset() {
*x = CreateInvoiceCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateInvoiceCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateInvoiceCommand) ProtoMessage() {}
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
}
func (x *CreateInvoiceCommand) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *CreateInvoiceCommand) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *CreateInvoiceCommand) GetPayload() string {
if x != nil {
return x.Payload
}
return ""
}
func (x *CreateInvoiceCommand) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
// (whole stars) in currency may be approved before the charge.
type PreCheckoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreCheckoutRequest) Reset() {
*x = PreCheckoutRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreCheckoutRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreCheckoutRequest) ProtoMessage() {}
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
}
func (x *PreCheckoutRequest) GetOrderId() string {
if x != nil {
return x.OrderId
}
return ""
}
func (x *PreCheckoutRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *PreCheckoutRequest) GetCurrency() string {
if x != nil {
return x.Currency
}
return ""
}
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
// short user-facing decline message when ok is false.
type PreCheckoutResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreCheckoutResponse) Reset() {
*x = PreCheckoutResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreCheckoutResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreCheckoutResponse) ProtoMessage() {}
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
}
func (x *PreCheckoutResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *PreCheckoutResponse) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
// and the payer's Telegram user id.
type ForwardPaymentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
TelegramPaymentChargeId string `protobuf:"bytes,2,opt,name=telegram_payment_charge_id,json=telegramPaymentChargeId,proto3" json:"telegram_payment_charge_id,omitempty"`
Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"`
TelegramUserId int64 `protobuf:"varint,4,opt,name=telegram_user_id,json=telegramUserId,proto3" json:"telegram_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ForwardPaymentRequest) Reset() {
*x = ForwardPaymentRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ForwardPaymentRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ForwardPaymentRequest) ProtoMessage() {}
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
}
func (x *ForwardPaymentRequest) GetOrderId() string {
if x != nil {
return x.OrderId
}
return ""
}
func (x *ForwardPaymentRequest) GetTelegramPaymentChargeId() string {
if x != nil {
return x.TelegramPaymentChargeId
}
return ""
}
func (x *ForwardPaymentRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *ForwardPaymentRequest) GetTelegramUserId() int64 {
if x != nil {
return x.TelegramUserId
}
return 0
}
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
// was credited (or already had been); false means the payment was recorded but could
// not be matched to a creditable order (an operator follows up). Either way the bot
// may forget the outbox row — only a transport error triggers a retry.
type ForwardPaymentResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Credited bool `protobuf:"varint,1,opt,name=credited,proto3" json:"credited,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ForwardPaymentResponse) Reset() {
*x = ForwardPaymentResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ForwardPaymentResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ForwardPaymentResponse) ProtoMessage() {}
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
}
func (x *ForwardPaymentResponse) GetCredited() bool {
if x != nil {
return x.Credited
}
return false
}
var File_botlink_v1_botlink_proto protoreflect.FileDescriptor var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" + const file_botlink_v1_botlink_proto_rawDesc = "" +
@@ -576,7 +910,7 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\x05Hello\x12\x1f\n" + "\x05Hello\x12\x1f\n" +
"\vinstance_id\x18\x01 \x01(\tR\n" + "\vinstance_id\x18\x01 \x01(\tR\n" +
"instanceId\x12!\n" + "instanceId\x12!\n" +
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xde\x02\n" + "\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xb2\x03\n" +
"\aCommand\x12\x1d\n" + "\aCommand\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" +
@@ -584,13 +918,15 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" + "\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" +
"sendToUser\x12X\n" + "sendToUser\x12X\n" +
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" + "\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" +
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGateB\t\n" + "\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGate\x12R\n" +
"\apayload\"X\n" + "\x0ecreate_invoice\x18\x06 \x01(\v2).scrabble.botlink.v1.CreateInvoiceCommandH\x00R\rcreateInvoiceB\t\n" +
"\apayload\"p\n" +
"\x03Ack\x12\x1d\n" + "\x03Ack\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
"\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" + "\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\"H\n" + "\x05error\x18\x03 \x01(\tR\x05error\x12\x16\n" +
"\x06result\x18\x04 \x01(\tR\x06result\"H\n" +
"\x0fChatGateCommand\x12\x1f\n" + "\x0fChatGateCommand\x12\x1f\n" +
"\vexternal_id\x18\x01 \x01(\tR\n" + "\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x14\n" + "externalId\x12\x14\n" +
@@ -602,10 +938,31 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\n" + "\n" +
"registered\x18\x01 \x01(\bR\n" + "registered\x18\x01 \x01(\bR\n" +
"registered\x12\x1a\n" + "registered\x12\x1a\n" +
"\beligible\x18\x02 \x01(\bR\beligible2\xc4\x01\n" + "\beligible\x18\x02 \x01(\bR\beligible\"\x80\x01\n" +
"\x14CreateInvoiceCommand\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x18\n" +
"\apayload\x18\x03 \x01(\tR\apayload\x12\x16\n" +
"\x06amount\x18\x04 \x01(\x03R\x06amount\"c\n" +
"\x12PreCheckoutRequest\x12\x19\n" +
"\border_id\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06amount\x18\x02 \x01(\x03R\x06amount\x12\x1a\n" +
"\bcurrency\x18\x03 \x01(\tR\bcurrency\"=\n" +
"\x13PreCheckoutResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\"\xb1\x01\n" +
"\x15ForwardPaymentRequest\x12\x19\n" +
"\border_id\x18\x01 \x01(\tR\aorderId\x12;\n" +
"\x1atelegram_payment_charge_id\x18\x02 \x01(\tR\x17telegramPaymentChargeId\x12\x16\n" +
"\x06amount\x18\x03 \x01(\x03R\x06amount\x12(\n" +
"\x10telegram_user_id\x18\x04 \x01(\x03R\x0etelegramUserId\"4\n" +
"\x16ForwardPaymentResponse\x12\x1a\n" +
"\bcredited\x18\x01 \x01(\bR\bcredited2\x99\x03\n" +
"\aBotLink\x12D\n" + "\aBotLink\x12D\n" +
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" + "\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" +
"\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3" "\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponse\x12h\n" +
"\x13ValidatePreCheckout\x12'.scrabble.botlink.v1.PreCheckoutRequest\x1a(.scrabble.botlink.v1.PreCheckoutResponse\x12i\n" +
"\x0eForwardPayment\x12*.scrabble.botlink.v1.ForwardPaymentRequest\x1a+.scrabble.botlink.v1.ForwardPaymentResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
var ( var (
file_botlink_v1_botlink_proto_rawDescOnce sync.Once file_botlink_v1_botlink_proto_rawDescOnce sync.Once
@@ -619,7 +976,7 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
return file_botlink_v1_botlink_proto_rawDescData return file_botlink_v1_botlink_proto_rawDescData
} }
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_botlink_v1_botlink_proto_goTypes = []any{ var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot (*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot (*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
@@ -629,27 +986,37 @@ var file_botlink_v1_botlink_proto_goTypes = []any{
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand (*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest (*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse (*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
(*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest (*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
(*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest (*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
(*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest (*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
} }
var file_botlink_v1_botlink_proto_depIdxs = []int32{ var file_botlink_v1_botlink_proto_depIdxs = []int32{
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello 2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack 4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command 3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
8, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest 13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
9, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest 14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
10, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest 15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand 5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
0, // 7: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot 8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest 0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot 6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse 9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
9, // [9:11] is the sub-list for method output_type 11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
7, // [7:9] is the sub-list for method input_type 1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // [7:7] is the sub-list for extension type_name 7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
7, // [7:7] is the sub-list for extension extendee 10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
0, // [0:7] is the sub-list for field type_name 12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
12, // [12:16] is the sub-list for method output_type
8, // [8:12] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
} }
func init() { file_botlink_v1_botlink_proto_init() } func init() { file_botlink_v1_botlink_proto_init() }
@@ -666,6 +1033,7 @@ func file_botlink_v1_botlink_proto_init() {
(*Command_SendToUser)(nil), (*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil), (*Command_SendToChannel)(nil),
(*Command_ChatGate)(nil), (*Command_ChatGate)(nil),
(*Command_CreateInvoice)(nil),
} }
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
@@ -673,7 +1041,7 @@ func file_botlink_v1_botlink_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 8, NumMessages: 13,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
+68 -1
View File
@@ -27,6 +27,24 @@ service BotLink {
// same mTLS channel when a user joins the chat, to decide whether to grant the // same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort). // write permission. Delivery of the answer is request/response (not best-effort).
rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse); rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse);
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
rpc ValidatePreCheckout(PreCheckoutRequest) returns (PreCheckoutResponse);
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
rpc ForwardPayment(ForwardPaymentRequest) returns (ForwardPaymentResponse);
} }
// FromBot is a message the bot sends to the gateway: the opening Hello, then one // FromBot is a message the bot sends to the gateway: the opening Hello, then one
@@ -61,17 +79,21 @@ message Command {
scrabble.telegram.v1.SendToUserRequest send_to_user = 3; scrabble.telegram.v1.SendToUserRequest send_to_user = 3;
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4; scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
ChatGateCommand chat_gate = 5; ChatGateCommand chat_gate = 5;
CreateInvoiceCommand create_invoice = 6;
} }
} }
// Ack reports the outcome of the Command with command_id. delivered mirrors the // Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the // connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an // user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered. // unexpected transport/render failure, distinct from a clean not-delivered. result
// carries a command's return value when it has one (the created invoice link for a
// create_invoice command); it is empty otherwise.
message Ack { message Ack {
string command_id = 1; string command_id = 1;
bool delivered = 2; bool delivered = 2;
string error = 3; string error = 3;
string result = 4;
} }
// ChatGateCommand sets a Telegram user's write access in the moderated discussion // ChatGateCommand sets a Telegram user's write access in the moderated discussion
@@ -99,3 +121,48 @@ message ChatEligibilityResponse {
bool registered = 1; bool registered = 1;
bool eligible = 2; bool eligible = 2;
} }
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
// echoes back in the pre_checkout_query and the successful_payment; amount is the
// price in whole stars; title and description are shown on the invoice. The bot
// returns the link in its Ack result.
message CreateInvoiceCommand {
string title = 1;
string description = 2;
string payload = 3;
int64 amount = 4;
}
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
// (whole stars) in currency may be approved before the charge.
message PreCheckoutRequest {
string order_id = 1;
int64 amount = 2;
string currency = 3;
}
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
// short user-facing decline message when ok is false.
message PreCheckoutResponse {
bool ok = 1;
string reason = 2;
}
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
// and the payer's Telegram user id.
message ForwardPaymentRequest {
string order_id = 1;
string telegram_payment_charge_id = 2;
int64 amount = 3;
int64 telegram_user_id = 4;
}
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
// was credited (or already had been); false means the payment was recorded but could
// not be matched to a creditable order (an operator follows up). Either way the bot
// may forget the outbox row — only a transport error triggers a retry.
message ForwardPaymentResponse {
bool credited = 1;
}
+104
View File
@@ -28,6 +28,8 @@ const _ = grpc.SupportPackageIsVersion9
const ( const (
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link" BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility" BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility"
BotLink_ValidatePreCheckout_FullMethodName = "/scrabble.botlink.v1.BotLink/ValidatePreCheckout"
BotLink_ForwardPayment_FullMethodName = "/scrabble.botlink.v1.BotLink/ForwardPayment"
) )
// BotLinkClient is the client API for BotLink service. // BotLinkClient is the client API for BotLink service.
@@ -48,6 +50,22 @@ type BotLinkClient interface {
// same mTLS channel when a user joins the chat, to decide whether to grant the // same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort). // write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error) ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error)
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error)
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error)
} }
type botLinkClient struct { type botLinkClient struct {
@@ -81,6 +99,26 @@ func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatElig
return out, nil return out, nil
} }
func (c *botLinkClient) ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PreCheckoutResponse)
err := c.cc.Invoke(ctx, BotLink_ValidatePreCheckout_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *botLinkClient) ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ForwardPaymentResponse)
err := c.cc.Invoke(ctx, BotLink_ForwardPayment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// BotLinkServer is the server API for BotLink service. // BotLinkServer is the server API for BotLink service.
// All implementations must embed UnimplementedBotLinkServer // All implementations must embed UnimplementedBotLinkServer
// for forward compatibility. // for forward compatibility.
@@ -99,6 +137,22 @@ type BotLinkServer interface {
// same mTLS channel when a user joins the chat, to decide whether to grant the // same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort). // write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error)
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error)
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error)
mustEmbedUnimplementedBotLinkServer() mustEmbedUnimplementedBotLinkServer()
} }
@@ -115,6 +169,12 @@ func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot])
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) { func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented") return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented")
} }
func (UnimplementedBotLinkServer) ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidatePreCheckout not implemented")
}
func (UnimplementedBotLinkServer) ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ForwardPayment not implemented")
}
func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {} func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {}
func (UnimplementedBotLinkServer) testEmbeddedByValue() {} func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
@@ -161,6 +221,42 @@ func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _BotLink_ValidatePreCheckout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PreCheckoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BotLinkServer).ValidatePreCheckout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BotLink_ValidatePreCheckout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BotLinkServer).ValidatePreCheckout(ctx, req.(*PreCheckoutRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BotLink_ForwardPayment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ForwardPaymentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BotLinkServer).ForwardPayment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BotLink_ForwardPayment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BotLinkServer).ForwardPayment(ctx, req.(*ForwardPaymentRequest))
}
return interceptor(ctx, in, info, handler)
}
// BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service. // BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -172,6 +268,14 @@ var BotLink_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveChatEligibility", MethodName: "ResolveChatEligibility",
Handler: _BotLink_ResolveChatEligibility_Handler, Handler: _BotLink_ResolveChatEligibility_Handler,
}, },
{
MethodName: "ValidatePreCheckout",
Handler: _BotLink_ValidatePreCheckout_Handler,
},
{
MethodName: "ForwardPayment",
Handler: _BotLink_ForwardPayment_Handler,
},
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
+16 -3
View File
@@ -91,6 +91,16 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
the seeded Mini App rather than the bot profile. the seeded Mini App rather than the bot profile.
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`, - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits. default 25) to respect the Bot API flood limits.
- **Payments (Telegram Stars).** When `TELEGRAM_STARS_OUTBOX_DIR` is set (default `/data`), the
bot handles the Stars rail. Only the bot reaches Telegram, so it mints the invoice on a
`CreateInvoice` bot-link command (`createInvoiceLink`, XTR — the link goes back to the Mini App
for `WebApp.openInvoice`); it gates each `pre_checkout_query` through the bot-link
(`ValidatePreCheckout`, backed by the backend intake — declining an already-paid reusable
invoice before the charge); and it records each `successful_payment` in a durable **SQLite
outbox** (`internal/outbox`, `stars.db` on the writable volume) before forwarding it over the
bot-link (`ForwardPayment`). The outbox is re-driven on startup and every 30 s, so a gateway or
backend outage never loses a paid order; crediting is idempotent on `telegram_payment_charge_id`.
The rail stays inert until a chip pack carries an XTR price (seeded in the admin).
The send commands address a recipient by the identity `external_id` (as in the backend The send commands address a recipient by the identity `external_id` (as in the backend
`identities` table), so a future VK / MAX bot reuses them; only the validator's initData `identities` table), so a future VK / MAX bot reuses them; only the validator's initData
@@ -104,9 +114,11 @@ parsing is Telegram-specific.
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
relay. relay.
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials - `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
on the gateway (`Hello` / `Command` / `Ack`), now also carrying a `ChatGateCommand` (set on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's
a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the
query) over the same mTLS channel. Generated Go is committed under `pkg`. Ack result), plus unary `ResolveChatEligibility` (the bot's join-time query),
`ValidatePreCheckout` and `ForwardPayment` (the Stars rail) over the same mTLS channel.
Generated Go is committed under `pkg`.
## Deep-link scheme ## Deep-link scheme
@@ -153,6 +165,7 @@ Bot (`cmd/bot`):
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating | | `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay | | `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) | | `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
| `TELEGRAM_STARS_OUTBOX_DIR` | `/data` | directory for the Telegram Stars payment outbox (`stars.db`, a writable volume); empty disables the Stars rail |
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it | | `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs | | `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs | | `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
+25
View File
@@ -23,6 +23,7 @@ import (
"scrabble/platform/telegram/internal/bot" "scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink" "scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config" "scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/promobot" "scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support" "scrabble/platform/telegram/internal/support"
) )
@@ -90,11 +91,24 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
GameChannelID: cfg.GameChannelID, GameChannelID: cfg.GameChannelID,
SupportChatID: cfg.SupportChatID, SupportChatID: cfg.SupportChatID,
SupportStore: supportStore, SupportStore: supportStore,
AcceptPayments: cfg.StarsOutboxDir != "",
}, logger) }, logger)
if err != nil { if err != nil {
return err return err
} }
// The Telegram Stars payment outbox: a durable SQLite store on the bot host's writable volume.
// Opened when the rail is enabled; a failure to open is fatal, since accepting Stars without a
// durable outbox would risk losing a paid-for order.
var paymentOutbox *outbox.Store
if cfg.StarsOutboxDir != "" {
paymentOutbox, err = outbox.Open(filepath.Join(cfg.StarsOutboxDir, "stars.db"))
if err != nil {
return err
}
defer func() { _ = paymentOutbox.Close() }()
}
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName) tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
if err != nil { if err != nil {
return err return err
@@ -115,6 +129,12 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
// the bot after the client is built — the late binding that breaks the bot <-> // the bot after the client is built — the late binding that breaks the bot <->
// client construction cycle. // client construction cycle.
b.SetEligibilityResolver(client.ResolveChatEligibility) b.SetEligibilityResolver(client.ResolveChatEligibility)
// The Telegram Stars payment path rides the same bot-link: pre_checkout validation and
// completed-payment forwarding, backed by the durable outbox — wired here for the same
// late-binding reason.
if paymentOutbox != nil {
b.SetPaymentHandlers(client.ValidatePreCheckout, client.ForwardPayment, paymentOutbox)
}
// The optional standalone promo bot: a second bot (its own token) that only answers // The optional standalone promo bot: a second bot (its own token) that only answers
// /start with a button opening the main bot's Mini App. It is self-contained — no // /start with a button opening the main bot's Mini App. It is self-contained — no
@@ -160,6 +180,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
logger.Error("bot-link client stopped", zap.Error(err)) logger.Error("bot-link client stopped", zap.Error(err))
} }
}) })
// Re-drive the Stars payment outbox: at startup (recovering payments left by a restart or a past
// outage) and periodically thereafter.
if paymentOutbox != nil {
wg.Go(func() { b.RunPaymentDrainer(ctx) })
}
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with // The promo bot runs its own getUpdates long-poll on its own token (no 409 with
// the main bot's lease). // the main bot's lease).
if promo != nil { if promo != nil {
+13
View File
@@ -12,3 +12,16 @@ require (
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
scrabble/pkg v0.0.0 scrabble/pkg v0.0.0
) )
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+29 -3
View File
@@ -16,6 +16,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/support" "scrabble/platform/telegram/internal/support"
) )
@@ -49,6 +50,10 @@ type Config struct {
// SupportStore persists the support relay's state (topic mapping, block list, // SupportStore persists the support relay's state (topic mapping, block list,
// relayed message ids); required when SupportChatID is set, ignored otherwise. // relayed message ids); required when SupportChatID is set, ignored otherwise.
SupportStore *support.Store SupportStore *support.Store
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
AcceptPayments bool
} }
// EligibilityResolver answers whether the Telegram user identified by externalID // EligibilityResolver answers whether the Telegram user identified by externalID
@@ -91,6 +96,12 @@ type Bot struct {
supportLocks *keyedMutex supportLocks *keyedMutex
// admins caches the support chat's administrator ids (who may reply and act). // admins caches the support chat's administrator ids (who may reply and act).
admins *adminCache admins *adminCache
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
precheck PreCheckoutValidator
forward PaymentForwarder
outbox *outbox.Store
} }
// New builds the bot wrapper, registering the /start handler and a default handler // New builds the bot wrapper, registering the /start handler and a default handler
@@ -123,9 +134,10 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// callback handler by their "sup:" data prefix. // callback handler by their "sup:" data prefix.
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback)) opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
} }
// Allowed updates default to "all except chat_member". Specify an explicit set only // Allowed updates default to "all except chat_member" (which already includes
// when we need chat_member (moderated chat) — and then re-add callback_query (which // pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
// the explicit set would otherwise drop) when the support relay needs it. // need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
// pre_checkout_query, which the explicit set would otherwise drop.
if cfg.ChatID != 0 { if cfg.ChatID != 0 {
allowed := tgbot.AllowedUpdates{ allowed := tgbot.AllowedUpdates{
models.AllowedUpdateMessage, models.AllowedUpdateMessage,
@@ -135,6 +147,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if t.supportEnabled() { if t.supportEnabled() {
allowed = append(allowed, models.AllowedUpdateCallbackQuery) allowed = append(allowed, models.AllowedUpdateCallbackQuery)
} }
if cfg.AcceptPayments {
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
}
opts = append(opts, tgbot.WithAllowedUpdates(allowed)) opts = append(opts, tgbot.WithAllowedUpdates(allowed))
} }
if cfg.TestEnv { if cfg.TestEnv {
@@ -346,6 +361,17 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleChatMember(ctx, update.ChatMember) t.handleChatMember(ctx, update.ChatMember)
return return
} }
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
// message is never mistaken for a support DM or given a launch reply.
if update.PreCheckoutQuery != nil {
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
return
}
if update.Message != nil && update.Message.SuccessfulPayment != nil {
t.handleSuccessfulPayment(ctx, update.Message)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler — // Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to // is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply. // relay. Everything else falls through to the Mini App launch reply.
+166
View File
@@ -0,0 +1,166 @@
package bot
import (
"context"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"scrabble/platform/telegram/internal/outbox"
)
// starsCurrency is the Telegram Stars currency code for createInvoiceLink and the invoice line.
const starsCurrency = "XTR"
// paymentDrainInterval is how often the bot re-drives undelivered outbox payments (the backstop for a
// gateway or backend outage, and the restart re-drive); the happy path forwards immediately on receipt.
const paymentDrainInterval = 30 * time.Second
// PreCheckoutValidator validates a Stars pre_checkout order over the bot-link and returns whether it
// may be charged plus a short, already-localised decline reason for the payer. The bot-link client
// backs it.
type PreCheckoutValidator func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
// PaymentForwarder delivers a completed Stars payment over the bot-link for crediting and reports
// whether it was credited (or already had been). A non-nil error is transient and retried. The
// bot-link client backs it.
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
// SetPaymentHandlers wires the Telegram Stars runtime dependencies after construction: the
// pre_checkout validator and payment forwarder (both over the bot-link, built after the bot) and the
// durable outbox. It is the late binding that breaks the bot <-> bot-link construction cycle.
func (t *Bot) SetPaymentHandlers(precheck PreCheckoutValidator, forward PaymentForwarder, store *outbox.Store) {
t.precheck = precheck
t.forward = forward
t.outbox = store
}
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged with payload
// (the order id, echoed back in pre_checkout and successful_payment), and returns the link. The
// provider token is empty for Stars. Only the bot reaches Telegram, so the gateway calls this over
// the bot-link on the wallet-order path.
func (t *Bot) CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error) {
return t.api.CreateInvoiceLink(ctx, &tgbot.CreateInvoiceLinkParams{
Title: title,
Description: description,
Payload: payload,
Currency: starsCurrency,
Prices: []models.LabeledPrice{{Label: title, Amount: int(amountStars)}},
})
}
// handlePreCheckout answers a Stars pre_checkout_query. It validates the order against the backend
// (over the bot-link) and approves only on a positive answer; a not-ok answer or a validation
// failure declines the charge (fail-closed), before any star moves. The decline reason from the
// backend is already localised to the payer's account language.
func (t *Bot) handlePreCheckout(ctx context.Context, q *models.PreCheckoutQuery) {
ok, reason := false, ""
if t.precheck == nil {
t.log.Warn("pre_checkout received but the validator is not wired; declining", zap.String("order", q.InvoicePayload))
reason = fallbackDeclineText(q.From)
} else if v, r, err := t.precheck(ctx, q.InvoicePayload, int64(q.TotalAmount), q.Currency); err != nil {
t.log.Warn("pre_checkout validation failed; declining", zap.String("order", q.InvoicePayload), zap.Error(err))
reason = fallbackDeclineText(q.From)
} else {
ok, reason = v, r
}
params := &tgbot.AnswerPreCheckoutQueryParams{PreCheckoutQueryID: q.ID, OK: ok}
if !ok {
params.ErrorMessage = reason
}
if _, err := t.api.AnswerPreCheckoutQuery(ctx, params); err != nil {
t.log.Warn("answer pre_checkout failed", zap.String("order", q.InvoicePayload), zap.Error(err))
}
}
// handleSuccessfulPayment records a completed Stars payment in the durable outbox and forwards it to
// the gateway. Persisting first is the durability point: a crash before forwarding still re-drives
// the payment on restart. The immediate forward is best-effort; the periodic drainer covers a
// gateway or backend outage.
func (t *Bot) handleSuccessfulPayment(ctx context.Context, msg *models.Message) {
sp := msg.SuccessfulPayment
if t.outbox == nil {
t.log.Error("successful_payment received but the outbox is not wired; the payment is at risk",
zap.String("charge", sp.TelegramPaymentChargeID))
return
}
var tgUserID int64
if msg.From != nil {
tgUserID = msg.From.ID
}
rec := outbox.Record{
ChargeID: sp.TelegramPaymentChargeID,
OrderID: sp.InvoicePayload,
Amount: int64(sp.TotalAmount),
UserID: tgUserID,
}
if err := t.outbox.Add(ctx, rec); err != nil {
t.log.Error("outbox persist failed; the drainer cannot recover this payment",
zap.String("charge", rec.ChargeID), zap.Error(err))
return
}
t.log.Info("stars payment received", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID))
t.forwardOne(ctx, rec)
}
// RunPaymentDrainer re-drives undelivered outbox payments: once at startup (recovering any left by a
// restart or a past outage) and then on paymentDrainInterval, until ctx is cancelled. It is a no-op
// when the Stars rail is not wired.
func (t *Bot) RunPaymentDrainer(ctx context.Context) {
if t.outbox == nil || t.forward == nil {
return
}
t.drainOutbox(ctx)
ticker := time.NewTicker(paymentDrainInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.drainOutbox(ctx)
}
}
}
// drainOutbox forwards a batch of pending payments to the gateway.
func (t *Bot) drainOutbox(ctx context.Context) {
recs, err := t.outbox.Pending(ctx, 50)
if err != nil {
t.log.Warn("outbox drain read failed", zap.Error(err))
return
}
for _, rec := range recs {
t.forwardOne(ctx, rec)
}
}
// forwardOne delivers one payment to the gateway and, on a durable response (credited or not), marks
// it forwarded so it is not re-sent. A transport error leaves the row for the next drain. Crediting is
// idempotent at the backend, so a re-forward after a lost ack never double-credits.
func (t *Bot) forwardOne(ctx context.Context, rec outbox.Record) {
if t.forward == nil {
return
}
credited, err := t.forward(ctx, rec.OrderID, rec.ChargeID, rec.Amount, rec.UserID)
if err != nil {
t.log.Warn("forward payment failed; will retry", zap.String("charge", rec.ChargeID), zap.Error(err))
return
}
if err := t.outbox.MarkForwarded(ctx, rec.ChargeID); err != nil {
t.log.Error("mark forwarded failed", zap.String("charge", rec.ChargeID), zap.Error(err))
return
}
t.log.Info("stars payment forwarded", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID), zap.Bool("credited", credited))
}
// fallbackDeclineText is the pre_checkout decline message used only when the backend cannot be
// reached to form a localised one; it falls back to the payer's Telegram client language.
func fallbackDeclineText(from *models.User) string {
if from != nil && from.LanguageCode == "ru" {
return "Оплата временно недоступна. Попробуйте позже."
}
return "Payment is temporarily unavailable. Please try again later."
}
+30 -2
View File
@@ -83,6 +83,34 @@ func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string)
return resp.GetEligible(), nil return resp.GetEligible(), nil
} }
// ValidatePreCheckout asks the gateway whether a Telegram Stars pre_checkout_query for orderID
// paying amount in currency may be approved, before the charge. The bot calls it on every
// pre_checkout_query over the same mTLS connection and approves only on ok; the reason is a short
// decline message (already localised by the backend) to show the payer.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) {
resp, err := c.client.ValidatePreCheckout(ctx, &botlinkv1.PreCheckoutRequest{OrderId: orderID, Amount: amount, Currency: currency})
if err != nil {
return false, "", err
}
return resp.GetOk(), resp.GetReason(), nil
}
// ForwardPayment delivers a completed Stars payment to the gateway for crediting. The bot calls it
// from the outbox drain; it reports whether the order was credited (or already had been). A non-nil
// error is a transient failure the bot retries.
func (c *Client) ForwardPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) {
resp, err := c.client.ForwardPayment(ctx, &botlinkv1.ForwardPaymentRequest{
OrderId: orderID,
TelegramPaymentChargeId: chargeID,
Amount: amount,
TelegramUserId: telegramUserID,
})
if err != nil {
return false, err
}
return resp.GetCredited(), nil
}
// Run keeps the bot-link command stream open, re-opening it after each break, until // Run keeps the bot-link command stream open, re-opening it after each break, until
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath. // ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
func (c *Client) Run(ctx context.Context) error { func (c *Client) Run(ctx context.Context) error {
@@ -121,8 +149,8 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
if cmd == nil { if cmd == nil {
continue continue
} }
delivered, herr := c.exec.Handle(ctx, cmd) delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered} ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
if herr != nil { if herr != nil {
ack.Error = herr.Error() ack.Error = herr.Error()
} }
+31 -7
View File
@@ -27,6 +27,10 @@ type Sender interface {
// chat, but only when they are currently in it; it reports whether a restriction // chat, but only when they are currently in it; it reports whether a restriction
// was applied. // was applied.
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged
// with payload (the order id, echoed back in pre_checkout and successful_payment), and
// returns the link.
CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error)
} }
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors // Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
@@ -48,22 +52,42 @@ func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
return &Executor{sender: sender, channelID: channelID, log: log} return &Executor{sender: sender, channelID: channelID, log: log}
} }
// Handle dispatches one command to the matching Bot API send. // Handle dispatches one command to the matching Bot API call. It returns whether the command was
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) { // delivered, an optional result string (the created invoice link for a create_invoice command; empty
// otherwise), and an error for an unexpected or malformed failure.
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, string, error) {
switch p := cmd.GetPayload().(type) { switch p := cmd.GetPayload().(type) {
case *botlinkv1.Command_Notify: case *botlinkv1.Command_Notify:
return e.notify(ctx, p.Notify) d, err := e.notify(ctx, p.Notify)
return d, "", err
case *botlinkv1.Command_SendToUser: case *botlinkv1.Command_SendToUser:
return e.sendToUser(ctx, p.SendToUser) d, err := e.sendToUser(ctx, p.SendToUser)
return d, "", err
case *botlinkv1.Command_SendToChannel: case *botlinkv1.Command_SendToChannel:
return e.sendToChannel(ctx, p.SendToChannel) d, err := e.sendToChannel(ctx, p.SendToChannel)
return d, "", err
case *botlinkv1.Command_ChatGate: case *botlinkv1.Command_ChatGate:
return e.chatGate(ctx, p.ChatGate) d, err := e.chatGate(ctx, p.ChatGate)
return d, "", err
case *botlinkv1.Command_CreateInvoice:
return e.createInvoice(ctx, p.CreateInvoice)
default: default:
return false, fmt.Errorf("botlink: empty command") return false, "", fmt.Errorf("botlink: empty command")
} }
} }
// createInvoice mints a Telegram Stars invoice link for the order and returns it in the Ack result.
// A Bot API failure is a hard error carried back in the Ack, so the gateway's synchronous mint fails
// (rather than returning an empty link).
func (e *Executor) createInvoice(ctx context.Context, req *botlinkv1.CreateInvoiceCommand) (bool, string, error) {
link, err := e.sender.CreateInvoiceLink(ctx, req.GetTitle(), req.GetDescription(), req.GetPayload(), req.GetAmount())
if err != nil {
e.log.Warn("create invoice link failed", zap.String("order", req.GetPayload()), zap.Error(err))
return false, "", err
}
return true, link, nil
}
// chatGate applies a chat-gate command: it parses the target Telegram user id and // chatGate applies a chat-gate command: it parses the target Telegram user id and
// sets their write access in the moderated chat (a no-op when they are not in it). A // sets their write access in the moderated chat (a no-op when they are not in it). A
// Bot API failure is logged and reported as not-delivered, not a hard error. // Bot API failure is logged and reported as not-delivered, not a hard error.
@@ -13,11 +13,13 @@ import (
// fakeSender records the delivery calls the executor makes. // fakeSender records the delivery calls the executor makes.
type fakeSender struct { type fakeSender struct {
notify []notifyCall notify []notifyCall
text []textCall text []textCall
gate []gateCall gate []gateCall
applied bool // ApplyChatGate's reported result invoice []invoiceCall
err error invoiceLink string // CreateInvoiceLink's returned link
applied bool // ApplyChatGate's reported result
err error
} }
type notifyCall struct { type notifyCall struct {
@@ -32,6 +34,10 @@ type gateCall struct {
userID int64 userID int64
allow bool allow bool
} }
type invoiceCall struct {
title, description, payload string
amount int64
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error { func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam}) f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
@@ -48,6 +54,11 @@ func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool)
return f.applied, f.err return f.applied, f.err
} }
func (f *fakeSender) CreateInvoiceLink(_ context.Context, title, description, payload string, amountStars int64) (string, error) {
f.invoice = append(f.invoice, invoiceCall{title, description, payload, amountStars})
return f.invoiceLink, f.err
}
func yourTurnPayload(gameID string) []byte { func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0) b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID) gid := b.CreateString(gameID)
@@ -67,7 +78,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en")) delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -85,7 +96,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) { func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en")) delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -99,7 +110,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
func TestExecutorNotifyInvalidExternalID(t *testing.T) { func TestExecutorNotifyInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil) exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil { if _, _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
t.Error("expected an error for a non-numeric external_id") t.Error("expected an error for a non-numeric external_id")
} }
} }
@@ -108,7 +119,7 @@ func TestExecutorSendToUser(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}} cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
delivered, err := exec.Handle(context.Background(), cmd) delivered, _, err := exec.Handle(context.Background(), cmd)
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -126,7 +137,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
func TestExecutorChatGateApplied(t *testing.T) { func TestExecutorChatGateApplied(t *testing.T) {
sender := &fakeSender{applied: true} sender := &fakeSender{applied: true}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true)) delivered, _, err := exec.Handle(context.Background(), chatGateCmd("777", true))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -138,7 +149,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
func TestExecutorChatGateNotInChat(t *testing.T) { func TestExecutorChatGateNotInChat(t *testing.T) {
sender := &fakeSender{applied: false} // user not in the chat sender := &fakeSender{applied: false} // user not in the chat
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false)) delivered, _, err := exec.Handle(context.Background(), chatGateCmd("888", false))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -152,24 +163,53 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
func TestExecutorChatGateInvalidExternalID(t *testing.T) { func TestExecutorChatGateInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil) exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil { if _, _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
t.Error("expected an error for a non-numeric external_id") t.Error("expected an error for a non-numeric external_id")
} }
} }
func TestExecutorCreateInvoice(t *testing.T) {
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
}}}
delivered, result, err := exec.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || result != "https://t.me/$abc" {
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
}
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
t.Errorf("invoice calls = %+v", sender.invoice)
}
}
func TestExecutorCreateInvoiceError(t *testing.T) {
sender := &fakeSender{err: context.DeadlineExceeded}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "x", Description: "x", Payload: "order-2", Amount: 10,
}}}
if _, _, err := exec.Handle(context.Background(), cmd); err == nil {
t.Error("expected an error when minting the invoice fails")
}
}
func TestExecutorSendToChannel(t *testing.T) { func TestExecutorSendToChannel(t *testing.T) {
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}} channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
t.Run("unconfigured", func(t *testing.T) { t.Run("unconfigured", func(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil) exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), channelCmd); err == nil { if _, _, err := exec.Handle(context.Background(), channelCmd); err == nil {
t.Error("expected an error when no channel is configured") t.Error("expected an error when no channel is configured")
} }
}) })
t.Run("configured", func(t *testing.T) { t.Run("configured", func(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 555, nil) exec := NewExecutor(sender, 555, nil)
delivered, err := exec.Handle(context.Background(), channelCmd) delivered, _, err := exec.Handle(context.Background(), channelCmd)
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -52,6 +52,11 @@ type BotConfig struct {
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the // (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
// container user (UID 65532) and backed by a persistent volume. // container user (UID 65532) and backed by a persistent volume.
SupportStateDir string SupportStateDir string
// StarsOutboxDir is the directory holding the Telegram Stars payment outbox SQLite file
// (TELEGRAM_STARS_OUTBOX_DIR, optional; empty disables the Stars rail). It must be writable by
// the container user (UID 65532) and backed by a persistent volume — a lost outbox loses any
// payment not yet forwarded to the gateway.
StarsOutboxDir string
// PromoBotToken is the API token of the optional standalone promo bot run in this // PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that // container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables // opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
@@ -157,6 +162,7 @@ func LoadBot() (BotConfig, error) {
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam), PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"), SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{ BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"), GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
+104
View File
@@ -0,0 +1,104 @@
// Package outbox is the Telegram Stars payment outbox: a small SQLite store on the bot host's
// writable volume that durably records each completed Stars payment the moment Telegram delivers it,
// so a gateway or backend outage cannot lose it. The bot forwards pending rows to the gateway and
// marks them delivered; rows still undelivered are re-driven on restart. It is pure-Go SQLite
// (modernc.org/sqlite, no CGO) so it runs on the distroless image.
package outbox
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Record is one completed Stars payment awaiting delivery to the gateway.
type Record struct {
// ChargeID is the telegram_payment_charge_id — the primary key here and the credit idempotency
// key at the backend, so a re-delivered or retried payment is never credited twice.
ChargeID string
// OrderID is the invoice payload (our order id) the payment settles.
OrderID string
// Amount is the stars paid (the XTR minor unit is the whole star).
Amount int64
// UserID is the payer's Telegram user id.
UserID int64
}
// Store is the SQLite-backed payment outbox.
type Store struct {
db *sql.DB
}
// Open opens (creating if absent) the outbox database at path and ensures its schema. The parent
// directory must exist and be writable by the container user.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("outbox: open %s: %w", path, err)
}
// A single connection serialises writes and sidesteps SQLite's "database is locked" under the
// bot's low, bursty payment volume.
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS stars_payments (
charge_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
amount INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
forwarded INTEGER NOT NULL DEFAULT 0
)`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("outbox: init schema: %w", err)
}
return &Store{db: db}, nil
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Add records a completed payment. It is idempotent on the charge id: a payment Telegram re-delivers
// (or one already recorded and forwarded) is ignored, so it is never forwarded — and credited —
// twice.
func (s *Store) Add(ctx context.Context, r Record) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO stars_payments (charge_id, order_id, amount, user_id, created_at, forwarded)
VALUES (?, ?, ?, ?, ?, 0)
ON CONFLICT(charge_id) DO NOTHING`,
r.ChargeID, r.OrderID, r.Amount, r.UserID, time.Now().Unix())
if err != nil {
return fmt.Errorf("outbox: add %s: %w", r.ChargeID, err)
}
return nil
}
// Pending returns up to limit payments not yet forwarded, oldest first.
func (s *Store) Pending(ctx context.Context, limit int) ([]Record, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT charge_id, order_id, amount, user_id FROM stars_payments
WHERE forwarded = 0 ORDER BY created_at LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("outbox: read pending: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var r Record
if err := rows.Scan(&r.ChargeID, &r.OrderID, &r.Amount, &r.UserID); err != nil {
return nil, fmt.Errorf("outbox: scan: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkForwarded flags a payment as delivered so it is not forwarded again.
func (s *Store) MarkForwarded(ctx context.Context, chargeID string) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE stars_payments SET forwarded = 1 WHERE charge_id = ?`, chargeID); err != nil {
return fmt.Errorf("outbox: mark forwarded %s: %w", chargeID, err)
}
return nil
}
@@ -0,0 +1,124 @@
package outbox
import (
"context"
"path/filepath"
"testing"
)
// openTemp opens a fresh outbox in a temp dir.
func openTemp(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "stars.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestOutboxAddPendingMark(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "ch1", OrderID: "ord1", Amount: 40, UserID: 777}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending = %+v, want [%+v]", pending, rec)
}
if err := s.MarkForwarded(ctx, "ch1"); err != nil {
t.Fatalf("mark: %v", err)
}
pending, err = s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending after mark: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending after mark = %+v, want empty", pending)
}
}
func TestOutboxAddIdempotent(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "dup", OrderID: "ord1", Amount: 80, UserID: 1}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add 1: %v", err)
}
// A re-delivered payment (same charge id) must not add a second row, even with different fields.
if err := s.Add(ctx, Record{ChargeID: "dup", OrderID: "other", Amount: 999, UserID: 2}); err != nil {
t.Fatalf("add 2: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending = %+v, want the single original row", pending)
}
}
func TestOutboxAddIgnoresForwarded(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "ch", OrderID: "o", Amount: 40, UserID: 5}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.MarkForwarded(ctx, "ch"); err != nil {
t.Fatalf("mark: %v", err)
}
// A re-delivery after forwarding must not resurrect the row as pending (no double credit).
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("re-add: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending = %+v, want empty (already forwarded)", pending)
}
}
// TestOutboxReopenReDrives proves a restart re-drives undelivered payments: a payment persisted but
// not marked forwarded is still pending after the store is reopened from the same file.
func TestOutboxReopenReDrives(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "stars.db")
s, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
rec := Record{ChargeID: "persist", OrderID: "o1", Amount: 40, UserID: 9}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.Close(); err != nil {
t.Fatalf("close: %v", err)
}
reopened, err := Open(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer func() { _ = reopened.Close() }()
pending, err := reopened.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending after reopen = %+v, want the undelivered payment", pending)
}
}
+27
View File
@@ -48,6 +48,33 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page })
await expect(page.locator('.make')).toBeHidden(); await expect(page.locator('.make')).toBeHidden();
}); });
// Regression: taking a hint must NOT fire the post-move interstitial. Firing on the hint interrupted
// placing the preview and reverted the board on the ad's close while the hint stayed spent (the
// reported bug). In mock mode the ad stub shows an "ad fired" toast, standing in for a real VK ad.
test('taking a hint does not fire the interstitial', async ({ page }) => {
await openGame(page);
// Take a hint (the control confirms on a second tap). It produces a legal preview, so the ✅
// control appears — proof the hint fired.
const hint = page.getByRole('button', { name: 'Hint' });
await hint.click();
await hint.click();
await expect(page.locator('.make')).toBeVisible();
// A spurious ad would have toasted by now; none may.
await page.waitForTimeout(400);
await expect(page.getByText('ad fired')).toHaveCount(0);
});
// The interstitial fires once the move is CONFIRMED (never on the hint / pass / exchange / resign).
test('confirming a move fires the interstitial', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
await page.locator('.make').click();
await expect(page.getByText('ad fired')).toBeVisible();
});
test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => { test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('.rack .tile').first().click(); await page.locator('.rack .tile').first().click();
+11
View File
@@ -58,3 +58,14 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn
expect(await web.getAttribute('href')).toContain('/app/'); expect(await web.getAttribute('href')).toContain('/app/');
await expect(page.getByText('Веб-версия')).toBeVisible(); await expect(page.getByText('Веб-версия')).toBeVisible();
}); });
// The footer carries a public-offer link to the static /offer/ page (rendered from
// ui/legal/offer_ru.md at build; the legal document a purchase accepts).
test('the landing footer links to the public offer at /offer/', async ({ page }) => {
await page.goto('/landing.html');
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
const offer = page.getByRole('link', { name: 'Публичная оферта' });
await expect(offer).toBeVisible();
expect(await offer.getAttribute('href')).toBe('/offer/');
});
+21 -2
View File
@@ -37,8 +37,27 @@ test('wallet: balances, benefits and the storefront render for a durable account
await expect(page.getByTestId('product')).toHaveCount(3); await expect(page.getByTestId('product')).toHaveCount(3);
const pack = page.locator('[data-kind="pack"]'); const pack = page.locator('[data-kind="pack"]');
await expect(pack).toContainText('₽'); await expect(pack).toContainText('₽');
// The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'. // The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled(); await expect(pack.getByTestId('buy-pack')).toBeEnabled();
await expect(page.getByTestId('offer')).toContainText('Public offer');
});
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it.
await page.evaluate(() => {
(window as { __opened?: string }).__opened = '';
window.open = ((u: string) => {
(window as { __opened?: string }).__opened = u;
return null;
}) as typeof window.open;
});
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
await expect
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
.toContain('robokassa');
}); });
test('wallet: the tab sits between Friends and About', async ({ page }) => { test('wallet: the tab sits between Friends and About', async ({ page }) => {
+145
View File
@@ -0,0 +1,145 @@
# Публичная оферта
Публичная оферта о заключении договора купли-продажи.
## 1. Общие положения
В настоящей Публичной оферте содержатся условия заключения Договора купли-продажи (далее по тексту — «Договор купли-продажи» и/или «Договор»). Настоящей офертой признается предложение, адресованное одному или нескольким конкретным лицам, которое достаточно определенно и выражает намерение лица, сделавшего предложение, считать себя заключившим Договор с адресатом, которым будет принято предложение.
Совершение указанных в настоящей Оферте действий является подтверждением согласия обеих Сторон заключить Договор купли-продажи на условиях, в порядке и объеме, изложенных в настоящей Оферте.
Нижеизложенный текст Публичной оферты является официальным публичным предложением Продавца, адресованный заинтересованному кругу лиц заключить Договор купли-продажи в соответствии с положениями пункта 2 статьи 437 Гражданского кодекса РФ.
Договор купли-продажи считается заключенным и приобретает силу с момента совершения Сторонами действий, предусмотренных в настоящей Оферте, и, означающих безоговорочное, а также полное принятие всех условий настоящей Оферты без каких-либо изъятий или ограничений на условиях присоединения.
### Термины и определения
**Договор** — текст настоящей Оферты с Приложениями, являющимися неотъемлемой частью настоящей Оферты, акцептованный Покупателем путем совершения конклюдентных действий, предусмотренных настоящей Офертой.
**Конклюдентные действия** — это поведение, которое выражает согласие с предложением контрагента заключить, изменить или расторгнуть договор. Действия состоят в полном или частичном выполнении условий, которые предложил контрагент.
**Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`.
**Стороны Договора (Стороны)** — Продавец и Покупатель.
**Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ.
## 2. Предмет Договора
**2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму.
**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`.
**2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности:
- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи;
- путем составления и заполнения заявки на оформление заказа Товара;
- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя;
- оплаты Товара Покупателем.
Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента.
## 3. Права и обязанности Сторон
### 3.1. Права и обязанности Продавца
**3.1.1.** Продавец вправе требовать оплаты Товаров и их доставки в порядке и на условиях, предусмотренных Договором;
**3.1.2.** Отказать в заключении Договора на основании настоящей Оферты Покупателю в случае его недобросовестного поведения, в частности, в случае:
- более 2 (Двух) отказов от Товаров надлежащего качества в течение года;
- предоставления заведомо недостоверной персональной информации;
- возврата испорченного Покупателем Товара или Товара, бывшего в употреблении;
- иных случаях недобросовестного поведения, свидетельствующих о заключении Покупателем Договора с целью злоупотребления правами, и отсутствия обычной экономической цели Договора — приобретения Товара.
**3.1.3.** Продавец обязуется передать Покупателю Товар надлежащего качества и в надлежащей упаковке;
**3.1.4.** Передать Товар свободным от прав третьих лиц;
**3.1.5.** Организовать доставку Товаров Покупателю;
**3.1.6.** Предоставить Покупателю всю необходимую информацию в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
### 3.2. Права и обязанности Покупателя
**3.2.1.** Покупатель вправе требовать передачи Товара в порядке и на условиях, предусмотренных Договором.
**3.2.2.** Требовать предоставления всей необходимой информации в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
**3.2.3.** Отказаться от Товара по основаниям, предусмотренным Договором и действующим законодательством Российской Федерации.
**3.2.4.** Покупатель обязуется предоставить Продавцу достоверную информацию, необходимую для надлежащего исполнения Договора;
**3.2.5.** Принять и оплатить Товар в соответствии с условиями Договора;
**3.2.6.** Покупатель гарантирует, что все условия Договора ему понятны; Покупатель принимает условия без оговорок, а также в полном объеме.
## 4. Цена и порядок расчетов
**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`.
**4.2.** Все расчеты по Договору производятся в безналичном порядке.
## 5. Обмен и возврат Товара
**5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463.
**5.2.** Требование Покупателя об обмене либо о возврате Товара рассматривается индивидуально при условии неиспользования приобретенного товара и наличии уважительных причин (технический сбой, ошибка в описании товара).
## 6. Конфиденциальность и безопасность
**6.1.** При реализации настоящего Договора Стороны обеспечивают конфиденциальность и безопасность персональных данных в соответствии с актуальной редакцией ФЗ от 27.07.2006 г. № 152-ФЗ «О персональных данных» и ФЗ от 27.07.2006 г. № 149-ФЗ «Об информации, информационных технологиях и о защите информации».
**6.2.** Стороны обязуются сохранять конфиденциальность информации, полученной в ходе исполнения настоящего Договора, и принять все возможные меры, чтобы предохранить полученную информацию от разглашения.
**6.3.** Под конфиденциальной информацией понимается любая информация, передаваемая Продавцом и Покупателем в процессе реализации Договора и подлежащая защите, исключения указаны ниже.
**6.4.** Такая информация может содержаться в предоставляемых Продавцом локальных нормативных актах, договорах, письмах, отчетах, аналитических материалах, результатах исследований, схемах, графиках, спецификациях и других документах, оформленных как на бумажных, так и на электронных носителях.
## 7. Форс-мажор
**7.1.** Стороны освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по Договору, если надлежащее исполнение оказалось невозможным вследствие непреодолимой силы, то есть чрезвычайных и непредотвратимых при данных условиях обстоятельств, под которыми понимаются: запретные действия властей, эпидемии, блокада, эмбарго, землетрясения, наводнения, пожары или другие стихийные бедствия.
**7.2.** В случае наступления этих обстоятельств Сторона обязана в течение 30 (Тридцати) рабочих дней уведомить об этом другую Сторону.
**7.3.** Документ, выданный уполномоченным государственным органом, является достаточным подтверждением наличия и продолжительности действия непреодолимой силы.
**7.4.** Если обстоятельства непреодолимой силы продолжают действовать более 60 (Шестидесяти) рабочих дней, то каждая Сторона вправе отказаться от настоящего Договора в одностороннем порядке.
## 8. Ответственность Сторон
**8.1.** В случае неисполнения и/или ненадлежащего исполнения своих обязательств по Договору, Стороны несут ответственность в соответствии с условиями настоящей Оферты.
**8.2.** Сторона, не исполнившая или ненадлежащим образом исполнившая обязательства по Договору, обязана возместить другой Стороне причиненные такими нарушениями убытки.
## 9. Срок действия настоящей Оферты
**9.1.** Оферта вступает в силу с момента размещения на Сайте Продавца и действует до момента её отзыва Продавцом.
**9.2.** Продавец оставляет за собой право внести изменения в условия Оферты и/или отозвать Оферту в любой момент по своему усмотрению. Сведения об изменении или отзыве Оферты доводятся до Покупателя по выбору Продавца посредством размещения на сайте Продавца в сети «Интернет», в Личном кабинете Покупателя, либо путем направления соответствующего уведомления на электронный или почтовый адрес, указанный Покупателем при заключении Договора или в ходе его исполнения.
**9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору.
**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме.
## 10. Дополнительные условия
**10.1.** Договор, его заключение и исполнение регулируется действующим законодательством Российской Федерации. Все вопросы, не урегулированные настоящей Офертой или урегулированные не полностью, регулируются в соответствии с материальным правом Российской Федерации.
**10.2.** В случае возникновения спора, который может возникнуть между Сторонами в ходе исполнения ими своих обязательств по Договору, заключенному на условиях настоящей Оферты, Стороны обязаны урегулировать спор мирным путем до начала судебного разбирательства.
Судебное разбирательство осуществляется в соответствии с законодательством Российской Федерации.
Споры или разногласия, по которым Стороны не достигли договоренности, подлежат разрешению в соответствии с законодательством РФ. Досудебный порядок урегулирования спора является обязательным.
**10.3.** В качестве языка Договора, заключаемого на условиях настоящей Оферты, а также языка, используемого при любом взаимодействии Сторон (включая ведение переписки, предоставление требований / уведомлений / разъяснений, предоставление документов и т. д.), Стороны определили русский язык.
**10.4.** Все документы, подлежащие предоставлению в соответствии с условиями настоящей Оферты, должны быть составлены на русском языке либо иметь перевод на русский язык, удостоверенный в установленном порядке.
**10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем.
**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок.
## 11. Реквизиты Продавца
Денисов Илья Аркадьевич, ИНН 290210610742.
+1
View File
@@ -28,6 +28,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0", "core-js-bundle": "^3.49.0",
"marked": "^18.0.5",
"svelte": "^5.15.0", "svelte": "^5.15.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
+10
View File
@@ -39,6 +39,9 @@ importers:
core-js-bundle: core-js-bundle:
specifier: ^3.49.0 specifier: ^3.49.0
version: 3.49.0 version: 3.49.0
marked:
specifier: ^18.0.5
version: 18.0.5
svelte: svelte:
specifier: ^5.15.0 specifier: ^5.15.0
version: 5.56.0 version: 5.56.0
@@ -1628,6 +1631,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3877,6 +3885,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.5: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
minimatch@10.2.5: minimatch@10.2.5:
+7 -3
View File
@@ -7,7 +7,9 @@
// //
// Three independent gates on the natural chunk boundaries, each with realistic headroom: // Three independent gates on the natural chunk boundaries, each with realistic headroom:
// - app entry (main): the app's own code; grows with features. // - app entry (main): the app's own code; grows with features.
// - shared (svelte+i18n): near-static framework runtime; only drifts on a dep/Svelte bump. // - shared (svelte+i18n): the framework runtime plus the flat i18n map; drifts on a dep/Svelte
// bump and, slightly, as feature copy adds i18n keys (raised to 31 for
// the rewarded-ad strings).
// - landing own: the landing's own code; kept minimal. // - landing own: the landing's own code; kept minimal.
// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is // Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is
// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still // ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still
@@ -28,9 +30,11 @@ const DIST = 'dist';
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN // live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen, // hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands // storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
// in the shared chunk). The heavy parts — the dict loader, the move generator and the preload // in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget. // orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 123, shared: 30, landing: 5 }; const BUDGET = { app: 125, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+14 -1
View File
@@ -123,7 +123,10 @@
</div> </div>
</section> </section>
<footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer> <footer class="ft">
<a class="offer" href="/offer/">{t('landing.offer')}</a>
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
</footer>
</main> </main>
<style> <style>
@@ -275,8 +278,18 @@
} }
.ft { .ft {
margin-top: auto; margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
.ft .offer {
color: inherit;
}
.ft .offer:hover {
color: var(--accent);
}
</style> </style>
+21
View File
@@ -15,6 +15,7 @@
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte'; import { offlineMode } from '../lib/offline.svelte';
import { maybeShowInterstitial } from '../lib/ads';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
@@ -93,6 +94,11 @@
let exchangeOpen = $state(false); let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]); let exchangeSel = $state<number[]>([]);
let resignOpen = $state(false); let resignOpen = $state(false);
// hintUsedThisTurn marks that a hint was applied on the current turn, so a confirmed play earns
// the hint-kind interstitial (its own 1-min cooldown) instead of the plain move one. Set in
// doHint when the hint's tiles land, read in commit, and cleared on any turn boundary
// (applyMoveResult) so a hint-then-pass does not leak into the next turn's move.
let hintUsedThisTurn = $state(false);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
// Landscape (wide) layout: when the viewport is wider than tall the game switches to a // Landscape (wide) layout: when the viewport is wider than tall the game switches to a
// two-column layout — the board fills the right side as a square fitted to the height (no // two-column layout — the board fills the right side as a square fitted to the height (no
@@ -819,6 +825,9 @@
// applyMoveResult renders the actor's own just-committed move from the response — the move, the // applyMoveResult renders the actor's own just-committed move from the response — the move, the
// post-move game and the refilled rack — without a follow-up game.state + game.history. // post-move game and the refilled rack — without a follow-up game.state + game.history.
function applyMoveResult(r: MoveResult) { function applyMoveResult(r: MoveResult) {
// A turn boundary (play / pass / exchange / resign): clear the hint marker so it never leaks
// into the next turn. commit captures it before this runs.
hintUsedThisTurn = false;
view = { view = {
game: r.game, game: r.game,
seat: r.move.player, seat: r.move.player,
@@ -941,11 +950,20 @@
const sub = toSubmit(placement); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
busy = true; busy = true;
// Capture the hint marker before applyMoveResult clears it: a move played off a hint earns the
// hint-kind interstitial (own cooldown), a plain move the move-kind one.
const usedHint = hintUsedThisTurn;
try { try {
applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
if (view?.game.hotseat) await advanceHotseat(); if (view?.game.hotseat) await advanceHotseat();
haptic('success'); haptic('success');
zoomed = false; zoomed = false;
// A confirmed move may trigger a post-move interstitial (VK, frequency-gated, client-mirrored).
// Only submitPlay earns one — never a pass / exchange / resign. Fire-and-forget.
void maybeShowInterstitial(app.profile?.ads, usedHint ? 'hint' : 'move', {
vsAi: !!view?.game.vsAi,
online: connection.online && !offlineMode.active,
});
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -1009,6 +1027,9 @@
const h = await source.hint(id); const h = await source.hint(id);
if (h.move.tiles.length && view) { if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack); placement = placementFromHint(h.move.tiles, view.rack);
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
// not now — showing it here would interrupt placing the preview and revert the board on close.
hintUsedThisTurn = true;
// Scroll the (zoomed) board to the hint's placement rather than the top-left: // Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box. // focus the centre of the laid tiles' bounding box.
const p = placement.pending; const p = placement.pending;
+4
View File
@@ -4,6 +4,7 @@ export { AccountDeleteConfirm } from './scrabblefb/account-delete-confirm.js';
export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js'; export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js';
export { AccountRef } from './scrabblefb/account-ref.js'; export { AccountRef } from './scrabblefb/account-ref.js';
export { Ack } from './scrabblefb/ack.js'; export { Ack } from './scrabblefb/ack.js';
export { AdsInfo } from './scrabblefb/ads-info.js';
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
export { BannerCampaign } from './scrabblefb/banner-campaign.js'; export { BannerCampaign } from './scrabblefb/banner-campaign.js';
export { BannerInfo } from './scrabblefb/banner-info.js'; export { BannerInfo } from './scrabblefb/banner-info.js';
@@ -86,6 +87,9 @@ export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
export { VKLoginRequest } from './scrabblefb/vklogin-request.js'; export { VKLoginRequest } from './scrabblefb/vklogin-request.js';
export { Wallet } from './scrabblefb/wallet.js'; export { Wallet } from './scrabblefb/wallet.js';
export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js'; export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js';
export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js';
export { WalletOrderResponse } from './scrabblefb/wallet-order-response.js';
export { WalletRewardRequest } from './scrabblefb/wallet-reward-request.js';
export { WalletSegment } from './scrabblefb/wallet-segment.js'; export { WalletSegment } from './scrabblefb/wallet-segment.js';
export { WordCheckResult } from './scrabblefb/word-check-result.js'; export { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.js'; export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
+76
View File
@@ -0,0 +1,76 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class AdsInfo {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):AdsInfo {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsAdsInfo(bb:flatbuffers.ByteBuffer, obj?:AdsInfo):AdsInfo {
return (obj || new AdsInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsAdsInfo(bb:flatbuffers.ByteBuffer, obj?:AdsInfo):AdsInfo {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new AdsInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
cooldownGlobalS():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
cooldownVsAiS():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
cooldownHintS():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
suppressed():boolean {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startAdsInfo(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addCooldownGlobalS(builder:flatbuffers.Builder, cooldownGlobalS:number) {
builder.addFieldInt32(0, cooldownGlobalS, 0);
}
static addCooldownVsAiS(builder:flatbuffers.Builder, cooldownVsAiS:number) {
builder.addFieldInt32(1, cooldownVsAiS, 0);
}
static addCooldownHintS(builder:flatbuffers.Builder, cooldownHintS:number) {
builder.addFieldInt32(2, cooldownHintS, 0);
}
static addSuppressed(builder:flatbuffers.Builder, suppressed:boolean) {
builder.addFieldInt8(3, +suppressed, +false);
}
static endAdsInfo(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createAdsInfo(builder:flatbuffers.Builder, cooldownGlobalS:number, cooldownVsAiS:number, cooldownHintS:number, suppressed:boolean):flatbuffers.Offset {
AdsInfo.startAdsInfo(builder);
AdsInfo.addCooldownGlobalS(builder, cooldownGlobalS);
AdsInfo.addCooldownVsAiS(builder, cooldownVsAiS);
AdsInfo.addCooldownHintS(builder, cooldownHintS);
AdsInfo.addSuppressed(builder, suppressed);
return AdsInfo.endAdsInfo(builder);
}
}
+11 -1
View File
@@ -2,6 +2,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { AdsInfo } from '../scrabblefb/ads-info.js';
import { BannerInfo } from '../scrabblefb/banner-info.js'; import { BannerInfo } from '../scrabblefb/banner-info.js';
import { DictVersion } from '../scrabblefb/dict-version.js'; import { DictVersion } from '../scrabblefb/dict-version.js';
@@ -135,8 +136,13 @@ dictVersionsLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
} }
ads(obj?:AdsInfo):AdsInfo|null {
const offset = this.bb!.__offset(this.bb_pos, 38);
return offset ? (obj || new AdsInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startProfile(builder:flatbuffers.Builder) { static startProfile(builder:flatbuffers.Builder) {
builder.startObject(17); builder.startObject(18);
} }
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -231,6 +237,10 @@ static startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4); builder.startVector(4, numElems, 4);
} }
static addAds(builder:flatbuffers.Builder, adsOffset:flatbuffers.Offset) {
builder.addFieldOffset(17, adsOffset, 0);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletOrderRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest {
return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
productId():string|null
productId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
productId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletOrderRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addProductId(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, productIdOffset, 0);
}
static endWalletOrderRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletOrderRequest(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletOrderRequest.startWalletOrderRequest(builder);
WalletOrderRequest.addProductId(builder, productIdOffset);
return WalletOrderRequest.endWalletOrderRequest(builder);
}
}
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletOrderResponse {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderResponse {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse {
return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
orderId():string|null
orderId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
orderId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
redirectUrl():string|null
redirectUrl(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
redirectUrl(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletOrderResponse(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addOrderId(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, orderIdOffset, 0);
}
static addRedirectUrl(builder:flatbuffers.Builder, redirectUrlOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, redirectUrlOffset, 0);
}
static endWalletOrderResponse(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletOrderResponse(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset, redirectUrlOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletOrderResponse.startWalletOrderResponse(builder);
WalletOrderResponse.addOrderId(builder, orderIdOffset);
WalletOrderResponse.addRedirectUrl(builder, redirectUrlOffset);
return WalletOrderResponse.endWalletOrderResponse(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletRewardRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletRewardRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
nonce():string|null
nonce(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
nonce(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletRewardRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, nonceOffset, 0);
}
static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletRewardRequest.startWalletRewardRequest(builder);
WalletRewardRequest.addNonce(builder, nonceOffset);
return WalletRewardRequest.endWalletRewardRequest(builder);
}
}
+12 -2
View File
@@ -48,8 +48,13 @@ hints():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
} }
rewardChips():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startWallet(builder:flatbuffers.Builder) { static startWallet(builder:flatbuffers.Builder) {
builder.startObject(4); builder.startObject(5);
} }
static addSegments(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset) { static addSegments(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset) {
@@ -80,17 +85,22 @@ static addHints(builder:flatbuffers.Builder, hints:number) {
builder.addFieldInt32(3, hints, 0); builder.addFieldInt32(3, hints, 0);
} }
static addRewardChips(builder:flatbuffers.Builder, rewardChips:number) {
builder.addFieldInt32(4, rewardChips, 0);
}
static endWallet(builder:flatbuffers.Builder):flatbuffers.Offset { static endWallet(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createWallet(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset, adsForever:boolean, adsPaidUntilMs:bigint, hints:number):flatbuffers.Offset { static createWallet(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset, adsForever:boolean, adsPaidUntilMs:bigint, hints:number, rewardChips:number):flatbuffers.Offset {
Wallet.startWallet(builder); Wallet.startWallet(builder);
Wallet.addSegments(builder, segmentsOffset); Wallet.addSegments(builder, segmentsOffset);
Wallet.addAdsForever(builder, adsForever); Wallet.addAdsForever(builder, adsForever);
Wallet.addAdsPaidUntilMs(builder, adsPaidUntilMs); Wallet.addAdsPaidUntilMs(builder, adsPaidUntilMs);
Wallet.addHints(builder, hints); Wallet.addHints(builder, hints);
Wallet.addRewardChips(builder, rewardChips);
return Wallet.endWallet(builder); return Wallet.endWallet(builder);
} }
} }
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AdsConfig } from './model';
// Mock ads.ts's three impure imports so the client-mirrored gate is observable without a VK
// bridge, a wallet context or the Svelte toast store. ./model is type-only (erased).
const mocks = vi.hoisted(() => ({
vkShowInterstitial: vi.fn(),
vkRewardedReady: vi.fn(),
vkShowRewarded: vi.fn(),
executionContext: vi.fn(),
showToast: vi.fn(),
}));
vi.mock('./vk', () => ({
vkShowInterstitial: mocks.vkShowInterstitial,
vkRewardedReady: mocks.vkRewardedReady,
vkShowRewarded: mocks.vkShowRewarded,
}));
vi.mock('./wallet', () => ({ executionContext: mocks.executionContext }));
vi.mock('./app.svelte', () => ({ showToast: mocks.showToast }));
import { maybeShowInterstitial } from './ads';
const ADS: AdsConfig = { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false };
const ONLINE = { vsAi: false, online: true };
// A realistic epoch base: the never-shown last time is 0, so the first show needs now >> cooldown
// (as with a real Date.now); anchoring at 0 would gate the very first call (now - 0 < cooldown).
const BASE = 1_700_000_000_000;
beforeEach(() => {
// A minimal in-memory localStorage (node has none) so the per-kind last-shown gate persists
// across calls within a test.
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: () => null,
length: 0,
} as Storage;
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(BASE);
mocks.executionContext.mockReturnValue('vk');
mocks.vkShowInterstitial.mockResolvedValue(true);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
describe('maybeShowInterstitial (client-mirrored gate)', () => {
it('does not show when the config is absent', async () => {
expect(await maybeShowInterstitial(undefined, 'move', ONLINE)).toBe(false);
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
});
it('does not show when suppressed (no-ads / no_banner)', async () => {
expect(await maybeShowInterstitial({ ...ADS, suppressed: true }, 'move', ONLINE)).toBe(false);
expect(mocks.showToast).not.toHaveBeenCalled();
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
});
it('does not show when offline', async () => {
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: false, online: false })).toBe(false);
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
});
it('does not show outside VK (real ad path)', async () => {
mocks.executionContext.mockReturnValue('web');
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false);
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
});
it('shows a VK interstitial inside VK when the cooldown allows', async () => {
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1);
expect(mocks.showToast).not.toHaveBeenCalled();
});
it('respects the cooldown: a second move within the window is gated', async () => {
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false);
vi.setSystemTime(BASE + 300_000); // the cooldown has now elapsed
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2);
});
it('shares one timer across kinds: a hint uses the shorter gap from the last ad', async () => {
// A move ad, then a hint: the hint is held until its own (shorter) 60s gap from THAT ad has
// elapsed — not fired at once (a per-kind timer used to let it fire immediately).
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
vi.setSystemTime(BASE + 59_000);
expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false);
vi.setSystemTime(BASE + 60_000); // the hint's 60s gap from the last ad has elapsed
expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true);
});
it('does not stack a move ad onto a just-shown hint ad (the reported bug)', async () => {
// A vs_ai hint-move fires the hint ad; a plain vs_ai move 30s later must NOT fire — the shared
// timer holds it for the vs_ai gap. Separate per-kind timers let it fire at once (the bug).
const vsAi = { vsAi: true, online: true };
expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true);
vi.setSystemTime(BASE + 30_000); // 30s later, far under the 1800s vs_ai gap
expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false);
expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); // only the hint ad fired
});
it('a hint-pushed ad restarts the vs_ai gap, so the next plain move is not over-served', async () => {
// The scenario the owner flagged: a vs_ai ad, then 20 min later a hinted move pushes an ad on
// its short gap, then a plain move 10 min after that must NOT fire — the shared timer restarted
// the 30-min vs_ai gap at the hint ad, so 10 min is not enough (it fires only 30 min after it).
const vsAi = { vsAi: true, online: true };
expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true); // t0
vi.setSystemTime(BASE + 20 * 60_000); // +20 min: a hint pushes an ad on its 1-min gap
expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true);
vi.setSystemTime(BASE + 30 * 60_000); // +10 min after the hint ad
expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false);
vi.setSystemTime(BASE + 50 * 60_000); // +30 min after the hint ad
expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true);
});
it('uses the longer vs_ai cooldown for a vs_ai move', async () => {
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true);
vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(false);
vi.setSystemTime(BASE + 1_800_000);
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true);
});
it('shows the test-stub toast instead of a real ad when the stub flag is set', async () => {
vi.stubEnv('VITE_ADS_STUB', '1');
mocks.executionContext.mockReturnValue('web'); // the stub bypasses the VK gate
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
expect(mocks.showToast).toHaveBeenCalledWith('ad fired');
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
});
});
+107
View File
@@ -0,0 +1,107 @@
// The ads-network abstraction (D28). VK is the only network now; a future network for another
// platform implements the same rewardedReady/showRewarded surface without touching the callers. On
// the contour a build flag (VITE_ADS_STUB) substitutes a toast stub for the real ad, so the reward
// flow is testable without waiting for a real ad — production never sets the flag, so a real ad that
// fails to load there must not credit (no stub fallback in prod).
import { executionContext } from './wallet';
import { vkRewardedReady, vkShowRewarded, vkShowInterstitial } from './vk';
import { showToast } from './app.svelte';
import type { AdsConfig } from './model';
/**
* adsStubEnabled reports the contour test stub (VITE_ADS_STUB=1). Production never sets it, so it
* always shows real ads. The mock e2e forces it on so the reward flow is exercisable without a VK
* bridge. To capture the real VK ad result on the contour (the diagnostic), deploy with the flag
* off first; flip it on afterwards for routine stub testing.
*/
export function adsStubEnabled(): boolean {
if (import.meta.env.MODE === 'mock') return true;
return (import.meta.env as Record<string, string | undefined>).VITE_ADS_STUB === '1';
}
/** RewardedResult is one rewarded-ad view: whether it was watched, and whether it was the test stub
* (so the caller can show the "ad fired" marker toast). */
export interface RewardedResult {
watched: boolean;
stub: boolean;
}
/**
* rewardedReady reports whether a rewarded ad is ready to show, gating the "watch for chips" button.
* The stub is always ready; a real ad is ready only inside VK with preloaded material. Rewarded is
* VK-only (D28).
*/
export async function rewardedReady(): Promise<boolean> {
if (adsStubEnabled()) return true;
if (executionContext() !== 'vk') return false;
return vkRewardedReady();
}
/**
* showRewarded shows a rewarded ad and reports whether it was watched, the raw provider result, and
* whether it was the stub. On the stub it resolves watched immediately (the caller shows the "ad
* fired" toast).
*/
export async function showRewarded(): Promise<RewardedResult> {
if (adsStubEnabled()) {
return { watched: true, stub: true };
}
return { watched: await vkShowRewarded(), stub: false };
}
// The post-move interstitial gate is client-mirrored: the server sends the cooldowns + suppressed in
// the profile (Profile.ads); the client tracks the last-shown time in localStorage and self-gates.
// Last-shown is per-device (cleared with storage) — acceptable for a frequency gate.
//
// It is a SINGLE shared timestamp across kinds, not one per kind: the kind only selects the required
// gap (hint's short cooldown vs the move / vs_ai one), while the wait is always measured from the
// last interstitial of ANY kind. A per-kind timer let a hint ad and a move ad stack — after a
// hint-move ad the move timer was still zero, so the next plain move fired an ad immediately.
const INTERSTITIAL_LAST_KEY = 'ads.interstitial.last';
// interstitialLast reads the last-shown epoch millis of any interstitial (0 when absent/corrupt, or
// when a pre-shared-timer value — an object — is still stored: it decodes to 0 and self-heals).
function interstitialLast(): number {
try {
const raw = localStorage.getItem(INTERSTITIAL_LAST_KEY);
if (raw) {
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
}
} catch {
/* a corrupt or unavailable store simply resets the gate */
}
return 0;
}
function recordInterstitial(now: number): void {
try {
localStorage.setItem(INTERSTITIAL_LAST_KEY, String(now));
} catch {
/* ignore a write failure — the worst case is one extra ad next time */
}
}
/**
* maybeShowInterstitial shows a post-move (`kind='move'`, global / vs_ai cooldown) or post-hint
* (`kind='hint'`, its own short cooldown) interstitial when the client-mirrored gate allows: not
* suppressed (no-ads), online, inside VK (the stub bypasses VK), and the required gap has elapsed
* since the last interstitial of any kind. The kind picks the gap; the timer is shared, so a hint ad
* and a move ad never fire within a cooldown of each other. The stub (contour test) shows an "ad
* fired" toast. Returns whether an ad was shown. Fire-and-forget the caller does not await it.
*/
export async function maybeShowInterstitial(
ads: AdsConfig | undefined,
kind: 'move' | 'hint',
opts: { vsAi: boolean; online: boolean },
): Promise<boolean> {
if (!ads || ads.suppressed || !opts.online) return false;
const stub = adsStubEnabled();
if (!stub && executionContext() !== 'vk') return false;
const cooldownS = kind === 'hint' ? ads.cooldownHintS : opts.vsAi ? ads.cooldownVsAiS : ads.cooldownGlobalS;
const now = Date.now();
if (now - interstitialLast() < cooldownS * 1000) return false;
const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial();
if (shown) recordInterstitial(now);
return shown;
}
+10
View File
@@ -137,6 +137,9 @@ export const app = $state<{
/** Whether an operator feedback reply awaits the player, for the lobby badge (combined /** Whether an operator feedback reply awaits the player, for the lobby badge (combined
* with friend requests) and the Settings Info badge. */ * with friend requests) and the Settings Info badge. */
feedbackReplyUnread: boolean; feedbackReplyUnread: boolean;
/** A monotone counter bumped when a payment-intake push signals the wallet changed; an open
* Wallet screen watches it and re-fetches in place. */
walletRefresh: number;
/** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
* the bot instead of a scary error on the Friends screen. */ * the bot instead of a scary error on the Friends screen. */
@@ -175,6 +178,7 @@ export const app = $state<{
chatUnread: {}, chatUnread: {},
messageUnread: {}, messageUnread: {},
feedbackReplyUnread: false, feedbackReplyUnread: false,
walletRefresh: 0,
staleInvite: false, staleInvite: false,
welcomeRedeem: false, welcomeRedeem: false,
resync: 0, resync: 0,
@@ -439,6 +443,12 @@ function openStream(): void {
if (e.sub === 'profile') { if (e.sub === 'profile') {
void refreshProfile(); void refreshProfile();
} }
// A payment-intake event credited (or refunded) the viewer's wallet: bump the signal an
// open Wallet screen watches, so it re-fetches in place (the return-focus poll is the
// fallback when the live stream is down).
if (e.sub === 'payment') {
app.walletRefresh++;
}
void refreshNotifications(); void refreshNotifications();
} }
}, },
+8
View File
@@ -35,6 +35,7 @@ import type {
Variant, Variant,
WordCheckResult, WordCheckResult,
Wallet, Wallet,
WalletOrder,
Catalog, Catalog,
} from './model'; } from './model';
@@ -147,6 +148,13 @@ export interface GatewayClient {
/** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked /** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked
* server-side: an untrusted/frozen context or an insufficient balance is refused. */ * server-side: an untrusted/frozen context or an insufficient balance is refused. */
walletBuy(productId: string): Promise<Wallet>; walletBuy(productId: string): Promise<Wallet>;
/** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
walletOrder(productId: string): Promise<WalletOrder>;
/** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is
* the per-view idempotency key. Client-attested + a server daily/hourly cap; a reached cap rejects
* with a domain code. */
walletReward(nonce: string): Promise<Wallet>;
// --- friends --- // --- friends ---
friendsList(): Promise<AccountRef[]>; friendsList(): Promise<AccountRef[]>;

Some files were not shown because too many files have changed in this diff Show More