Compare commits
38 Commits
v1.13.0
...
00d1bd33e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 00d1bd33e3 | |||
| 7ce8101cfa | |||
| b5c8a04f0b | |||
| 2ce80c241d | |||
| 13be7c3d9a | |||
| e08d3301bd | |||
| 9acf6ab3b4 | |||
| dbd76d53e8 | |||
| 68c937f3b6 | |||
| 21fb14facf | |||
| ee9924c313 | |||
| e10f3beed5 | |||
| 55dbb1005e | |||
| 219fc7c827 | |||
| 6e03ce0131 | |||
| 5612bb624d | |||
| 8230253142 | |||
| 890a887257 | |||
| e33b9864c8 | |||
| f6aa0ba4b0 | |||
| f48ebf2151 | |||
| 3bb9f10fad | |||
| 3e0763463f | |||
| 96adf98e1d | |||
| 3367cc2bf1 | |||
| 04435a3283 | |||
| be0e4995f3 | |||
| 936a70ab94 | |||
| 4f6c22d669 | |||
| 2a6dc5a304 | |||
| a66a5bfa08 | |||
| 36a5730e52 | |||
| 7860efce48 | |||
| ca60025fbe | |||
| 0bdc034f7a | |||
| 625fc3135a | |||
| a118c63411 | |||
| 7123ba439d |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}},
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(¶ms); 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(¶ms); 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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
|||||||
Generated
+10
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user