Compare commits
45 Commits
v1.13.0
...
ec5c6afa23
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5c6afa23 | |||
| 82648a4398 | |||
| d2d6955cbf | |||
| c3eecf16b3 | |||
| 0036b55618 | |||
| aabb32081e | |||
| e089a0a997 | |||
| 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_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||
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 }}
|
||||
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
||||
# 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.
|
||||
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||
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
|
||||
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
|
||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||
@@ -497,6 +507,38 @@ jobs:
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
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
|
||||
run: |
|
||||
set -u
|
||||
|
||||
@@ -33,10 +33,10 @@ status — without re-deriving decisions.
|
||||
| E1 | Trusted platform signal | 1 | DONE |
|
||||
| E2 | Currency + benefit core | 1 | DONE |
|
||||
| E3 | Wallet UI | 1 | DONE |
|
||||
| E4 | Durability (PITR) | 2 | WIP |
|
||||
| E5 | Payment intake | 2 | TODO |
|
||||
| E6 | Ads | 2 | TODO |
|
||||
| E7 | Admin & reports | 2 | TODO |
|
||||
| E4 | Durability (PITR) | 2 | DONE |
|
||||
| E5 | Payment intake | 2 | DONE |
|
||||
| E6 | Ads | 2 | DONE |
|
||||
| E7 | Admin, reports & catalog | 2 | TODO |
|
||||
| E8 | Guest limits | — | TODO |
|
||||
| E9 | Tournament fee | future | TODO |
|
||||
|
||||
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
|
||||
|
||||
## E4 — Durability (PITR)
|
||||
|
||||
**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before
|
||||
E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
|
||||
**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** ·
|
||||
depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
|
||||
|
||||
**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.
|
||||
@@ -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.
|
||||
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.
|
||||
|
||||
**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates
|
||||
the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
|
||||
then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance
|
||||
window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with
|
||||
`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps:
|
||||
`deploy/README.md` (point-in-time recovery — arming).
|
||||
**Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3
|
||||
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
|
||||
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
|
||||
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
|
||||
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
|
||||
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),
|
||||
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).
|
||||
|
||||
**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL
|
||||
archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook
|
||||
current in `deploy/README.md`.
|
||||
**Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an
|
||||
isolated one-shot target; cost + perf assessment reviewed; runbook current in
|
||||
`deploy/README.md`.
|
||||
|
||||
**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
|
||||
window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
|
||||
from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble`
|
||||
(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
|
||||
|
||||
**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,
|
||||
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
|
||||
@@ -525,12 +568,14 @@ receipts, and refunds.
|
||||
|
||||
**TG bot outbox (`platform/telegram/`).**
|
||||
|
||||
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
|
||||
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
|
||||
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
|
||||
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
|
||||
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
|
||||
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
|
||||
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
|
||||
store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
|
||||
mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
|
||||
directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
|
||||
mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
|
||||
`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.**
|
||||
|
||||
@@ -542,9 +587,13 @@ receipts, and refunds.
|
||||
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
|
||||
handles Votes tax itself; TG Stars — no receipt.
|
||||
|
||||
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
|
||||
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
|
||||
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
|
||||
**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
|
||||
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
|
||||
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.**
|
||||
|
||||
@@ -569,9 +618,42 @@ force-recreate when the Caddyfile changes.
|
||||
|
||||
## 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.
|
||||
|
||||
**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
|
||||
(credits chips via server verify), plus extending the existing banner suppression to
|
||||
per-origin.
|
||||
@@ -586,8 +668,9 @@ per-origin.
|
||||
- **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
|
||||
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
|
||||
(per user) or client-mirrored from a server value — pick and document at implementation.
|
||||
offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored**
|
||||
(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
|
||||
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.
|
||||
@@ -610,38 +693,89 @@ it tunes without a store release.
|
||||
|
||||
---
|
||||
|
||||
## E7 — Admin & reports
|
||||
## E7 — Admin, reports & catalog
|
||||
|
||||
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
|
||||
mechanics: PAYMENTS §11.
|
||||
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
|
||||
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
|
||||
|
||||
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
|
||||
refund UI, ledger export.
|
||||
**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
|
||||
|
||||
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
|
||||
- **Archived product = the existing `product.active` flag** (no new column, no migration):
|
||||
`active=false` **is** "archived". Its three behaviours already hold — hidden from the user
|
||||
storefront (`store_catalog.go` filters `active`), an **in-flight external payment still
|
||||
credits** (the `fund` credit path resolves the order and never re-checks `active`; only order
|
||||
*creation* / chip *spend* require `active`), and a product with any order/ledger row **cannot
|
||||
be hard-deleted** (FK `orders→product` / `ledger→product` are RESTRICT). The admin toggle is
|
||||
labelled **Archive / Unarchive**.
|
||||
- **Delete vs archive:** the editor offers a hard **Delete** only for a product with **no
|
||||
orders and no ledger rows** (never transacted — the FK is the DB backstop); a transacted
|
||||
product is **archive-only**.
|
||||
- **Admin grant = raw atoms + by-product.** Keep the quick raw-atom grant (N hints / no-ads
|
||||
days / forever) AND add grant-by-product: pick a defined product (including archived "reward"
|
||||
bundles) and grant its atoms. Both write an `admin_grant` ledger row; by-product records
|
||||
`product_id` + the snapshot. **Both refuse any set containing `chips`** (admin never grants
|
||||
currency) **or `tournament`** (no credit target until E9) — an explicit refusal, never a
|
||||
silent no-op.
|
||||
- **Manual refund = full order only** for now (the E5 `Refund` engine takes an amount; partial
|
||||
is a later add if needed).
|
||||
- **Tournament stays atom-only (E0); its entry economy is E9.** The catalog editor can compose
|
||||
products carrying the `tournament` atom (archived templates for E9), but granting/spending a
|
||||
`tournament` atom is refused until E9 designs the storage (recurring types, each its own
|
||||
benefit + price) — see E9. Adding a `benefits.tournament` counter now was rejected: the model
|
||||
is multi-type, so a single column would be wrong and force a second DB break.
|
||||
- The admin grant **no longer mirrors `grant-hints`** (E6/D31 removed that action); it is a
|
||||
fresh action on `payments.Grant`.
|
||||
|
||||
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
|
||||
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
|
||||
read from the append-only ledger + materialized cache.
|
||||
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
|
||||
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
|
||||
`admin_grant` ledger row (E2 `Grant`).
|
||||
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
|
||||
path); records a `refund` ledger row; best-effort benefit revoke.
|
||||
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
|
||||
reconciliation (export-ready schema; reconciliation itself not built).
|
||||
**Goal.** The admin console financial surface — per-user report, admin grant, manual refund,
|
||||
ledger export — plus the **configurable product catalog editor** (D32).
|
||||
|
||||
**Work (`/_gm`, `handlers_admin_console.go` + `adminconsole/`, `internal/payments/`).**
|
||||
|
||||
- **Per-user financial panel** on the user card (`consoleUserDetail`, `UserDetailView`): segment
|
||||
chip balances `(account, source)`, benefits `(account, origin)` (hints, no-ads until/forever),
|
||||
and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product,
|
||||
provider, snapshot) from the ledger + materialized cache. Replaces the retired
|
||||
`PaidAccount`/`HintBalance` fields with the segmented view.
|
||||
- **Catalog editor** (`/_gm/catalog`): list every product (active + archived) with its atoms and
|
||||
per-method/currency prices; create/edit (title, atom items `atom_type→quantity`, price rows
|
||||
`method+currency→amount`); **Archive/Unarchive** (`active`); **Delete** (never-transacted
|
||||
only). Enforce the projection's shape: a **pack** (carries `chips`) needs a money price per
|
||||
method; a **value** (no `chips`) needs a single `CHIP` price. A `tournament`-bearing product is
|
||||
allowed in composition but cannot be activated for sale until E9.
|
||||
- **Admin grant** action: raw atoms (hints / no-ads days / forever) and by-product (a value
|
||||
product, incl. archived); **origin picker**; refuses `chips`/`tournament`; `admin_grant` ledger
|
||||
row (+ `product_id`/snapshot for by-product) via `payments.Grant`.
|
||||
- **Manual refund** action: refund a specific paid order **in full** — a `refund` ledger row +
|
||||
best-effort floor-0 benefit revoke (E5 `Refund`).
|
||||
- **Ledger export**: CSV/JSON for tax + future Robokassa reconciliation (export-ready;
|
||||
reconciliation itself not built).
|
||||
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit: report view assembly; grant refuses chips; export shape.
|
||||
- integration: grant/refund write correct ledger rows; report reflects balances+history.
|
||||
- unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament;
|
||||
editor validation (pack⇒money price, value⇒CHIP price); export shape.
|
||||
- integration: grant (raw + by-product) writes the right `admin_grant` row + benefit; refund writes
|
||||
a `refund` row + floor-0 revoke; catalog CRUD round-trips (create→edit→archive→delete-if-clean);
|
||||
delete refused on a transacted product; panel reflects balances + benefits + history.
|
||||
|
||||
**Done-criteria.** An operator can see a user's full financial picture, grant concrete
|
||||
values (origin-picked, never chips), issue a manual refund, and export the ledger.
|
||||
**Done-criteria.** An operator can: see a user's full financial picture; create/edit/archive
|
||||
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
|
||||
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
|
||||
|
||||
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
|
||||
those with the segmented view as the legacy columns retire.
|
||||
**PR stack (linear into `development`).**
|
||||
|
||||
1. **Per-user financial panel** (read-only ledger/segments/benefits on the user card; retires the
|
||||
`PaidAccount`/`HintBalance` display).
|
||||
2. **Catalog editor** (product/atom/price CRUD + archive/unarchive + delete-if-clean + shape
|
||||
validation).
|
||||
3. **Admin grant** (raw + by-product, refuse chips/tournament, origin-picked, `admin_grant` +
|
||||
snapshot).
|
||||
4. **Manual refund** (full order via E5 `Refund`) + **ledger export** (CSV/JSON).
|
||||
|
||||
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
|
||||
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
|
||||
become bootstrap-only.
|
||||
|
||||
---
|
||||
|
||||
@@ -687,11 +821,23 @@ on creation, not mid-game) at implementation and record it here.
|
||||
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
|
||||
mechanics: PAYMENTS §5, §7.
|
||||
|
||||
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
|
||||
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
|
||||
feature and its coupling to entry are out of scope until the tournament feature exists.
|
||||
**Goal.** A chip entry economy for tournaments. The `tournament` atom is provisioned (E0) and
|
||||
E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage
|
||||
+ credit/spend** here, to avoid guessing the schema.
|
||||
|
||||
**Done-criteria.** Deferred — revisit when tournaments are built.
|
||||
**Design to settle here (not before — avoid a double DB break).** Tournaments are expected in
|
||||
**several recurring types** (daily / weekly / monthly …), each its **own benefit with its own
|
||||
price** — so a single `benefits.tournament` counter is wrong. Model the entry store as
|
||||
**per-tournament-type** (e.g. a `tournament_type` catalog + a per-`(account, type)` entry
|
||||
balance), design the pricing, then:
|
||||
|
||||
- lift E7's **refusal** of granting/spending the `tournament` atom (admin grant by-product +
|
||||
chip spend);
|
||||
- add the **spend** (charge an entry) reusing E2 `Spend`;
|
||||
- wire the coupling to the actual tournament feature (out of scope until it exists).
|
||||
|
||||
**Done-criteria.** Deferred — revisit when tournaments are built; this stage owns the
|
||||
tournament-entry storage + pricing design.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+19
-6
@@ -135,21 +135,34 @@ 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` +
|
||||
`/_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
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
|
||||
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
|
||||
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
|
||||
applicable in the current context and no **`no_banner`** role; the message language picked by
|
||||
`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 user card
|
||||
also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per
|
||||
funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
(newest first) — read straight from the payments tables, uncached. The **catalog editor**
|
||||
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
|
||||
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
|
||||
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
|
||||
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
|
||||
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
|
||||
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
|
||||
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
|
||||
atom (never grant currency; no tournament target yet). The shared wire
|
||||
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
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
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats summed,
|
||||
identities/games/chat/complaints transferred,
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
|
||||
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,
|
||||
ExportSignKey: cfg.ExportSignKey,
|
||||
Renderer: renderer,
|
||||
Robokassa: cfg.Robokassa,
|
||||
})
|
||||
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",
|
||||
zap.String("http_addr", cfg.HTTPAddr),
|
||||
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)
|
||||
go func() { errc <- pushSrv.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
|
||||
}
|
||||
|
||||
// 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
|
||||
// configured, otherwise the development log mailer (the code is logged, not sent).
|
||||
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
|
||||
// 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,
|
||||
// computed in internal/robot, not from a robot account's away window.) HintBalance
|
||||
// is the player's wallet of purchasable hints, spent after a game's per-seat
|
||||
// allowance.
|
||||
// computed in internal/robot, not from a robot account's away window.)
|
||||
type Account struct {
|
||||
ID uuid.UUID
|
||||
DisplayName string
|
||||
@@ -53,7 +51,6 @@ type Account struct {
|
||||
TimeZone string
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
// 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
|
||||
// account.
|
||||
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
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// by limit and offset.
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -649,12 +611,10 @@ func modelToAccount(row model.Accounts) Account {
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
FlaggedHighRateAt: flaggedHighRateAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
|
||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "content" -}}
|
||||
<h1>Product catalog</h1>
|
||||
{{with .Data}}
|
||||
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
|
||||
<section class="panel"><h2>Add product</h2>
|
||||
<form class="form col" method="post" action="/_gm/catalog">
|
||||
<label>Title <input type="text" name="title" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; blank = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; blank = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
|
||||
</fieldset>
|
||||
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
|
||||
<div><button type="submit">Add</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Products</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Products}}
|
||||
<tr>
|
||||
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
|
||||
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
|
||||
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
|
||||
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
|
||||
<td class="row-actions">
|
||||
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
|
||||
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<p class="note"><a href="/_gm/catalog">← all products</a></p>
|
||||
<h1>{{.Title}} {{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</h1>
|
||||
<section class="panel"><h2>Edit</h2>
|
||||
<p class="note">A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the <a href="/_gm/catalog">catalog list</a>.</p>
|
||||
<form class="form col" method="post" action="/_gm/catalog/{{.ID}}">
|
||||
<label>Title <input type="text" name="title" value="{{.Title}}" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; 0 = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0" value="{{.Chips}}"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="{{.Hints}}"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="{{.NoAds}}"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0" value="{{.Tournament}}"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; 0 = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0" value="{{.PriceRUB}}"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0" value="{{.PriceVote}}"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0" value="{{.PriceStar}}"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0" value="{{.PriceChip}}"></label>
|
||||
</fieldset>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -10,8 +10,6 @@
|
||||
<li><b>Timezone</b> {{.TimeZone}}</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>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 .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
@@ -21,10 +19,6 @@
|
||||
<button type="submit">Clear high-rate flag</button>
|
||||
</form>
|
||||
{{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 class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
@@ -69,6 +63,48 @@
|
||||
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Finance</h2>
|
||||
{{if .Finance.Present}}
|
||||
{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
|
||||
<ul class="kv">
|
||||
{{range .Finance.Segments}}<li><b>Chips ({{.Source}})</b> {{.Chips}}</li>{{end}}
|
||||
{{range .Finance.Benefits}}<li><b>Benefits ({{.Origin}})</b> {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}</li>{{end}}
|
||||
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
||||
</ul>
|
||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||
<h3>Ledger</h3>
|
||||
{{if .Finance.Ledger}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Finance.Ledger}}
|
||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Grant benefits</h2>
|
||||
{{if .Grant.Present}}
|
||||
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
||||
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
||||
<div><button type="submit">Grant</button></div>
|
||||
</form>
|
||||
{{if .Grant.Products}}
|
||||
<h3>Grant a product</h3>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
||||
<div><button type="submit">Grant product</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Roles</h2>
|
||||
{{$id := .ID}}
|
||||
{{if .Roles}}
|
||||
|
||||
@@ -149,7 +149,6 @@ type UserDetailView struct {
|
||||
TimeZone string
|
||||
Guest bool
|
||||
NotificationsInAppOnly bool
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge, or empty for a live account.
|
||||
MergedInto string
|
||||
@@ -165,14 +164,10 @@ type UserDetailView struct {
|
||||
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||
// empty for an unflagged account; the card shows it with the Clear action.
|
||||
FlaggedHighRateAt string
|
||||
HintBalance int
|
||||
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
||||
// server's maxHintGrant), passed through so the policy value lives in one place.
|
||||
HintGrantMax int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
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 bool
|
||||
Games []GameRow
|
||||
@@ -200,6 +195,55 @@ type UserDetailView struct {
|
||||
Blocks []RelationRow
|
||||
BlockedBy []RelationRow
|
||||
Friends []RelationRow
|
||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||
// is false when the payments domain is unwired.
|
||||
Finance FinanceView
|
||||
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||
// payments domain is unwired.
|
||||
Grant GrantFormView
|
||||
}
|
||||
|
||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
// (newest first). Present is false when the payments domain is unwired.
|
||||
type FinanceView struct {
|
||||
Present bool
|
||||
Segments []SegmentRow
|
||||
Benefits []BenefitRow
|
||||
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
||||
Abuse bool
|
||||
Loss int
|
||||
Ledger []LedgerRow
|
||||
}
|
||||
|
||||
// SegmentRow is one funding segment's chip balance.
|
||||
type SegmentRow struct {
|
||||
Source string
|
||||
Chips int
|
||||
}
|
||||
|
||||
// BenefitRow is one origin's benefit: the hint wallet, the ad-free expiry (pre-formatted, empty
|
||||
// when none) and the lifetime ad-free flag.
|
||||
type BenefitRow struct {
|
||||
Origin string
|
||||
Hints int
|
||||
AdsUntil string
|
||||
Forever bool
|
||||
}
|
||||
|
||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
||||
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
|
||||
// the pre-formatted time.
|
||||
type LedgerRow struct {
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
ChipsDelta int
|
||||
Product string
|
||||
Order string
|
||||
Provider string
|
||||
Snapshot string
|
||||
At string
|
||||
}
|
||||
|
||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||
@@ -612,3 +656,69 @@ type FeedbackDetailView struct {
|
||||
UserTZ string
|
||||
Banned bool
|
||||
}
|
||||
|
||||
// CatalogView is the product-catalog list page.
|
||||
type CatalogView struct {
|
||||
Products []ProductRow
|
||||
}
|
||||
|
||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||
// (Active) and the transacted flag (which forbids a hard delete).
|
||||
type ProductRow struct {
|
||||
ID string
|
||||
Title string
|
||||
Active bool
|
||||
Atoms []AtomRow
|
||||
Prices []PriceRow
|
||||
Transacted bool
|
||||
}
|
||||
|
||||
// AtomRow is one atom line of a product row.
|
||||
type AtomRow struct {
|
||||
Atom string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// PriceRow is one price of a product: the method ("" for a value's CHIP price), the currency, and
|
||||
// the amount in that currency's minor units.
|
||||
type PriceRow struct {
|
||||
Method string
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// ProductFormView is the product edit form, pre-filled from the current composition. Atom quantities
|
||||
// and prices are flattened to the fixed fields the form offers (0 = absent); Transacted disables the
|
||||
// delete action.
|
||||
type ProductFormView struct {
|
||||
ID string
|
||||
Title string
|
||||
Active bool
|
||||
Chips int
|
||||
Hints int
|
||||
NoAds int
|
||||
Tournament int
|
||||
PriceRUB int64
|
||||
PriceVote int64
|
||||
PriceStar int64
|
||||
PriceChip int64
|
||||
Transacted bool
|
||||
}
|
||||
|
||||
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
|
||||
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
|
||||
// products are excluded). Present is false when the payments domain is unwired.
|
||||
type GrantFormView struct {
|
||||
Present bool
|
||||
Origins []string
|
||||
Products []GrantProductOption
|
||||
}
|
||||
|
||||
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
|
||||
// summary, and whether it is archived (the common case for a non-public reward bundle).
|
||||
type GrantProductOption struct {
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Archived bool
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
@@ -64,6 +65,9 @@ type Config struct {
|
||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||
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.
|
||||
@@ -153,6 +157,13 @@ func Load() (Config, error) {
|
||||
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{
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||
@@ -170,6 +181,7 @@ func Load() (Config, error) {
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
Robokassa: robo,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
|
||||
@@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHintsRemaining(t *testing.T) {
|
||||
cases := []struct{ allowance, used, wallet, want int }{
|
||||
{1, 0, 3, 4},
|
||||
{1, 1, 3, 3},
|
||||
{1, 2, 3, 3}, // used past allowance clamps to 0
|
||||
{0, 0, 5, 5},
|
||||
{2, 1, 0, 1},
|
||||
cases := []struct{ allowance, used, want int }{
|
||||
{1, 0, 1},
|
||||
{1, 1, 0},
|
||||
{1, 2, 0}, // used past allowance clamps to 0
|
||||
{3, 1, 2},
|
||||
{0, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
|
||||
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
|
||||
if got := hintsRemaining(c.allowance, c.used); got != c.want {
|
||||
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,7 +1207,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
return HintResult{}, err
|
||||
}
|
||||
used++
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
|
||||
}
|
||||
|
||||
// Candidates returns the to-move player's legal plays for a seated player on
|
||||
@@ -1272,10 +1272,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
if !ok {
|
||||
return StateView{}, ErrNotAPlayer
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return StateView{}, err
|
||||
}
|
||||
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
@@ -1290,12 +1286,13 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
}
|
||||
}
|
||||
return StateView{
|
||||
Game: pre,
|
||||
Seat: seat,
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
Game: pre,
|
||||
Seat: seat,
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
|
||||
// (payments) and the client adds it (lib/hints.hintsLeft).
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
|
||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||
}, nil
|
||||
@@ -1776,10 +1773,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
|
||||
return svc.registry.DictBytes(variant, version)
|
||||
}
|
||||
|
||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||||
// allowance plus the profile wallet.
|
||||
func hintsRemaining(allowance, used, wallet int) int {
|
||||
return max(0, allowance-used) + wallet
|
||||
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
|
||||
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
|
||||
func hintsRemaining(allowance, used int) int {
|
||||
return max(0, allowance-used)
|
||||
}
|
||||
|
||||
// allowedTimeout reports whether d is one of the offered move clocks.
|
||||
|
||||
@@ -211,10 +211,9 @@ type MoveResult struct {
|
||||
BagLen int
|
||||
}
|
||||
|
||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
||||
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
|
||||
// the global wallet alone, so the client can keep its live wallet authoritative and
|
||||
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
|
||||
// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the
|
||||
// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts
|
||||
// WalletBalance into the profile so the badge stays live across games (lib/hints).
|
||||
type HintResult struct {
|
||||
Move engine.MoveRecord
|
||||
HintsRemaining int
|
||||
@@ -241,9 +240,6 @@ type StateView struct {
|
||||
Rack []string
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||
WalletBalance int
|
||||
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
||||
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// benefitFor returns the account's benefit on the given origin from its statement.
|
||||
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
|
||||
t.Helper()
|
||||
stmt, err := pay.AccountStatement(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
for _, b := range stmt.Benefits {
|
||||
if b.Origin == origin {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return payments.OriginBenefit{}
|
||||
}
|
||||
|
||||
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
|
||||
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
|
||||
func TestConsoleAdminGrant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
id := provisionAccount(t)
|
||||
const origin = "http://admin.test"
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
// CSRF: a grant without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("grant without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
|
||||
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
|
||||
}
|
||||
|
||||
// By-product grant: an archived reward bundle (3 hints) to vk.
|
||||
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("create reward: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
|
||||
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
|
||||
}
|
||||
|
||||
// A chips pack cannot be granted.
|
||||
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
|
||||
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create pack: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
|
||||
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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
|
||||
// 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) {
|
||||
|
||||
@@ -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 {
|
||||
Banner *struct {
|
||||
Campaigns []struct {
|
||||
@@ -203,6 +203,12 @@ type profileBanner struct {
|
||||
HoldMs int `json:"hold_ms"`
|
||||
} `json:"timings"`
|
||||
} `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
|
||||
@@ -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
|
||||
// banner block too, so the client's profile keeps the banner instead of losing it until reload.
|
||||
func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
@@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
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,97 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||
t.Helper()
|
||||
all, err := pay.AdminCatalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("admin catalog: %v", err)
|
||||
}
|
||||
for _, p := range all {
|
||||
if p.Title == title {
|
||||
return p
|
||||
}
|
||||
}
|
||||
t.Fatalf("product %q not found", title)
|
||||
return payments.AdminProduct{}
|
||||
}
|
||||
|
||||
// TestConsoleCatalogEditor drives the catalog editor end to end: create is CSRF-guarded; a value and
|
||||
// a pack are created and edited; an invalid active product is refused; archive hides it; a
|
||||
// never-transacted product deletes; a transacted product is refused deletion (archive only).
|
||||
func TestConsoleCatalogEditor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const catalog = "http://admin.test/_gm/catalog"
|
||||
|
||||
// CSRF: a create without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=x&hints=1&price_chip=10&active=true", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("create without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Create a value product: 5 hints for 50 chips, active.
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-hints-5&hints=5&price_chip=50&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
|
||||
t.Fatalf("create value = %d, has 'Created' = %v", code, strings.Contains(body, "Created"))
|
||||
}
|
||||
val := productByTitle(t, pay, "cat-hints-5")
|
||||
if !val.Active || len(val.Atoms) != 1 || val.Atoms[0].Atom != "hints" || val.Atoms[0].Quantity != 5 {
|
||||
t.Fatalf("created value = %+v", val)
|
||||
}
|
||||
|
||||
// An invalid active pack (chips, no money price) is refused.
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||
}
|
||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
|
||||
// Edit the value: raise to 8 hints.
|
||||
base := catalog + "/" + val.ID.String()
|
||||
if code, _ := consoleDo(h, http.MethodPost, base, "title=cat-hints-8&hints=8&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||
t.Fatalf("edit = %d", code)
|
||||
}
|
||||
if got := productByTitle(t, pay, "cat-hints-8"); len(got.Atoms) != 1 || got.Atoms[0].Quantity != 8 {
|
||||
t.Fatalf("after edit atoms = %+v, want 8 hints", got.Atoms)
|
||||
}
|
||||
|
||||
// Archive it → hidden from the storefront (the user Catalog filters active).
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/archive", "active=false", origin); code != http.StatusOK {
|
||||
t.Fatalf("archive = %d", code)
|
||||
}
|
||||
if productByTitle(t, pay, "cat-hints-8").Active {
|
||||
t.Error("product still active after archive")
|
||||
}
|
||||
|
||||
// Delete the never-transacted product.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
|
||||
t.Fatalf("delete clean = %d, has 'Deleted' = %v", code, strings.Contains(body, "Deleted"))
|
||||
}
|
||||
|
||||
// A transacted product cannot be deleted — only archived.
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=cat-pack&chips=100&price_rub=14900&active=true", origin); code != http.StatusOK {
|
||||
t.Fatal("create pack failed")
|
||||
}
|
||||
pack := productByTitle(t, pay, "cat-pack")
|
||||
if _, err := pay.CreateOrder(ctx, uuid.New(), payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, pack.ID, "robokassa"); err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog+"/"+pack.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Cannot delete") {
|
||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||
}
|
||||
}
|
||||
@@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) {
|
||||
t.Fatalf("first hint: %v", err)
|
||||
}
|
||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
||||
// per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile).
|
||||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||
if err != nil {
|
||||
t.Fatalf("state: %v", err)
|
||||
}
|
||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
||||
if st.HintsRemaining != 0 {
|
||||
t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining)
|
||||
}
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||
@@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("wallet hint: %v", err)
|
||||
}
|
||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
||||
// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
|
||||
// 2->1 and WalletBalance carries it (the client adopts it into the profile).
|
||||
if res.HintsRemaining != 0 || res.WalletBalance != 1 {
|
||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
|
||||
}
|
||||
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
|
||||
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// TestAccountStatement checks the admin financial statement: a funded pack shows as a chip segment
|
||||
// + a fund ledger row, an admin grant as a benefit + an admin_grant row, the ledger is newest-first,
|
||||
// and a clean account carries no refund risk.
|
||||
func TestAccountStatement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
|
||||
// A funded pack: 100 chips to the direct segment + a fund ledger row.
|
||||
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)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
// An admin grant: 5 hints to the direct origin + an admin_grant ledger row.
|
||||
if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, false); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
|
||||
stmt, err := svc.AccountStatement(ctx, acc)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
|
||||
if len(stmt.Segments) != 1 || stmt.Segments[0].Source != payments.SourceDirect || stmt.Segments[0].Chips != 100 {
|
||||
t.Fatalf("segments = %+v, want 100 chips on direct", stmt.Segments)
|
||||
}
|
||||
if len(stmt.Benefits) != 1 || stmt.Benefits[0].Origin != payments.SourceDirect || stmt.Benefits[0].Hints != 5 {
|
||||
t.Fatalf("benefits = %+v, want 5 hints on direct", stmt.Benefits)
|
||||
}
|
||||
if len(stmt.Ledger) != 2 {
|
||||
t.Fatalf("ledger rows = %d, want 2 (fund + admin_grant)", len(stmt.Ledger))
|
||||
}
|
||||
// Newest-first ordering (the grant is the later write).
|
||||
if stmt.Ledger[0].CreatedAt.Before(stmt.Ledger[1].CreatedAt) {
|
||||
t.Error("ledger is not newest-first")
|
||||
}
|
||||
kinds := map[string]int{}
|
||||
for _, e := range stmt.Ledger {
|
||||
kinds[e.Kind]++
|
||||
if e.Kind == "fund" && e.ChipsDelta != 100 {
|
||||
t.Errorf("fund chips delta = %d, want 100", e.ChipsDelta)
|
||||
}
|
||||
}
|
||||
if kinds["fund"] != 1 || kinds["admin_grant"] != 1 {
|
||||
t.Fatalf("ledger kinds = %v, want one fund + one admin_grant", kinds)
|
||||
}
|
||||
if stmt.Risk.Present {
|
||||
t.Errorf("risk = %+v, want none for a clean account", stmt.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
|
||||
// grant surface as the segment balance, the benefit and the ledger rows.
|
||||
func TestConsoleFinancePanel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
id := provisionAccount(t)
|
||||
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
if err := pay.Grant(ctx, id, payments.SourceDirect, 5, 0, false); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
|
||||
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/users/"+id.String(), "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("user card = %d, want 200", code)
|
||||
}
|
||||
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("finance panel missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
Alphabet: alphabet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ const (
|
||||
// (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.
|
||||
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,
|
||||
// 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
|
||||
|
||||
@@ -56,7 +56,6 @@ type PlayerState struct {
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
Alphabet []AlphabetLetter
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AdminProduct is one catalog product for the admin editor: its full composition, every price, the
|
||||
// archived flag (Active), and whether it has ever been transacted — which forbids a hard delete
|
||||
// (orders / ledger rows reference it, and the ledger is append-only).
|
||||
type AdminProduct struct {
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
Active bool
|
||||
Atoms []AtomLine
|
||||
Prices []PriceLine
|
||||
Transacted bool
|
||||
}
|
||||
|
||||
// AtomLine is one atom of a product: the atom type and its positive quantity.
|
||||
type AtomLine struct {
|
||||
Atom string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// PriceLine is one price of a product: the payment method ("" for a value's CHIP price, which is
|
||||
// stored with a NULL method), the currency, and the amount in that currency's minor units.
|
||||
type PriceLine struct {
|
||||
Method string
|
||||
Currency Currency
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// ProductInput is the editable content of a product: its title, atom composition and prices.
|
||||
type ProductInput struct {
|
||||
Title string
|
||||
Atoms []AtomLine
|
||||
Prices []PriceLine
|
||||
}
|
||||
|
||||
// knownAtoms is the fixed atom set, mirroring the catalog_atom seed / CHECK.
|
||||
var knownAtoms = map[string]bool{atomChips: true, "hints": true, "noads_days": true, "tournament": true}
|
||||
|
||||
// validCurrency reports whether c is one of the four catalog currencies.
|
||||
func validCurrency(c Currency) bool {
|
||||
switch c {
|
||||
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateProduct checks a product's composition and prices. When sellable is true (the product is
|
||||
// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money
|
||||
// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no
|
||||
// money price). The tournament atom is never sellable (its economy is the tournament stage), so an
|
||||
// active product may not carry it; an archived draft may (a template for later) and skips the shape
|
||||
// check.
|
||||
func validateProduct(in ProductInput, sellable bool) error {
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return errors.New("product title is required")
|
||||
}
|
||||
if len(in.Atoms) == 0 {
|
||||
return errors.New("product needs at least one atom")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
hasChips, hasTournament, hasBenefit := false, false, false
|
||||
for _, a := range in.Atoms {
|
||||
if !knownAtoms[a.Atom] {
|
||||
return fmt.Errorf("unknown atom %q", a.Atom)
|
||||
}
|
||||
if seen[a.Atom] {
|
||||
return fmt.Errorf("duplicate atom %q", a.Atom)
|
||||
}
|
||||
seen[a.Atom] = true
|
||||
if a.Quantity <= 0 {
|
||||
return fmt.Errorf("atom %q quantity must be positive", a.Atom)
|
||||
}
|
||||
switch a.Atom {
|
||||
case atomChips:
|
||||
hasChips = true
|
||||
case "tournament":
|
||||
hasTournament = true
|
||||
default:
|
||||
hasBenefit = true
|
||||
}
|
||||
}
|
||||
|
||||
priceSeen := map[string]bool{}
|
||||
hasChipPrice, hasMoneyPrice := false, false
|
||||
for _, p := range in.Prices {
|
||||
if p.Method != "" && p.Method != string(SourceVK) && p.Method != string(SourceTelegram) && p.Method != string(SourceDirect) {
|
||||
return fmt.Errorf("invalid price method %q", p.Method)
|
||||
}
|
||||
if !validCurrency(p.Currency) {
|
||||
return fmt.Errorf("invalid currency %q", p.Currency)
|
||||
}
|
||||
if p.Amount < 0 {
|
||||
return errors.New("price amount must be non-negative")
|
||||
}
|
||||
if p.Currency == CurrencyChip && p.Method != "" {
|
||||
return errors.New("a CHIP price must have no method")
|
||||
}
|
||||
if p.Currency != CurrencyChip && p.Method == "" {
|
||||
return fmt.Errorf("a %s price needs a payment method", p.Currency)
|
||||
}
|
||||
key := p.Method + "|" + string(p.Currency)
|
||||
if priceSeen[key] {
|
||||
return fmt.Errorf("duplicate price (%s, %s)", p.Method, p.Currency)
|
||||
}
|
||||
priceSeen[key] = true
|
||||
if p.Currency == CurrencyChip {
|
||||
hasChipPrice = true
|
||||
} else {
|
||||
hasMoneyPrice = true
|
||||
}
|
||||
}
|
||||
|
||||
if !sellable {
|
||||
return nil // an archived draft may be incomplete
|
||||
}
|
||||
if hasTournament {
|
||||
return errors.New("a product carrying the tournament atom cannot be sold yet")
|
||||
}
|
||||
if hasChips {
|
||||
if hasBenefit {
|
||||
return errors.New("a chip pack must contain only the chips atom")
|
||||
}
|
||||
if !hasMoneyPrice || hasChipPrice {
|
||||
return errors.New("a chip pack needs a money price per method and no CHIP price")
|
||||
}
|
||||
} else {
|
||||
if !hasChipPrice || hasMoneyPrice {
|
||||
return errors.New("a value needs a single CHIP price and no money price")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx)
|
||||
}
|
||||
|
||||
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
||||
// product created active must satisfy the sellable shape.
|
||||
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return s.store.createProduct(ctx, in, active, s.clock())
|
||||
}
|
||||
|
||||
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
||||
// satisfy the sellable shape.
|
||||
func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInput) (err error) {
|
||||
active, err := s.store.productActive(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.updateProduct(ctx, id, in, s.clock())
|
||||
}
|
||||
|
||||
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
||||
// sellable shape, so a draft or a tournament product cannot be put on sale.
|
||||
func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active bool) error {
|
||||
if active {
|
||||
in, err := s.store.productInput(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateProduct(in, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.store.setProductActive(ctx, id, active, s.clock())
|
||||
}
|
||||
|
||||
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
||||
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
||||
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||
return s.store.deleteProduct(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package payments
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateProduct(t *testing.T) {
|
||||
pack := ProductInput{
|
||||
Title: "100 chips",
|
||||
Atoms: []AtomLine{{Atom: "chips", Quantity: 100}},
|
||||
Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 14900}},
|
||||
}
|
||||
value := ProductInput{
|
||||
Title: "5 hints",
|
||||
Atoms: []AtomLine{{Atom: "hints", Quantity: 5}},
|
||||
Prices: []PriceLine{{Method: "", Currency: CurrencyChip, Amount: 50}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in ProductInput
|
||||
sellable bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid pack", pack, true, false},
|
||||
{"valid value", value, true, false},
|
||||
{"pack with a benefit atom", ProductInput{Title: "x", Atoms: []AtomLine{{"chips", 100}, {"hints", 5}}, Prices: pack.Prices}, true, true},
|
||||
{"pack without money price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: value.Prices}, true, true},
|
||||
{"value without chip price", ProductInput{Title: "x", Atoms: value.Atoms, Prices: pack.Prices}, true, true},
|
||||
{"tournament sellable is refused", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}, Prices: value.Prices}, true, true},
|
||||
{"tournament draft is allowed", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}}, false, false},
|
||||
{"unknown atom", ProductInput{Title: "x", Atoms: []AtomLine{{"gold", 1}}}, false, true},
|
||||
{"duplicate atom", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 1}, {"hints", 2}}}, false, true},
|
||||
{"non-positive quantity", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 0}}}, false, true},
|
||||
{"chip price with a method", ProductInput{Title: "x", Atoms: value.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyChip, Amount: 50}}}, true, true},
|
||||
{"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true},
|
||||
{"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true},
|
||||
{"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true},
|
||||
{"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateProduct(tt.in, tt.sellable)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateProduct = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,10 @@ func NewContext(kind, subtype string) Context {
|
||||
// every spend/purchase and the application of any foreign origin.
|
||||
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
|
||||
// subtype. A previously bought benefit still applies there, but no spend or purchase is possible.
|
||||
// vkFrozen reports whether this is the VK-iOS purchase freeze: VK context on the trusted iOS
|
||||
// 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 }
|
||||
|
||||
// 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
|
||||
// order, restricted to the sources the account actually has (present). It is empty when the
|
||||
// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named
|
||||
// segment is spendable; on web/native all attached segments are, drained direct→vk→tg.
|
||||
// order, restricted to the sources the account actually has (present). It is empty only when the
|
||||
// platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so
|
||||
// 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 {
|
||||
if !c.Trusted() || c.vkFrozen() {
|
||||
if !c.Trusted() {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is
|
||||
// NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG
|
||||
// only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a
|
||||
// store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg.
|
||||
// order, restricted to present sources. It mirrors spendableSources (both gate only on a trusted
|
||||
// platform now that the VK-iOS freeze is purchase-only). Inside VK/TG only the same-named origin
|
||||
// applies (a foreign, e.g. direct, origin never activates inside a store — the compliance wall);
|
||||
// on web/native direct+vk+tg all apply, drained direct→vk→tg.
|
||||
func applicableOrigins(c Context, present []Source) []Source {
|
||||
if !c.Trusted() {
|
||||
return nil
|
||||
|
||||
@@ -16,7 +16,8 @@ func TestSpendableSources(t *testing.T) {
|
||||
want []Source
|
||||
}{
|
||||
{"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},
|
||||
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
|
||||
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
|
||||
@@ -41,8 +42,8 @@ func TestApplicableOrigins(t *testing.T) {
|
||||
present []Source
|
||||
want []Source
|
||||
}{
|
||||
// A benefit still APPLIES on VK-iOS while spending is frozen.
|
||||
{"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
|
||||
// A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only).
|
||||
{"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
|
||||
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
|
||||
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{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
|
||||
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
|
||||
func (m Money) String() string {
|
||||
// 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", "250") — the form a provider's amount
|
||||
// field (Robokassa OutSum) takes.
|
||||
func (m Money) Major() string {
|
||||
scale := m.currency.minorPerUnit()
|
||||
if scale == 1 {
|
||||
return fmt.Sprintf("%d %s", m.minor, m.currency)
|
||||
return fmt.Sprintf("%d", m.minor)
|
||||
}
|
||||
neg := m.minor < 0
|
||||
abs := m.minor
|
||||
@@ -167,5 +168,10 @@ func (m Money) String() string {
|
||||
if neg {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -156,6 +156,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source,
|
||||
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
|
||||
}
|
||||
|
||||
// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
|
||||
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
|
||||
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
|
||||
// yet), and one whose atoms yield no grantable benefit.
|
||||
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
|
||||
if !origin.Valid() {
|
||||
return fmt.Errorf("payments: invalid grant origin %q", origin)
|
||||
}
|
||||
in, err := s.store.productInput(ctx, productID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var d benefitDelta
|
||||
for _, a := range in.Atoms {
|
||||
switch a.Atom {
|
||||
case "hints":
|
||||
d.hintsAdd += a.Quantity
|
||||
case "noads_days":
|
||||
d.noAdsDays += a.Quantity
|
||||
case atomChips:
|
||||
return ErrCannotGrantChips
|
||||
case "tournament":
|
||||
return ErrCannotGrantTournament
|
||||
}
|
||||
}
|
||||
if d.zero() {
|
||||
return ErrNothingToGrant
|
||||
}
|
||||
snapshot, err := marshalGrantProduct(productID, in.Title, d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock())
|
||||
}
|
||||
|
||||
// MergeTx merges the secondary account's segments and benefits into the primary inside the
|
||||
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
||||
// after committing (Invalidate).
|
||||
@@ -244,3 +279,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) {
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
|
||||
// benefit atoms it granted (price 0).
|
||||
func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) {
|
||||
atoms := map[string]int{}
|
||||
if d.hintsAdd > 0 {
|
||||
atoms["hints"] = d.hintsAdd
|
||||
}
|
||||
if d.noAdsDays > 0 {
|
||||
atoms["noads_days"] = d.noAdsDays
|
||||
}
|
||||
b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
@@ -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,63 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Statement is an account's full financial picture for the admin console: chip balances per funding
|
||||
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
// (newest first). Read straight from the materialized tables + the ledger, uncached — an admin-only,
|
||||
// rare view, not a hot path.
|
||||
type Statement struct {
|
||||
Segments []SegmentChips
|
||||
Benefits []OriginBenefit
|
||||
Risk RiskInfo
|
||||
Ledger []LedgerEntry
|
||||
}
|
||||
|
||||
// SegmentChips is one funding segment's chip balance.
|
||||
type SegmentChips struct {
|
||||
Source Source
|
||||
Chips int
|
||||
}
|
||||
|
||||
// OriginBenefit is one origin's benefit state: the hint wallet, the ad-free term (zero AdsPaidUntil
|
||||
// when none) and the lifetime ad-free flag.
|
||||
type OriginBenefit struct {
|
||||
Origin Source
|
||||
Hints int
|
||||
AdsPaidUntil time.Time
|
||||
AdsForever bool
|
||||
}
|
||||
|
||||
// RiskInfo is the account's recorded refund risk: whether it is abuse-flagged and the unrecoverable
|
||||
// chip loss accumulated by floor-0 refunds. Present is false when the account has no risk row.
|
||||
type RiskInfo struct {
|
||||
Present bool
|
||||
Abuse bool
|
||||
LossChips int
|
||||
}
|
||||
|
||||
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
|
||||
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
|
||||
type LedgerEntry struct {
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
ChipsDelta int
|
||||
ProductID string
|
||||
OrderID string
|
||||
Provider string
|
||||
ProviderPaymentID string
|
||||
Snapshot string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AccountStatement assembles the account's financial picture (segments, benefits, risk, full ledger
|
||||
// history) for the admin console, straight from the materialized tables and the ledger (uncached).
|
||||
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
||||
return s.store.accountStatement(ctx, accountID)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// ErrProductTransacted is returned when a hard delete is attempted on a product an order or ledger
|
||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||
|
||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
var prods []model.Product
|
||||
if err := postgres.SELECT(table.Product.AllColumns).
|
||||
FROM(table.Product).
|
||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||
QueryContext(ctx, s.db, &prods); err != nil {
|
||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||
}
|
||||
out := make([]AdminProduct, 0, len(prods))
|
||||
for _, p := range prods {
|
||||
atoms, prices, err := s.productComposition(ctx, p.ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transacted, err := s.productTransacted(ctx, p.ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AdminProduct{
|
||||
ID: p.ProductID, Title: p.Title, Active: p.Active,
|
||||
Atoms: atoms, Prices: prices, Transacted: transacted,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// productComposition reads one product's atom lines and price rows.
|
||||
func (s *Store) productComposition(ctx context.Context, id uuid.UUID) ([]AtomLine, []PriceLine, error) {
|
||||
var items []model.ProductItem
|
||||
if err := postgres.SELECT(table.ProductItem.AllColumns).
|
||||
FROM(table.ProductItem).
|
||||
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(id))).
|
||||
ORDER_BY(table.ProductItem.AtomType.ASC()).
|
||||
QueryContext(ctx, s.db, &items); err != nil {
|
||||
return nil, nil, fmt.Errorf("payments: load items %s: %w", id, err)
|
||||
}
|
||||
atoms := make([]AtomLine, len(items))
|
||||
for i, it := range items {
|
||||
atoms[i] = AtomLine{Atom: it.AtomType, Quantity: int(it.Quantity)}
|
||||
}
|
||||
var prs []model.ProductPrice
|
||||
if err := postgres.SELECT(table.ProductPrice.AllColumns).
|
||||
FROM(table.ProductPrice).
|
||||
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(id))).
|
||||
QueryContext(ctx, s.db, &prs); err != nil {
|
||||
return nil, nil, fmt.Errorf("payments: load prices %s: %w", id, err)
|
||||
}
|
||||
prices := make([]PriceLine, len(prs))
|
||||
for i, pr := range prs {
|
||||
method := ""
|
||||
if pr.Method != nil {
|
||||
method = *pr.Method
|
||||
}
|
||||
prices[i] = PriceLine{Method: method, Currency: Currency(pr.Currency), Amount: pr.Amount}
|
||||
}
|
||||
return atoms, prices, nil
|
||||
}
|
||||
|
||||
// productTransacted reports whether any order or ledger row references the product.
|
||||
func (s *Store) productTransacted(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var yes bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM payments.orders WHERE product_id=$1)
|
||||
OR EXISTS(SELECT 1 FROM payments.ledger WHERE product_id=$1)`, id).Scan(&yes); err != nil {
|
||||
return false, fmt.Errorf("payments: product transacted %s: %w", id, err)
|
||||
}
|
||||
return yes, nil
|
||||
}
|
||||
|
||||
// productActive reads a product's archived flag, ErrProductNotFound when it is missing.
|
||||
func (s *Store) productActive(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var p model.Product
|
||||
err := postgres.SELECT(table.Product.Active).FROM(table.Product).
|
||||
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
|
||||
QueryContext(ctx, s.db, &p)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return false, ErrProductNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("payments: product active %s: %w", id, err)
|
||||
}
|
||||
return p.Active, nil
|
||||
}
|
||||
|
||||
// productInput reads a product's current editable content (title, atoms, prices).
|
||||
func (s *Store) productInput(ctx context.Context, id uuid.UUID) (ProductInput, error) {
|
||||
var p model.Product
|
||||
err := postgres.SELECT(table.Product.AllColumns).FROM(table.Product).
|
||||
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
|
||||
QueryContext(ctx, s.db, &p)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ProductInput{}, ErrProductNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ProductInput{}, fmt.Errorf("payments: product input %s: %w", id, err)
|
||||
}
|
||||
atoms, prices, err := s.productComposition(ctx, id)
|
||||
if err != nil {
|
||||
return ProductInput{}, err
|
||||
}
|
||||
return ProductInput{Title: p.Title, Atoms: atoms, Prices: prices}, nil
|
||||
}
|
||||
|
||||
// createProduct inserts a product with its atoms and prices in one transaction, returning its id.
|
||||
func (s *Store) createProduct(ctx context.Context, in ProductInput, active bool, now time.Time) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
if err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.product (product_id, title, active, created_at, updated_at) VALUES ($1,$2,$3,$4,$4)`,
|
||||
id, in.Title, active, now); err != nil {
|
||||
return fmt.Errorf("insert product: %w", err)
|
||||
}
|
||||
return insertComposition(ctx, tx, id, in)
|
||||
}); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("payments: create product: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// updateProduct replaces a product's title, atoms and prices in one transaction (active unchanged).
|
||||
func (s *Store) updateProduct(ctx context.Context, id uuid.UUID, in ProductInput, now time.Time) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE payments.product SET title=$2, updated_at=$3 WHERE product_id=$1`, id, in.Title, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: update product %s: %w", id, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_item WHERE product_id=$1`, id); err != nil {
|
||||
return fmt.Errorf("payments: clear items %s: %w", id, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_price WHERE product_id=$1`, id); err != nil {
|
||||
return fmt.Errorf("payments: clear prices %s: %w", id, err)
|
||||
}
|
||||
return insertComposition(ctx, tx, id, in)
|
||||
})
|
||||
}
|
||||
|
||||
// insertComposition inserts a product's atom items and price rows inside tx (a value's CHIP price
|
||||
// carries a NULL method).
|
||||
func insertComposition(ctx context.Context, tx *sql.Tx, id uuid.UUID, in ProductInput) error {
|
||||
for _, a := range in.Atoms {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,$2,$3)`,
|
||||
id, a.Atom, a.Quantity); err != nil {
|
||||
return fmt.Errorf("insert item %s: %w", a.Atom, err)
|
||||
}
|
||||
}
|
||||
for _, p := range in.Prices {
|
||||
var method any
|
||||
if p.Method != "" {
|
||||
method = p.Method
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
|
||||
id, method, string(p.Currency), p.Amount); err != nil {
|
||||
return fmt.Errorf("insert price %s: %w", p.Currency, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setProductActive flips the archived flag.
|
||||
func (s *Store) setProductActive(ctx context.Context, id uuid.UUID, active bool, now time.Time) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE payments.product SET active=$2, updated_at=$3 WHERE product_id=$1`, id, active, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: set product active %s: %w", id, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteProduct hard-deletes a never-transacted product (its items/prices cascade); a transacted
|
||||
// product is refused with ErrProductTransacted.
|
||||
func (s *Store) deleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||
transacted, err := s.productTransacted(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if transacted {
|
||||
return ErrProductTransacted
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM payments.product WHERE product_id=$1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: delete product %s: %w", id, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// statementOrder is the fixed segment/origin ordering the report renders, so the panel is stable
|
||||
// (the balance/benefit maps iterate randomly).
|
||||
var statementOrder = []Source{SourceDirect, SourceVK, SourceTelegram}
|
||||
|
||||
// accountStatement reads the account's balances, benefits, refund risk and full ledger history for
|
||||
// the admin report. Uncached and outside any transaction — an operator view, not a hot path.
|
||||
func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
||||
st, err := s.loadState(ctx, accountID)
|
||||
if err != nil {
|
||||
return Statement{}, err
|
||||
}
|
||||
var out Statement
|
||||
for _, src := range statementOrder {
|
||||
if chips, ok := st.chips[src]; ok {
|
||||
out.Segments = append(out.Segments, SegmentChips{Source: src, Chips: chips})
|
||||
}
|
||||
if b, ok := st.benefits[src]; ok {
|
||||
out.Benefits = append(out.Benefits, OriginBenefit{
|
||||
Origin: src, Hints: b.hints, AdsPaidUntil: derefTime(b.adsPaidUntil), AdsForever: b.adsForever,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var risk model.AccountRisk
|
||||
err = postgres.SELECT(table.AccountRisk.AllColumns).
|
||||
FROM(table.AccountRisk).
|
||||
WHERE(table.AccountRisk.AccountID.EQ(postgres.UUID(accountID))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &risk)
|
||||
switch {
|
||||
case err == nil:
|
||||
out.Risk = RiskInfo{Present: true, Abuse: risk.Abuse, LossChips: int(risk.LossChips)}
|
||||
case errors.Is(err, qrm.ErrNoRows):
|
||||
// no risk row — a clean account
|
||||
default:
|
||||
return Statement{}, fmt.Errorf("payments: load risk %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
var rows []model.Ledger
|
||||
if err := postgres.SELECT(table.Ledger.AllColumns).
|
||||
FROM(table.Ledger).
|
||||
WHERE(table.Ledger.AccountID.EQ(postgres.UUID(accountID))).
|
||||
ORDER_BY(table.Ledger.CreatedAt.DESC()).
|
||||
QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return Statement{}, fmt.Errorf("payments: load ledger %s: %w", accountID, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
out.Ledger = append(out.Ledger, LedgerEntry{
|
||||
Kind: r.Kind,
|
||||
Source: derefStr(r.Source),
|
||||
Origin: derefStr(r.Origin),
|
||||
ChipsDelta: int(r.ChipsDelta),
|
||||
ProductID: derefUUID(r.ProductID),
|
||||
OrderID: derefUUID(r.OrderID),
|
||||
Provider: derefStr(r.Provider),
|
||||
ProviderPaymentID: derefStr(r.ProviderPaymentID),
|
||||
Snapshot: derefStr(r.Snapshot),
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
|
||||
func derefStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// derefUUID renders the pointed-to UUID as a string, or "" when the pointer is nil.
|
||||
func derefUUID(p *uuid.UUID) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.String()
|
||||
}
|
||||
|
||||
// derefTime returns the pointed-to time, or the zero time when the pointer is nil (a NULL column).
|
||||
func derefTime(p *time.Time) time.Time {
|
||||
if p == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -26,6 +26,14 @@ var (
|
||||
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
||||
// cannot be bought with chips.
|
||||
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
||||
// ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the
|
||||
// admin never grants currency (a gifted balance would bypass the cash desk, D16).
|
||||
ErrCannotGrantChips = errors.New("payments: cannot grant chips")
|
||||
// ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom,
|
||||
// which has no credit target until the tournament stage.
|
||||
ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet")
|
||||
// ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days).
|
||||
ErrNothingToGrant = errors.New("payments: product has nothing to grant")
|
||||
)
|
||||
|
||||
// withTx runs fn inside a transaction on db, rolling back on error or panic.
|
||||
@@ -230,8 +238,11 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertLedgerTx appends one append-only ledger row inside tx.
|
||||
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 {
|
||||
// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
|
||||
// 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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: ledger id: %w", err)
|
||||
@@ -246,11 +257,13 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s
|
||||
stmt := table.Ledger.INSERT(
|
||||
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
|
||||
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(
|
||||
id, accountID, kind,
|
||||
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 {
|
||||
return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
|
||||
@@ -278,7 +291,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -295,7 +308,23 @@ 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.
|
||||
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 {
|
||||
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 applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.cache.invalidate(accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
|
||||
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
|
||||
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||
@@ -419,3 +448,11 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression {
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable {
|
||||
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
|
||||
CooldownHintSeconds 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
|
||||
CooldownHintSeconds postgres.ColumnInteger
|
||||
OrderTTLSeconds postgres.ColumnInteger
|
||||
RewardDailyCap postgres.ColumnInteger
|
||||
RewardHourlyCap postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
|
||||
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
|
||||
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
|
||||
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
|
||||
RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
|
||||
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{
|
||||
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
|
||||
CooldownHintSeconds: CooldownHintSecondsColumn,
|
||||
OrderTTLSeconds: OrderTTLSecondsColumn,
|
||||
RewardDailyCap: RewardDailyCapColumn,
|
||||
RewardHourlyCap: RewardHourlyCapColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
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
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
AccountRisk = AccountRisk.FromSchema(schema)
|
||||
Balances = Balances.FromSchema(schema)
|
||||
Benefits = Benefits.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 {
|
||||
r := profileResponseFor(acc)
|
||||
// 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
|
||||
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy
|
||||
// value from profileResponseFor (zeroed in production) stands.
|
||||
// the hint count and the banner. The profile hint balance comes from the payments benefit
|
||||
// (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any
|
||||
// failure the fallback from profileResponseFor is a plain 0.
|
||||
cxt, present, err := s.walletGate(ctx, acc.ID)
|
||||
if err != nil {
|
||||
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.Ads = s.adsFor(ctx, acc, cxt, present)
|
||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||
r.DictVersions = s.currentDictVersions()
|
||||
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,
|
||||
// projecting each optional colour set into its three "#rrggbb" fields (empty when
|
||||
// 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
|
||||
// no_banner role), absent otherwise. See banner.go.
|
||||
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
|
||||
// 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
|
||||
@@ -172,7 +177,6 @@ type stateDTO struct {
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||
@@ -218,13 +222,15 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
||||
// profileResponseFor projects an account into its profile DTO.
|
||||
func profileResponseFor(acc account.Account) profileResponse {
|
||||
return profileResponse{
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
HintBalance: acc.HintBalance,
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
// 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,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
@@ -327,7 +333,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||
}
|
||||
if includeAlphabet {
|
||||
|
||||
@@ -72,6 +72,23 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/wallet", s.handleWallet)
|
||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||
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 {
|
||||
// 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"
|
||||
case errors.Is(err, payments.ErrNotAValue):
|
||||
return http.StatusBadRequest, "not_a_value"
|
||||
case errors.Is(err, payments.ErrNotAPack):
|
||||
return http.StatusBadRequest, "not_a_pack"
|
||||
case errors.Is(err, account.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "invalid_email"
|
||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/adminconsole"
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// catalogBack is the product-list path the catalog console actions return to.
|
||||
const catalogBack = "/_gm/catalog"
|
||||
|
||||
// atomField pairs a form field with its atom type; priceField pairs a form field with the payment
|
||||
// method + currency it prices, following the one-currency-per-rail mapping (direct→RUB, vk→VOTE,
|
||||
// telegram→XTR) plus the value's CHIP price (no method).
|
||||
var atomFields = []struct{ field, atom string }{
|
||||
{"chips", "chips"}, {"hints", "hints"}, {"noads", "noads_days"}, {"tournament", "tournament"},
|
||||
}
|
||||
var priceFields = []struct {
|
||||
field, method string
|
||||
currency payments.Currency
|
||||
}{
|
||||
{"price_rub", "direct", payments.CurrencyRUB},
|
||||
{"price_vote", "vk", payments.CurrencyVote},
|
||||
{"price_star", "telegram", payments.CurrencyStar},
|
||||
{"price_chip", "", payments.CurrencyChip},
|
||||
}
|
||||
|
||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
||||
// flag, and the inline create form.
|
||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
var view adminconsole.CatalogView
|
||||
for _, p := range products {
|
||||
view.Products = append(view.Products, catalogRow(p))
|
||||
}
|
||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||
}
|
||||
|
||||
// catalogRow projects a product into its list row.
|
||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||
for _, a := range p.Atoms {
|
||||
row.Atoms = append(row.Atoms, adminconsole.AtomRow{Atom: a.Atom, Quantity: a.Quantity})
|
||||
}
|
||||
for _, pr := range p.Prices {
|
||||
row.Prices = append(row.Prices, adminconsole.PriceRow{Method: pr.Method, Currency: string(pr.Currency), Amount: pr.Amount})
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// consoleCatalogDetail renders one product's edit form, pre-filled from its current composition.
|
||||
func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
for _, p := range products {
|
||||
if p.ID == id {
|
||||
s.renderConsole(c, "product_detail", "catalog", p.Title, productForm(p))
|
||||
return
|
||||
}
|
||||
}
|
||||
s.renderConsoleMessage(c, "Not found", "no such product", catalogBack)
|
||||
}
|
||||
|
||||
// productForm builds the pre-filled edit form for a product.
|
||||
func productForm(p payments.AdminProduct) adminconsole.ProductFormView {
|
||||
fv := adminconsole.ProductFormView{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||
for _, a := range p.Atoms {
|
||||
switch a.Atom {
|
||||
case "chips":
|
||||
fv.Chips = a.Quantity
|
||||
case "hints":
|
||||
fv.Hints = a.Quantity
|
||||
case "noads_days":
|
||||
fv.NoAds = a.Quantity
|
||||
case "tournament":
|
||||
fv.Tournament = a.Quantity
|
||||
}
|
||||
}
|
||||
for _, pr := range p.Prices {
|
||||
switch pr.Currency {
|
||||
case payments.CurrencyRUB:
|
||||
fv.PriceRUB = pr.Amount
|
||||
case payments.CurrencyVote:
|
||||
fv.PriceVote = pr.Amount
|
||||
case payments.CurrencyStar:
|
||||
fv.PriceStar = pr.Amount
|
||||
case payments.CurrencyChip:
|
||||
fv.PriceChip = pr.Amount
|
||||
}
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// parseProductForm reads a product's title, atoms, prices and active flag from the submitted form.
|
||||
func parseProductForm(c *gin.Context) (payments.ProductInput, bool) {
|
||||
in := payments.ProductInput{Title: strings.TrimSpace(c.PostForm("title"))}
|
||||
for _, a := range atomFields {
|
||||
if q, err := strconv.Atoi(strings.TrimSpace(c.PostForm(a.field))); err == nil && q > 0 {
|
||||
in.Atoms = append(in.Atoms, payments.AtomLine{Atom: a.atom, Quantity: q})
|
||||
}
|
||||
}
|
||||
for _, p := range priceFields {
|
||||
if amt, err := strconv.ParseInt(strings.TrimSpace(c.PostForm(p.field)), 10, 64); err == nil && amt > 0 {
|
||||
in.Prices = append(in.Prices, payments.PriceLine{Method: p.method, Currency: p.currency, Amount: amt})
|
||||
}
|
||||
}
|
||||
return in, c.PostForm("active") != ""
|
||||
}
|
||||
|
||||
// consoleCreateProduct validates and inserts a new product from the create form.
|
||||
func (s *Server) consoleCreateProduct(c *gin.Context) {
|
||||
in, active := parseProductForm(c)
|
||||
if _, err := s.payments.CreateProduct(c.Request.Context(), in, active); err != nil {
|
||||
s.renderConsoleMessage(c, "Invalid product", err.Error(), catalogBack)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Created", "the product was created", catalogBack)
|
||||
}
|
||||
|
||||
// consoleUpdateProduct validates and replaces a product's title, atoms and prices.
|
||||
func (s *Server) consoleUpdateProduct(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, _ := parseProductForm(c)
|
||||
back := catalogBack + "/" + id.String()
|
||||
if err := s.payments.UpdateProduct(c.Request.Context(), id, in); err != nil {
|
||||
s.renderConsoleMessage(c, "Invalid product", err.Error(), back)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Saved", "the product was updated", back)
|
||||
}
|
||||
|
||||
// consoleArchiveProduct archives or unarchives a product (the desired state rides in the form);
|
||||
// unarchiving revalidates the sellable shape.
|
||||
func (s *Server) consoleArchiveProduct(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
active := c.PostForm("active") == "true"
|
||||
if err := s.payments.SetProductActive(c.Request.Context(), id, active); err != nil {
|
||||
s.renderConsoleMessage(c, "Cannot change status", err.Error(), catalogBack)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Updated", "the product status was changed", catalogBack)
|
||||
}
|
||||
|
||||
// consoleDeleteProductAction hard-deletes a never-transacted product; a transacted one is refused
|
||||
// with a hint to archive instead.
|
||||
func (s *Server) consoleDeleteProductAction(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := s.payments.DeleteProduct(c.Request.Context(), id)
|
||||
if errors.Is(err, payments.ErrProductTransacted) {
|
||||
s.renderConsoleMessage(c, "Cannot delete", "this product has transactions; archive it instead", catalogBack)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
|
||||
}
|
||||
|
||||
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
|
||||
// zero-price admin sale.
|
||||
func (s *Server) consoleGrant(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
if s.payments == nil {
|
||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||
return
|
||||
}
|
||||
origin := payments.Source(c.PostForm("origin"))
|
||||
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
|
||||
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
|
||||
forever := c.PostForm("forever") != ""
|
||||
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
|
||||
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||
return
|
||||
}
|
||||
s.publishBannerChange(id)
|
||||
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
|
||||
}
|
||||
|
||||
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
|
||||
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
|
||||
func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
if s.payments == nil {
|
||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||
return
|
||||
}
|
||||
origin := payments.Source(c.PostForm("origin"))
|
||||
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
|
||||
if err != nil {
|
||||
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
|
||||
return
|
||||
}
|
||||
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
|
||||
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||
return
|
||||
}
|
||||
s.publishBannerChange(id)
|
||||
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
|
||||
}
|
||||
|
||||
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
|
||||
// bundles, including archived ones — chips and tournament products are excluded).
|
||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||
products, err := s.payments.AdminCatalog(ctx)
|
||||
if err != nil {
|
||||
return fv
|
||||
}
|
||||
for _, p := range products {
|
||||
if grantableProduct(p) {
|
||||
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
|
||||
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
|
||||
})
|
||||
}
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
|
||||
// atom (hints / no-ads days) and no chips or tournament atom.
|
||||
func grantableProduct(p payments.AdminProduct) bool {
|
||||
benefit := false
|
||||
for _, a := range p.Atoms {
|
||||
switch a.Atom {
|
||||
case "chips", "tournament":
|
||||
return false
|
||||
case "hints", "noads_days":
|
||||
benefit = true
|
||||
}
|
||||
}
|
||||
return benefit
|
||||
}
|
||||
|
||||
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
|
||||
func atomSummary(atoms []payments.AtomLine) string {
|
||||
parts := make([]string, 0, len(atoms))
|
||||
for _, a := range atoms {
|
||||
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"scrabble/backend/internal/dictadmin"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/social"
|
||||
@@ -29,10 +30,6 @@ import (
|
||||
// adminPageSize is the page size of the admin console's paginated lists.
|
||||
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
|
||||
// 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
|
||||
@@ -56,12 +53,13 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.GET("/users/:id", s.consoleUserDetail)
|
||||
gm.POST("/users/:id/message", s.consoleUserMessage)
|
||||
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/unblock", s.consoleUnblockUser)
|
||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||
gm.POST("/users/:id/grant", s.consoleGrant)
|
||||
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||
gm.GET("/reasons", s.consoleReasons)
|
||||
gm.POST("/reasons", s.consoleCreateReason)
|
||||
@@ -106,6 +104,14 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.GET("/banner-settings", s.consoleBannerSettings)
|
||||
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
|
||||
}
|
||||
if s.payments != nil {
|
||||
gm.GET("/catalog", s.consoleCatalog)
|
||||
gm.POST("/catalog", s.consoleCreateProduct)
|
||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleDashboard renders the landing page: the top-line counts and the resident
|
||||
@@ -354,7 +360,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view := adminconsole.UserDetailView{
|
||||
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
|
||||
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,
|
||||
}
|
||||
if acc.MergedInto != uuid.Nil {
|
||||
@@ -442,9 +447,41 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view.Friends = relationRows(rels)
|
||||
}
|
||||
}
|
||||
if s.payments != nil {
|
||||
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
|
||||
view.Finance = financeView(stmt)
|
||||
} else {
|
||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||
}
|
||||
view.Grant = s.grantForm(ctx)
|
||||
}
|
||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||
}
|
||||
|
||||
// financeView projects an account's payments statement into the user-card finance panel, with the
|
||||
// benefit expiry and ledger times pre-formatted for the logic-free template.
|
||||
func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
||||
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
|
||||
for _, sg := range stmt.Segments {
|
||||
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
|
||||
}
|
||||
for _, b := range stmt.Benefits {
|
||||
row := adminconsole.BenefitRow{Origin: string(b.Origin), Hints: b.Hints, Forever: b.AdsForever}
|
||||
if !b.AdsPaidUntil.IsZero() {
|
||||
row.AdsUntil = fmtTime(b.AdsPaidUntil)
|
||||
}
|
||||
fv.Benefits = append(fv.Benefits, row)
|
||||
}
|
||||
for _, e := range stmt.Ledger {
|
||||
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
||||
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
|
||||
At: fmtTime(e.CreatedAt),
|
||||
})
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
|
||||
// user card renders.
|
||||
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
||||
@@ -963,30 +1000,6 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
|
||||
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
|
||||
// confirmations), freeing the address. It refuses to remove the account's only
|
||||
// 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/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
|
||||
|
||||
// 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).
|
||||
// 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 {
|
||||
Segments []walletSegmentDTO `json:"segments"`
|
||||
AdsForever bool `json:"ads_forever"`
|
||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
|
||||
Hints int `json:"hints"`
|
||||
RewardChips int `json:"reward_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
|
||||
// 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) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
@@ -125,7 +131,13 @@ func (s *Server) handleWallet(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
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
|
||||
@@ -162,3 +174,54 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
|
||||
}
|
||||
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/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
@@ -109,6 +110,9 @@ type Deps struct {
|
||||
// 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).
|
||||
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
|
||||
@@ -136,6 +140,7 @@ type Server struct {
|
||||
banview *banview.View
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
robokassa robokassa.Config
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
@@ -189,6 +194,7 @@ func New(addr string, deps Deps) *Server {
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
payments: deps.Payments,
|
||||
robokassa: deps.Robokassa,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
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 —
|
||||
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.
|
||||
**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
|
||||
**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
|
||||
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
|
||||
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup
|
||||
is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch
|
||||
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup is
|
||||
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).
|
||||
|
||||
**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
|
||||
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.)
|
||||
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
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble check
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=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
|
||||
`pgbackrest-backup.timer` on the main host).
|
||||
The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the
|
||||
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
|
||||
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:
|
||||
|
||||
```sh
|
||||
pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore
|
||||
# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail
|
||||
# Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an
|
||||
# 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
|
||||
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
|
||||
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
|
||||
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
|
||||
|
||||
@@ -10,6 +10,8 @@ Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_*
|
||||
# environment (the S3 repository + cipher), so no repository config is needed on the host.
|
||||
ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
|
||||
# docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and
|
||||
# PGDATA consistency with archive-push, and connect to the database as the superuser role via
|
||||
# --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
|
||||
# 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).
|
||||
@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 {
|
||||
reverse_proxy gateway:8081 {
|
||||
header_up -X-Scrabble-Honeypot
|
||||
|
||||
@@ -28,6 +28,10 @@ services:
|
||||
# the manage-topics and delete-messages rights.
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||
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_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||
|
||||
@@ -161,6 +161,13 @@ services:
|
||||
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
|
||||
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
|
||||
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 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
|
||||
@@ -200,6 +207,9 @@ services:
|
||||
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||
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).
|
||||
VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
@@ -378,6 +388,10 @@ services:
|
||||
# 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_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
|
||||
# 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).
|
||||
|
||||
@@ -18,10 +18,25 @@
|
||||
@shell not path /assets/*
|
||||
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 "/"
|
||||
# behaviour); "/" itself resolves through the index below.
|
||||
try_files {path} /landing.html
|
||||
file_server {
|
||||
index landing.html
|
||||
handle {
|
||||
try_files {path} /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
|
||||
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
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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**,
|
||||
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
|
||||
|
||||
+63
-26
@@ -82,7 +82,7 @@ leaking out to the open web) is allowed.
|
||||
| Execution context | Spendable chip segments | Spend priority |
|
||||
|---------------------|-------------------------|--------------------|
|
||||
| Inside VK (Android) | `vk` | — |
|
||||
| Inside VK (iOS) | none — frozen (view only)| — |
|
||||
| Inside VK (iOS) | `vk` (purchase-frozen) | — |
|
||||
| Inside Telegram | `telegram` | — |
|
||||
| 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.
|
||||
- On the web the store has no jurisdiction, so all attached segments are spendable, drained
|
||||
by priority direct → vk → tg.
|
||||
- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital
|
||||
goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or
|
||||
spend is possible. A previously bought benefit still *applies* there.
|
||||
- **VK iOS is a PURCHASE freeze, not a spend freeze.** Apple's ToS forbids only **buying** in-app
|
||||
values there (for any currency) — so a purchase (money → chips) is refused. **Spending** VK-wallet
|
||||
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
|
||||
**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 —
|
||||
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
|
||||
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: receive → store → ack the Telegram update → forward
|
||||
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
|
||||
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
|
||||
intake = credited exactly once.
|
||||
**TG Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse
|
||||
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
|
||||
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
|
||||
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
|
||||
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
|
||||
(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
|
||||
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
|
||||
message).
|
||||
|
||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
|
||||
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
|
||||
account — tie into `accountdelete`, which already preserves messages). **External** refunds
|
||||
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
|
||||
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
|
||||
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
|
||||
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
|
||||
yet; the schema stays compatible).
|
||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are
|
||||
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa
|
||||
refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a
|
||||
deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and
|
||||
Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine —
|
||||
the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger
|
||||
row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
are rejected — no legal ruble income for a self-employed (НПД) developer.
|
||||
|
||||
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
|
||||
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
|
||||
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
|
||||
**Rewarded** (voluntary video for chips) credits chips through payments. **VK Mini App ads expose
|
||||
only a client-side watch result** (`VKWebAppShowNativeAds` → `data.result`) — there is no
|
||||
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**.
|
||||
- **`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
|
||||
cooldown, with its own **1-min** cooldown.
|
||||
- Applying a **hint** triggers an interstitial **independently** of the main cooldown, with
|
||||
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.
|
||||
- Respect VK's own frequency caps.
|
||||
|
||||
@@ -284,10 +319,12 @@ cache. Identity-presence (which segments are awake, §6) is supplied by the call
|
||||
here, so unlink/re-link takes effect immediately.
|
||||
|
||||
**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never
|
||||
chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the
|
||||
chips** (a gifted currency balance = a store cash-desk bypass). It grants either raw atoms or a
|
||||
**defined value product** (a reward bundle, which may be archived — hidden from the store but
|
||||
grantable); both refuse a `chips` or `tournament` atom. The admin **picks the
|
||||
origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk,
|
||||
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
||||
chips — full audit of rewards.
|
||||
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
|
||||
|
||||
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
||||
spends, grants, refunds, full history — as an extension of the existing user card
|
||||
|
||||
@@ -110,6 +110,11 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) —
|
||||
подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть
|
||||
(`platform/telegram/internal/initdata`).
|
||||
**АМЕНД (E6, находка владельца по ToS):** VK iOS — **заморозка только ПОКУПОК** (деньги→Фишки),
|
||||
а не траты. Apple запрещает там лишь **покупать** внутриигровые ценности за любую валюту; а
|
||||
**тратить** Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте
|
||||
в Android) и зарабатывать их — легально и на iOS. Код: `vkFrozen()` гейтит только `CreateOrder`
|
||||
(покупку), не `spendableSources` (трату).
|
||||
- **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без
|
||||
валидной подписи на старте; старая сессия без записанной платформы) → запрет
|
||||
трат/покупок/применения чужого origin, только просмотр.
|
||||
@@ -158,20 +163,40 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
закладываем **абстракцией** (будущая крутилка для других платформ встроится без
|
||||
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
|
||||
дохода самозанятому (санкции + НПД не учитывает крипту).
|
||||
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments по
|
||||
**серверному verify-колбэку** рекламной сети (клиенту не верим, как платёж). Не
|
||||
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments. Не
|
||||
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
|
||||
**АМЕНД (E6, по факту реализации):** у VK Mini App серверного verify-колбэка **нет** —
|
||||
`VKWebAppShowNativeAds` отдаёт только клиентский `data.result`. Поэтому начисление
|
||||
**client-attested**, а защита (и экономический рычаг) — **серверный дневной и часовой кап**
|
||||
(config `reward_daily_cap` / `reward_hourly_cap`, дефолт 50 / 10) + идемпотентность по клиентскому
|
||||
nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
|
||||
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
|
||||
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
|
||||
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
|
||||
**независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только
|
||||
баннер. Уважать собственные лимиты частоты VK.
|
||||
**АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed`
|
||||
в профиле (`adsFor`), клиент сам гейтит по **единому** времени последнего показа в `localStorage`
|
||||
(без раунд-трипа на ход). Таймер **общий на все виды** — вид (`hint`/`move`/`vs_ai`) лишь выбирает
|
||||
нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь **более короткий
|
||||
интервал от последнего показа**, а не отдельный таймер: hint-ролик и move-ролик не встают подряд
|
||||
(баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу
|
||||
крутил рекламу). Ролик показывается **только после подтверждённого хода или подсказки** — **не**
|
||||
после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial —
|
||||
**только VK** (как и rewarded). Частота — глобальная на все игры (localStorage на устройство).
|
||||
- **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в
|
||||
пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible`
|
||||
(`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту,
|
||||
**применимому в текущем контексте**, а не по одному глобальному флагу. Legacy
|
||||
`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. Каталог — конфигурируемый (БД + админка).** Базовые ценности (атомы
|
||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||
|
||||
+62
-27
@@ -83,7 +83,7 @@
|
||||
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|
||||
|----------------------|---------------------------|--------------------|
|
||||
| Внутри VK (Android) | `vk` | — |
|
||||
| Внутри VK (iOS) | нет — заморожено (только просмотр) | — |
|
||||
| Внутри VK (iOS) | `vk` (заморожена покупка) | — |
|
||||
| Внутри Telegram | `telegram` | — |
|
||||
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
|
||||
|
||||
@@ -91,9 +91,11 @@
|
||||
`direct`) там невидимо как тратимое.
|
||||
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
|
||||
приоритету direct → vk → tg.
|
||||
- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые
|
||||
товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата
|
||||
невозможны. Ранее купленный бенефит там всё равно *действует*.
|
||||
- **VK iOS — заморозка ПОКУПОК, а не траты.** ToS Apple запрещает там только **покупать**
|
||||
внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А
|
||||
**тратить** Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в
|
||||
VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже
|
||||
*действует*. Блокируется только шаг «деньги внутрь».
|
||||
|
||||
Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
|
||||
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
|
||||
@@ -220,12 +222,22 @@ provider_payment_id)`.
|
||||
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
|
||||
|
||||
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
|
||||
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
|
||||
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
|
||||
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
|
||||
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
|
||||
at-least-once + идемпотентный приём = начисление ровно один раз.
|
||||
**TG Stars.** До Telegram дотягивается только **бот**, поэтому весь рельс идёт через обратный
|
||||
mTLS **bot-link** (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути
|
||||
заказа gateway шлёт команду `CreateInvoice`, а бот возвращает `createInvoiceLink` (XTR) в Ack,
|
||||
который Mini App открывает через `WebApp.openInvoice`. До списания звёзд бот отвечает на
|
||||
`pre_checkout_query` через унарный вызов бот→gateway `ValidatePreCheckout` (за ним — приём): одобрить,
|
||||
только если заказ существует, ещё оплачиваем и **не оплачен ранее** — ссылка 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);
|
||||
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
|
||||
@@ -233,14 +245,20 @@ at-least-once + идемпотентный приём = начисление р
|
||||
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
|
||||
бота).
|
||||
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
|
||||
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
|
||||
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
|
||||
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
|
||||
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
|
||||
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
|
||||
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
|
||||
(саму сверку пока не строим; схема остаётся совместимой).
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
|
||||
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
|
||||
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
|
||||
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
|
||||
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
||||
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
||||
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
||||
полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный
|
||||
остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает
|
||||
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
|
||||
с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
|
||||
`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не
|
||||
отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности
|
||||
и сверки (саму сверку пока не строим; схема остаётся совместимой).
|
||||
|
||||
## 10. Реклама
|
||||
|
||||
@@ -250,17 +268,31 @@ web/native/TG держат только существующий наш **тек
|
||||
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
|
||||
рублёвого дохода самозанятому (НПД).
|
||||
|
||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
|
||||
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
|
||||
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
|
||||
позже).
|
||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен. **VK Mini
|
||||
App отдаёт только клиентский результат просмотра** (`VKWebAppShowNativeAds` → `data.result`) —
|
||||
серверной проверки нет — поэтому начисление **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 мин**.
|
||||
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
|
||||
- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна,
|
||||
со своим кулдауном **1 мин**.
|
||||
- Применение **подсказки** триггерит ролик **независимо** от основного кулдауна, со своим
|
||||
кулдауном **1 мин**.
|
||||
- Показывается **только после подтверждённого хода или подсказки** — никогда после пропуска,
|
||||
обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»).
|
||||
- Оффлайн — только баннер.
|
||||
- Уважать собственные лимиты частоты VK.
|
||||
|
||||
@@ -286,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
||||
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
|
||||
|
||||
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
|
||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо
|
||||
сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт
|
||||
из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ
|
||||
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
||||
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
||||
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
|
||||
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||
полный аудит наград.
|
||||
|
||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||
|
||||
+5
-1
@@ -29,6 +29,9 @@ ARG VITE_VK_APP_ID=
|
||||
ARG VITE_VK_ID_REDIRECT_URL=
|
||||
ARG VITE_GATEWAY_URL=
|
||||
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 \
|
||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||
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_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_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
|
||||
# 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)
|
||||
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)
|
||||
if terr != nil {
|
||||
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)
|
||||
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{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
@@ -208,6 +223,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
|
||||
@@ -37,6 +37,8 @@ type ProfileResp struct {
|
||||
// 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.
|
||||
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
|
||||
// attached platform identity — they drive the profile's link/unlink/change controls.
|
||||
Email string `json:"email"`
|
||||
@@ -47,6 +49,15 @@ type ProfileResp struct {
|
||||
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.
|
||||
type DictVersion struct {
|
||||
Variant string `json:"variant"`
|
||||
@@ -186,7 +197,6 @@ type StateResp struct {
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||
}
|
||||
@@ -369,12 +379,14 @@ type WalletSegmentResp struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Segments []WalletSegmentResp `json:"segments"`
|
||||
AdsForever bool `json:"ads_forever"`
|
||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"`
|
||||
Hints int `json:"hints"`
|
||||
RewardChips int `json:"reward_chips"`
|
||||
}
|
||||
|
||||
// walletBuyBody is the chip-spend request body.
|
||||
@@ -382,6 +394,19 @@ type walletBuyBody struct {
|
||||
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.
|
||||
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
|
||||
var out WalletResp
|
||||
@@ -396,6 +421,89 @@ func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (Walle
|
||||
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.
|
||||
type CatalogAtomResp struct {
|
||||
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"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
@@ -31,6 +32,11 @@ var ErrNoBot = errors.New("botlink: no bot connected")
|
||||
// (at-most-once under backpressure).
|
||||
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
|
||||
// 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
|
||||
@@ -38,6 +44,17 @@ const outboundBuffer = 64
|
||||
// chat-access endpoint.
|
||||
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
|
||||
// expected today; the registry already holds a set so adding more later needs no
|
||||
// rewrite.
|
||||
@@ -46,6 +63,10 @@ type Hub struct {
|
||||
|
||||
log *zap.Logger
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *Hub) register(l *link) {
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
"scrabble/gateway/internal/session"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
"scrabble/gateway/internal/vkpay"
|
||||
"scrabble/gateway/internal/webui"
|
||||
edgev1 "scrabble/gateway/proto/edge/v1"
|
||||
"scrabble/gateway/proto/edge/v1/edgev1connect"
|
||||
@@ -70,18 +71,19 @@ const (
|
||||
|
||||
// Server implements edgev1connect.GatewayHandler.
|
||||
type Server struct {
|
||||
registry *transcode.Registry
|
||||
sessions *session.Cache
|
||||
backend *backendclient.Client
|
||||
limiter *ratelimit.Limiter
|
||||
tracker *ratelimit.Tracker
|
||||
banlist *ratelimit.Banlist
|
||||
honeytoken string
|
||||
hub *push.Hub
|
||||
heartbeat time.Duration
|
||||
log *zap.Logger
|
||||
adminProxy http.Handler
|
||||
metrics *serverMetrics
|
||||
registry *transcode.Registry
|
||||
sessions *session.Cache
|
||||
backend *backendclient.Client
|
||||
limiter *ratelimit.Limiter
|
||||
tracker *ratelimit.Tracker
|
||||
banlist *ratelimit.Banlist
|
||||
honeytoken string
|
||||
vkAppSecret string
|
||||
hub *push.Hub
|
||||
heartbeat time.Duration
|
||||
log *zap.Logger
|
||||
adminProxy http.Handler
|
||||
metrics *serverMetrics
|
||||
|
||||
maxBodyBytes int
|
||||
|
||||
@@ -110,12 +112,15 @@ type Deps struct {
|
||||
// Honeytoken, when non-empty, is the planted bearer value whose presentation
|
||||
// bans the caller and raises a high-severity alarm.
|
||||
Honeytoken string
|
||||
Hub *push.Hub
|
||||
RateLimit config.RateLimitConfig
|
||||
Heartbeat time.Duration
|
||||
Logger *zap.Logger
|
||||
AdminProxy http.Handler
|
||||
Meter metric.Meter
|
||||
// VKAppSecret is the VK Mini App protected key; it verifies the VK payment callback signature
|
||||
// (the same key the launch-signature auth uses). Empty leaves the VK callback rejecting.
|
||||
VKAppSecret string
|
||||
Hub *push.Hub
|
||||
RateLimit config.RateLimitConfig
|
||||
Heartbeat time.Duration
|
||||
Logger *zap.Logger
|
||||
AdminProxy http.Handler
|
||||
Meter metric.Meter
|
||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||
MaxBodyBytes int
|
||||
@@ -151,6 +156,7 @@ func NewServer(d Deps) *Server {
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
backend: d.Backend,
|
||||
vkAppSecret: d.VKAppSecret,
|
||||
limiter: limiter,
|
||||
tracker: tracker,
|
||||
banlist: banlist,
|
||||
@@ -196,6 +202,12 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// through this session-gated route (not public); see dictBytesHandler.
|
||||
mux.Handle("/dict/", s.dictBytesHandler())
|
||||
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).
|
||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||
// 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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
|
||||
@@ -37,6 +37,19 @@ func encodeAck(ok bool) []byte {
|
||||
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
|
||||
// deletion step-up the account uses ("email" | "phrase").
|
||||
func encodeDeleteRequestResult(method string) []byte {
|
||||
@@ -103,6 +116,7 @@ func encodeWallet(w backendclient.WalletResp) []byte {
|
||||
fb.WalletAddAdsForever(b, w.AdsForever)
|
||||
fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil)
|
||||
fb.WalletAddHints(b, int32(w.Hints))
|
||||
fb.WalletAddRewardChips(b, int32(w.RewardChips))
|
||||
b.Finish(fb.WalletEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
@@ -170,6 +184,16 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
}
|
||||
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
||||
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.ProfileAddUserId(b, uid)
|
||||
fb.ProfileAddDisplayName(b, name)
|
||||
@@ -192,6 +216,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
if p.Banner != nil {
|
||||
fb.ProfileAddBanner(b, banner)
|
||||
}
|
||||
if p.Ads != nil {
|
||||
fb.ProfileAddAds(b, ads)
|
||||
}
|
||||
b.Finish(fb.ProfileEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
@@ -378,7 +405,6 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||
Alphabet: alphabet,
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ const (
|
||||
MsgWalletGet = "wallet.get"
|
||||
MsgWalletCatalog = "wallet.catalog"
|
||||
MsgWalletBuy = "wallet.buy"
|
||||
MsgWalletOrder = "wallet.order"
|
||||
MsgWalletReward = "wallet.reward"
|
||||
)
|
||||
|
||||
// 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[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(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[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(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.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
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 {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"hint_unlock_left_seconds":1200}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
st := fb.GetRootAsStateView(payload, 0)
|
||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.HintUnlockLeftSeconds() != 1200 {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.HintUnlockLeftSeconds())
|
||||
}
|
||||
game := st.Game(nil)
|
||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||
|
||||
@@ -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/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
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/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-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw=
|
||||
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/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/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/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/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-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/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/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/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/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
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/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
|
||||
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/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
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/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.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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.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/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
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/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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.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/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
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=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
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/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
||||
|
||||
+41
-10
@@ -264,6 +264,18 @@ table Profile {
|
||||
// preload the right dictionary and pin a new local game without a separate request (added
|
||||
// trailing — backward-compatible).
|
||||
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
|
||||
@@ -318,12 +330,11 @@ table StateView {
|
||||
bag_len:int;
|
||||
hints_remaining:int;
|
||||
alphabet:[AlphabetEntry];
|
||||
// wallet_balance is the requesting player's global hint-wallet balance, sent apart from
|
||||
// hints_remaining (which folds the wallet in with the per-game allowance) so the client can
|
||||
// separate the two: the per-game allowance remaining is hints_remaining - wallet_balance, and
|
||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
||||
// backward-compatible).
|
||||
wallet_balance:int;
|
||||
// wallet_balance is deprecated (D31): the purchasable hint wallet moved to the profile (payments),
|
||||
// and hints_remaining now carries the per-game allowance alone. The field is tombstoned rather than
|
||||
// deleted so the vtable slots of the fields after it stay stable across a rolling deploy (an old
|
||||
// SPA served before the deploy must keep reading a new gateway correctly). No accessor is generated.
|
||||
wallet_balance:int (deprecated);
|
||||
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
||||
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
|
||||
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
|
||||
@@ -381,10 +392,9 @@ table ComplaintRequest {
|
||||
note:string;
|
||||
}
|
||||
|
||||
// HintResult is the top-ranked move plus the remaining hint budget. wallet_balance is the
|
||||
// global hint-wallet balance after spending (see StateView.wallet_balance), so the client
|
||||
// refreshes its live wallet and re-derives the per-game allowance (added trailing —
|
||||
// backward-compatible).
|
||||
// HintResult is the top-ranked move plus hints_remaining (the per-game allowance alone) and
|
||||
// wallet_balance (the purchasable hint wallet after spending, from payments). The client adopts
|
||||
// wallet_balance into the profile so the badge stays live across games (see lib/hints).
|
||||
table HintResult {
|
||||
move:MoveRecord;
|
||||
hints_remaining:int;
|
||||
@@ -866,6 +876,9 @@ table Wallet {
|
||||
ads_forever:bool;
|
||||
ads_paid_until_ms:long;
|
||||
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.
|
||||
@@ -873,6 +886,24 @@ table WalletBuyRequest {
|
||||
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
|
||||
// ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries.
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
builder.StartObject(17)
|
||||
builder.StartObject(18)
|
||||
}
|
||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||
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 {
|
||||
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 {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -144,18 +144,6 @@ func (rcv *StateView) AlphabetLength() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *StateView) WalletBalance() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(16, n)
|
||||
}
|
||||
|
||||
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
@@ -195,9 +183,6 @@ func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOf
|
||||
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
||||
}
|
||||
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||
}
|
||||
|
||||
@@ -97,8 +97,20 @@ func (rcv *Wallet) MutateHints(n int32) bool {
|
||||
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) {
|
||||
builder.StartObject(4)
|
||||
builder.StartObject(5)
|
||||
}
|
||||
func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) {
|
||||
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) {
|
||||
builder.PrependInt32Slot(3, hints, 0)
|
||||
}
|
||||
func WalletAddRewardChips(builder *flatbuffers.Builder, rewardChips int32) {
|
||||
builder.PrependInt32Slot(4, rewardChips, 0)
|
||||
}
|
||||
func WalletEnd(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 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_SendToChannel
|
||||
// *Command_ChatGate
|
||||
// *Command_CreateInvoice
|
||||
Payload isCommand_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -310,6 +311,15 @@ func (x *Command) GetChatGate() *ChatGateCommand {
|
||||
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 {
|
||||
isCommand_Payload()
|
||||
}
|
||||
@@ -330,6 +340,10 @@ type Command_ChatGate struct {
|
||||
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_SendToUser) isCommand_Payload() {}
|
||||
@@ -338,15 +352,20 @@ func (*Command_SendToChannel) 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
|
||||
// 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
|
||||
// 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 {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
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"`
|
||||
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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -402,6 +421,13 @@ func (x *Ack) GetError() string {
|
||||
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
|
||||
// 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
|
||||
@@ -562,6 +588,314 @@ func (x *ChatEligibilityResponse) GetEligible() bool {
|
||||
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
|
||||
|
||||
const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||
@@ -576,7 +910,7 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||
"\x05Hello\x12\x1f\n" +
|
||||
"\vinstance_id\x18\x01 \x01(\tR\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" +
|
||||
"\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" +
|
||||
"sendToUser\x12X\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" +
|
||||
"\apayload\"X\n" +
|
||||
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGate\x12R\n" +
|
||||
"\x0ecreate_invoice\x18\x06 \x01(\v2).scrabble.botlink.v1.CreateInvoiceCommandH\x00R\rcreateInvoiceB\t\n" +
|
||||
"\apayload\"p\n" +
|
||||
"\x03Ack\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\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" +
|
||||
"\vexternal_id\x18\x01 \x01(\tR\n" +
|
||||
"externalId\x12\x14\n" +
|
||||
@@ -602,10 +938,31 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"registered\x18\x01 \x01(\bR\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" +
|
||||
"\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 (
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
|
||||
(*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
|
||||
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
|
||||
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
|
||||
(*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest
|
||||
(*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest
|
||||
(*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest
|
||||
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
|
||||
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
|
||||
(*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{
|
||||
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
|
||||
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
|
||||
9, // 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
|
||||
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
|
||||
14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
|
||||
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
|
||||
0, // 7: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||
6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||
1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||
9, // [9:11] is the sub-list for method output_type
|
||||
7, // [7:9] is the sub-list for method input_type
|
||||
7, // [7:7] is the sub-list for extension type_name
|
||||
7, // [7:7] is the sub-list for extension extendee
|
||||
0, // [0:7] is the sub-list for field type_name
|
||||
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
|
||||
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
|
||||
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
|
||||
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
|
||||
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() }
|
||||
@@ -666,6 +1033,7 @@ func file_botlink_v1_botlink_proto_init() {
|
||||
(*Command_SendToUser)(nil),
|
||||
(*Command_SendToChannel)(nil),
|
||||
(*Command_ChatGate)(nil),
|
||||
(*Command_CreateInvoice)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
@@ -673,7 +1041,7 @@ func file_botlink_v1_botlink_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 8,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -27,6 +27,24 @@ service BotLink {
|
||||
// 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).
|
||||
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
|
||||
@@ -61,17 +79,21 @@ message Command {
|
||||
scrabble.telegram.v1.SendToUserRequest send_to_user = 3;
|
||||
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
|
||||
ChatGateCommand chat_gate = 5;
|
||||
CreateInvoiceCommand create_invoice = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
string command_id = 1;
|
||||
bool delivered = 2;
|
||||
string error = 3;
|
||||
string result = 4;
|
||||
}
|
||||
|
||||
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
|
||||
@@ -99,3 +121,48 @@ message ChatEligibilityResponse {
|
||||
bool registered = 1;
|
||||
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 (
|
||||
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
|
||||
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.
|
||||
@@ -48,6 +50,22 @@ type BotLinkClient interface {
|
||||
// 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).
|
||||
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 {
|
||||
@@ -81,6 +99,26 @@ func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatElig
|
||||
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.
|
||||
// All implementations must embed UnimplementedBotLinkServer
|
||||
// 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
|
||||
// write permission. Delivery of the answer is request/response (not best-effort).
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -115,6 +169,12 @@ func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot])
|
||||
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
|
||||
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) testEmbeddedByValue() {}
|
||||
|
||||
@@ -161,6 +221,42 @@ func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Contex
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -172,6 +268,14 @@ var BotLink_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ResolveChatEligibility",
|
||||
Handler: _BotLink_ResolveChatEligibility_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ValidatePreCheckout",
|
||||
Handler: _BotLink_ValidatePreCheckout_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ForwardPayment",
|
||||
Handler: _BotLink_ForwardPayment_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
|
||||
@@ -92,7 +92,6 @@ type StateView struct {
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
HintUnlockLeftSeconds int
|
||||
Alphabet []AlphabetEntry
|
||||
}
|
||||
@@ -256,7 +255,6 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
||||
fb.StateViewAddRack(b, rack)
|
||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||
if hasAlphabet {
|
||||
fb.StateViewAddAlphabet(b, alphabet)
|
||||
|
||||
@@ -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.
|
||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||
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
|
||||
`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
|
||||
relay.
|
||||
- `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
|
||||
a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time
|
||||
query) over the same mTLS channel. Generated Go is committed under `pkg`.
|
||||
on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's
|
||||
chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the
|
||||
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
|
||||
|
||||
@@ -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_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_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_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 |
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/botlink"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/promobot"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
@@ -90,11 +91,24 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
GameChannelID: cfg.GameChannelID,
|
||||
SupportChatID: cfg.SupportChatID,
|
||||
SupportStore: supportStore,
|
||||
AcceptPayments: cfg.StarsOutboxDir != "",
|
||||
}, logger)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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 <->
|
||||
// client construction cycle.
|
||||
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
|
||||
// /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))
|
||||
}
|
||||
})
|
||||
// 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 main bot's lease).
|
||||
if promo != nil {
|
||||
|
||||
@@ -12,3 +12,16 @@ require (
|
||||
google.golang.org/protobuf v1.36.11
|
||||
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"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
@@ -49,6 +50,10 @@ type Config struct {
|
||||
// SupportStore persists the support relay's state (topic mapping, block list,
|
||||
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
||||
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
|
||||
@@ -91,6 +96,12 @@ type Bot struct {
|
||||
supportLocks *keyedMutex
|
||||
// admins caches the support chat's administrator ids (who may reply and act).
|
||||
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
|
||||
@@ -123,9 +134,10 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
// callback handler by their "sup:" data prefix.
|
||||
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
||||
}
|
||||
// Allowed updates default to "all except chat_member". Specify an explicit set only
|
||||
// when we need chat_member (moderated chat) — and then re-add callback_query (which
|
||||
// the explicit set would otherwise drop) when the support relay needs it.
|
||||
// Allowed updates default to "all except chat_member" (which already includes
|
||||
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
|
||||
// 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 {
|
||||
allowed := tgbot.AllowedUpdates{
|
||||
models.AllowedUpdateMessage,
|
||||
@@ -135,6 +147,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if t.supportEnabled() {
|
||||
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
||||
}
|
||||
if cfg.AcceptPayments {
|
||||
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
|
||||
}
|
||||
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
||||
}
|
||||
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)
|
||||
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 —
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
delivered, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
|
||||
delivered, result, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
|
||||
if herr != nil {
|
||||
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
|
||||
// was applied.
|
||||
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
|
||||
@@ -48,22 +52,42 @@ func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
|
||||
return &Executor{sender: sender, channelID: channelID, log: log}
|
||||
}
|
||||
|
||||
// Handle dispatches one command to the matching Bot API send.
|
||||
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
|
||||
// Handle dispatches one command to the matching Bot API call. It returns whether the command was
|
||||
// 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) {
|
||||
case *botlinkv1.Command_Notify:
|
||||
return e.notify(ctx, p.Notify)
|
||||
d, err := e.notify(ctx, p.Notify)
|
||||
return d, "", err
|
||||
case *botlinkv1.Command_SendToUser:
|
||||
return e.sendToUser(ctx, p.SendToUser)
|
||||
d, err := e.sendToUser(ctx, p.SendToUser)
|
||||
return d, "", err
|
||||
case *botlinkv1.Command_SendToChannel:
|
||||
return e.sendToChannel(ctx, p.SendToChannel)
|
||||
d, err := e.sendToChannel(ctx, p.SendToChannel)
|
||||
return d, "", err
|
||||
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:
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
|
||||
// fakeSender records the delivery calls the executor makes.
|
||||
type fakeSender struct {
|
||||
notify []notifyCall
|
||||
text []textCall
|
||||
gate []gateCall
|
||||
applied bool // ApplyChatGate's reported result
|
||||
err error
|
||||
notify []notifyCall
|
||||
text []textCall
|
||||
gate []gateCall
|
||||
invoice []invoiceCall
|
||||
invoiceLink string // CreateInvoiceLink's returned link
|
||||
applied bool // ApplyChatGate's reported result
|
||||
err error
|
||||
}
|
||||
|
||||
type notifyCall struct {
|
||||
@@ -32,6 +34,10 @@ type gateCall struct {
|
||||
userID int64
|
||||
allow bool
|
||||
}
|
||||
type invoiceCall struct {
|
||||
title, description, payload string
|
||||
amount int64
|
||||
}
|
||||
|
||||
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
gid := b.CreateString(gameID)
|
||||
@@ -67,7 +78,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
||||
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
sender := &fakeSender{}
|
||||
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 {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -85,7 +96,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
||||
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
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 {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -99,7 +110,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
|
||||
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -108,7 +119,7 @@ func TestExecutorSendToUser(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
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 {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -126,7 +137,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
|
||||
func TestExecutorChatGateApplied(t *testing.T) {
|
||||
sender := &fakeSender{applied: true}
|
||||
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 {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -138,7 +149,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
|
||||
func TestExecutorChatGateNotInChat(t *testing.T) {
|
||||
sender := &fakeSender{applied: false} // user not in the chat
|
||||
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 {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -152,24 +163,53 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
|
||||
|
||||
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
|
||||
|
||||
t.Run("unconfigured", func(t *testing.T) {
|
||||
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.Run("configured", func(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 555, nil)
|
||||
delivered, err := exec.Handle(context.Background(), channelCmd)
|
||||
delivered, _, err := exec.Handle(context.Background(), channelCmd)
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ type BotConfig struct {
|
||||
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
|
||||
// container user (UID 65532) and backed by a persistent volume.
|
||||
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
|
||||
// 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
|
||||
@@ -157,6 +162,7 @@ func LoadBot() (BotConfig, error) {
|
||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
|
||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||
BotLink: BotLinkClientConfig{
|
||||
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user