Compare commits

..

21 Commits

Author SHA1 Message Date
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
98 changed files with 224 additions and 5631 deletions
-42
View File
@@ -366,13 +366,6 @@ jobs:
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
# (independent of the shop's own mode). Empty login leaves the direct rail off.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: "1"
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }} SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana # Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
@@ -407,9 +400,6 @@ jobs:
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below. # the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the
# contour (empty = real ads, for capturing the real VK ad result). Prod never sets it.
VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the # VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults. # compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
@@ -507,38 +497,6 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /offer/ is a static page baked into the landing image (rendered from
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
# silently falls through to the landing shell (also 200) — so assert offer-specific
# content, never just the status.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742"; then
echo "ok: /offer/ serves the public offer page"
else
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
docker logs --tail 50 scrabble-landing || true
exit 1
fi
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
# a 404 landing.html), which proves the edge route is wired.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
echo "ok: /pay/ reaches the gateway (non-landing response)"
else
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
docker logs --tail 50 scrabble-gateway || true
exit 1
fi
- name: Probe the /dict edge route reaches the gateway - name: Probe the /dict edge route reaches the gateway
run: | run: |
set -u set -u
+27 -98
View File
@@ -33,9 +33,9 @@ status — without re-deriving decisions.
| E1 | Trusted platform signal | 1 | DONE | | E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | DONE | | E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE | | E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | DONE | | E4 | Durability (PITR) | 2 | WIP |
| E5 | Payment intake | 2 | DONE | | E5 | Payment intake | 2 | TODO |
| E6 | Ads | 2 | WIP | | E6 | Ads | 2 | TODO |
| E7 | Admin & reports | 2 | TODO | | E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO | | E8 | Guest limits | — | TODO |
| E9 | Tournament fee | future | TODO | | E9 | Tournament fee | future | TODO |
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
## E4 — Durability (PITR) ## E4 — Durability (PITR)
**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** · **Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before
depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4). E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first **Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
real money** is accepted (E5 prod). Protects both money and game data. real money** is accepted (E5 prod). Protects both money and game data.
@@ -479,73 +479,30 @@ real money** is accepted (E5 prod). Protects both money and game data.
`-n backend`, silently excluding `payments`); manual-restore runbook updated. `-n backend`, silently excluding `payments`); manual-restore runbook updated.
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`. - Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.
**Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3 **Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`); the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window), then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data `-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps:
intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming). `deploy/README.md` (point-in-time recovery — arming).
**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp), **Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config` recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
valid + the custom PG image builds (archiving inert on the contour). valid + the custom PG image builds (archiving inert on the contour).
**Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an **Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL
isolated one-shot target; cost + perf assessment reviewed; runbook current in archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook
`deploy/README.md`. current in `deploy/README.md`.
**Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart **Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart
from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble` from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance
(docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
--- ---
## E5 — Payment intake ## E5 — Payment intake
**Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. **Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД
receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a
chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is
**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`:
the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the
`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36
confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public
`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the
`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a
self-closing provider-return page (the payment opens in a separate window) and a return-focus
refetch fallback. The **VK Votes rail** is delivered too: the client opens
`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack
title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent
on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`,
already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse
bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's
`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`**
unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox**
(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement).
**Goal.** Accept real money on all three rails into the payments domain: order-flow, **Goal.** Accept real money on all three rails into the payments domain: order-flow,
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
@@ -568,14 +525,12 @@ receipts, and refunds.
**TG bot outbox (`platform/telegram/`).** **TG bot outbox (`platform/telegram/`).**
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite** - The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse **SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend to a backend payments-intake endpoint (internal, authenticated over the existing reverse
directly) → the gateway proxies to the backend payments-intake REST → on a durable response, mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
`telegram_payment_charge_id`. - Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
`CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.
**Events & notifications.** **Events & notifications.**
@@ -587,13 +542,9 @@ receipts, and refunds.
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK **Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
handles Votes tax itself; TG Stars — no receipt. handles Votes tax itself; TG Stars — no receipt.
**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an **Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker), `refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort, flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).
**Tests.** **Tests.**
@@ -618,31 +569,9 @@ force-recreate when the Caddyfile changes.
## E6 — Ads ## E6 — Ads
**Status:** WIP · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · **Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10. mechanics: PAYMENTS §10.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only,
order-less, idempotent on a client nonce, floored by the caps, payout from config
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). The legacy `paid_account`
/ `hint_balance` drop (D31) and the **interstitial** are the next slice.
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video **Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
(credits chips via server verify), plus extending the existing banner suppression to (credits chips via server verify), plus extending the existing banner suppression to
per-origin. per-origin.
-54
View File
@@ -308,7 +308,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Notifier: hub, Notifier: hub,
ExportSignKey: cfg.ExportSignKey, ExportSignKey: cfg.ExportSignKey,
Renderer: renderer, Renderer: renderer,
Robokassa: cfg.Robokassa,
}) })
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -317,13 +316,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("servers starting", logger.Info("servers starting",
zap.String("http_addr", cfg.HTTPAddr), zap.String("http_addr", cfg.HTTPAddr),
zap.String("grpc_addr", cfg.GRPCAddr)) zap.String("grpc_addr", cfg.GRPCAddr))
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
// still credits). Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, logger)
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
errc := make(chan error, 2) errc := make(chan error, 2)
go func() { errc <- pushSrv.Run(ctx) }() go func() { errc <- pushSrv.Run(ctx) }()
go func() { errc <- srv.Run(ctx) }() go func() { errc <- srv.Run(ctx) }()
@@ -333,52 +325,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err return err
} }
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
// order.
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := p.ExpireOrders(ctx); err != nil {
log.Warn("order reaper: sweep failed", zap.Error(err))
} else if n > 0 {
log.Info("order reaper: expired pending orders", zap.Int("count", n))
}
}
}
}
// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh
// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit
// already committed to the ledger; this is only the in-app push so an open wallet updates in place.
func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
evs, err := p.UndispatchedEvents(ctx, 50)
if err != nil {
log.Warn("payment dispatcher: read failed", zap.Error(err))
continue
}
for _, e := range evs {
pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment))
if err := p.MarkEventDispatched(ctx, e.EventID); err != nil {
log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err))
}
}
}
}
}
// newMailer builds the confirm-code mailer: an SMTP relay when a host is // newMailer builds the confirm-code mailer: an SMTP relay when a host is
// configured, otherwise the development log mailer (the code is logged, not sent). // configured, otherwise the development log mailer (the code is logged, not sent).
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer { func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
-15
View File
@@ -393,21 +393,6 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
return out, nil return out, nil
} }
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
}
}
return false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated // ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset. // by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
-15
View File
@@ -14,7 +14,6 @@ import (
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
) )
@@ -65,9 +64,6 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g. // RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact. // http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
// leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
} }
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
@@ -157,13 +153,6 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
} }
robo := robokassa.Config{
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
}
c := Config{ c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
@@ -181,7 +170,6 @@ func Load() (Config, error) {
GuestRetention: guestRetention, GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: robo,
} }
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
@@ -234,9 +222,6 @@ func (c Config) validate() error {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL) return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
} }
} }
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
}
return nil return nil
} }
@@ -1,563 +0,0 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// orderStatus reads an order's status.
func orderStatus(t *testing.T, orderID uuid.UUID) string {
t.Helper()
var status string
if err := testDB.QueryRowContext(context.Background(),
`SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil {
t.Fatalf("read order status: %v", err)
}
return status
}
// readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when
// none exists.
func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) {
t.Helper()
err := testDB.QueryRowContext(context.Background(),
`SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss)
if errors.Is(err, sql.ErrNoRows) {
return false, 0
}
if err != nil {
t.Fatalf("read risk: %v", err)
}
return abuse, loss
}
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
// credits nothing more — the ledger idempotency index holds.
func TestPaymentsOrderFundCreditsOnce(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB {
t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount)
}
if orderStatus(t, res.OrderID) != "pending" {
t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect {
t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after fund = %d, want 100", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID))
}
// A replayed callback for the same order is rejected by the unique index: no second credit.
out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("duplicate fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate = %d, want 100 (credited once)", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Error("duplicate callback wrote a second fund ledger row")
}
}
// TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the
// pack in votes; the get_item lookup returns its title and vote price; a chargeable
// order_status_change credits the vk segment exactly once (idempotent on VK's own order id).
func TestPaymentsVKOrderFundCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk")
if err != nil {
t.Fatalf("create vk order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 {
t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount)
}
// get_item phase: title + vote price.
title, amount, err := svc.OrderItem(ctx, res.OrderID)
if err != nil {
t.Fatalf("order item: %v", err)
}
if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote {
t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount)
}
// order_status_change phase: VK's own order id is the idempotency key.
paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote)
out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("vk fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK {
t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out)
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after fund = %d, want 200", got)
}
// A duplicate VK callback (same VK order id) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("duplicate vk fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate VK callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got)
}
}
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
func TestPaymentsTelegramStarsRail(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
}
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout: %v", err)
}
if !pc.OK || pc.AccountID != acc {
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
}
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("telegram fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after fund = %d, want 50", got)
}
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("duplicate telegram fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried forward not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
}
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout after paid: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
}
}
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
// and an amount that no longer matches.
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
// An unknown order id declines as gone, with no account to localise against.
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
if err != nil {
t.Fatalf("pre_checkout unknown: %v", err)
}
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
}
// A pending order validated at the wrong amount declines as price-changed.
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
if err != nil {
t.Fatalf("pre_checkout mismatch: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
}
}
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
// after the test (the row is a singleton shared across the sequential suite).
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
payout, dailyCap, hourlyCap); err != nil {
t.Fatalf("set reward config: %v", err)
}
t.Cleanup(func() {
_, _ = testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
})
}
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
// third distinct view.
func TestPaymentsRewardCredit(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
vk := payments.NewContext("vk", "web")
present := []payments.Source{payments.SourceVK}
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("credit: %v", err)
}
if out.Chips != 5 || out.Capped {
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance = %d, want 5", got)
}
// A retried view (same nonce) credits nothing more.
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("retry: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried view not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
}
// A second distinct view credits again (2 total).
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
t.Fatalf("credit 2: %v", err)
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance = %d, want 10", got)
}
// The third distinct view hits the hourly cap (2) — capped, no credit.
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
if err != nil {
t.Fatalf("credit 3: %v", err)
}
if !out3.Capped {
t.Error("third view not capped (hourly cap 2)")
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
}
}
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
// refused outside a VK context (rewarded is VK-only, D28).
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
vk := payments.NewContext("vk", "web")
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
if err != nil {
t.Fatalf("credit (disabled): %v", err)
}
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
}
// A direct (non-VK) context is refused — rewarded is VK-only.
direct := payments.NewContext("direct", "web")
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
}
}
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
// refused and credits nothing (§9: verify the amount after matching by order id).
func TestPaymentsFundAmountMismatch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) {
t.Fatalf("fund = %v, want ErrAmountMismatch", err)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got)
}
if ledgerRows(t, acc, "fund") != 0 {
t.Error("fund ledger row written on an amount mismatch")
}
}
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
func TestPaymentsEventDispatchDrain(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
t.Fatalf("record event: %v", err)
}
// testDB is shared, so filter the queue to our account.
find := func() *payments.PaymentEvent {
evs, err := svc.UndispatchedEvents(ctx, 100)
if err != nil {
t.Fatalf("undispatched: %v", err)
}
for i := range evs {
if evs[i].AccountID == acc {
return &evs[i]
}
}
return nil
}
mine := find()
if mine == nil {
t.Fatal("recorded event not in the undispatched queue")
}
if mine.Type != "succeeded" {
t.Errorf("event type = %s, want succeeded", mine.Type)
}
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
t.Fatalf("mark dispatched: %v", err)
}
if find() != nil {
t.Error("event still undispatched after MarkEventDispatched")
}
}
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
// Age the order well past the configured TTL, then sweep it to expired.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 {
t.Fatalf("expire orders = %d (err %v), want at least 1", n, err)
}
if orderStatus(t, res.OrderID) != "expired" {
t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund after expiry: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 {
t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 (expired order honoured)", got)
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
}
}
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
acc := uuid.New()
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
return res.OrderID, acc
}
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
func TestPaymentsRefundFull(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
}
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
}
}
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
func TestPaymentsRefundAfterSpend(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
// Simulate 70 chips already spent, leaving 30 in the funded segment.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("simulate spend: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.Revoked != 30 || out.Loss != 70 {
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
}
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
}
}
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
// more — the ledger idempotency index holds.
func TestPaymentsRefundIdempotent(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
t.Fatalf("first refund: %v", err)
}
// Re-credit the segment to prove the duplicate does not revoke again.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("re-credit: %v", err)
}
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
if err != nil {
t.Fatalf("duplicate refund: %v", err)
}
if !out2.AlreadyRefunded {
t.Error("duplicate refund not flagged AlreadyRefunded")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
}
}
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
}
}
-4
View File
@@ -73,10 +73,6 @@ const (
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser), // (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only. // so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile" NotifyProfile = "profile"
// NotifyPayment tells the client that the viewer's wallet changed from a payment-intake event
// (a credit or a refund), so it re-fetches the wallet in place. It carries no payload. In-app
// only; the payment_events dispatcher emits it after the crediting transaction commits.
NotifyPayment = "payment"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect, // NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the // carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered // in-game block/add-friend controls and the struck name in place. It is delivered
+10 -13
View File
@@ -89,10 +89,8 @@ func NewContext(kind, subtype string) Context {
// every spend/purchase and the application of any foreign origin. // every spend/purchase and the application of any foreign origin.
func (c Context) Trusted() bool { return c.Kind.Valid() } func (c Context) Trusted() bool { return c.Kind.Valid() }
// vkFrozen reports whether this is the VK-iOS purchase freeze: VK context on the trusted iOS // vkFrozen reports whether this is the VK-iOS spend freeze: VK context on the trusted iOS
// subtype. Apple's ToS forbids only BUYING in-app values there, so a purchase (money -> chips) is // subtype. A previously bought benefit still applies there, but no spend or purchase is possible.
// refused; earning chips (rewarded ads) and SPENDING chips already in the VK wallet — earned or
// bought on the same account elsewhere (e.g. VK Android) — are legal and stay allowed.
func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS } func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS }
// spendPriority is the fixed draw order when several segments are spendable in one context // spendPriority is the fixed draw order when several segments are spendable in one context
@@ -105,12 +103,11 @@ func has(present []Source, s Source) bool {
} }
// spendableSources returns the chip segments that may be SPENT in the context, in draw-priority // spendableSources returns the chip segments that may be SPENT in the context, in draw-priority
// order, restricted to the sources the account actually has (present). It is empty only when the // order, restricted to the sources the account actually has (present). It is empty when the
// platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so // platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named
// spending VK-wallet chips there is allowed. Inside VK/TG only the same-named segment is spendable; // segment is spendable; on web/native all attached segments are, drained direct→vk→tg.
// on web/native all attached segments are, drained direct→vk→tg.
func spendableSources(c Context, present []Source) []Source { func spendableSources(c Context, present []Source) []Source {
if !c.Trusted() { if !c.Trusted() || c.vkFrozen() {
return nil return nil
} }
switch c.Kind { switch c.Kind {
@@ -131,10 +128,10 @@ func spendableSources(c Context, present []Source) []Source {
} }
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority // applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
// order, restricted to present sources. It mirrors spendableSources (both gate only on a trusted // order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is
// platform now that the VK-iOS freeze is purchase-only). Inside VK/TG only the same-named origin // NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG
// applies (a foreign, e.g. direct, origin never activates inside a store — the compliance wall); // only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a
// on web/native direct+vk+tg all apply, drained direct→vk→tg. // store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg.
func applicableOrigins(c Context, present []Source) []Source { func applicableOrigins(c Context, present []Source) []Source {
if !c.Trusted() { if !c.Trusted() {
return nil return nil
+3 -4
View File
@@ -16,8 +16,7 @@ func TestSpendableSources(t *testing.T) {
want []Source want []Source
}{ }{
{"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}}, {"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
// VK-iOS spends its own vk segment: the freeze is purchase-only, spending is allowed. {"vk ios frozen", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, nil},
{"vk ios spends vk", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil}, {"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil},
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}}, {"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil}, {"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
@@ -42,8 +41,8 @@ func TestApplicableOrigins(t *testing.T) {
present []Source present []Source
want []Source want []Source
}{ }{
// A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only). // A benefit still APPLIES on VK-iOS while spending is frozen.
{"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}}, {"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}}, {"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}}, {"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}},
{"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}}, {"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
+5 -11
View File
@@ -147,13 +147,12 @@ func (m Money) Cmp(o Money) (int, error) {
} }
} }
// Major renders the amount as a decimal string without the currency, with the currency's // String renders the amount as "<value> <currency>", with the currency's
// fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount // fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
// field (Robokassa OutSum) takes. func (m Money) String() string {
func (m Money) Major() string {
scale := m.currency.minorPerUnit() scale := m.currency.minorPerUnit()
if scale == 1 { if scale == 1 {
return fmt.Sprintf("%d", m.minor) return fmt.Sprintf("%d %s", m.minor, m.currency)
} }
neg := m.minor < 0 neg := m.minor < 0
abs := m.minor abs := m.minor
@@ -168,10 +167,5 @@ func (m Money) Major() string {
if neg { if neg {
sign = "-" sign = "-"
} }
return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale) return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
}
// 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)
} }
-194
View File
@@ -1,194 +0,0 @@
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"
// 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())
}
@@ -1,42 +0,0 @@
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)
}
})
}
}
-609
View File
@@ -1,609 +0,0 @@
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
}
// 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"
}
+6 -19
View File
@@ -230,11 +230,8 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin
return nil return nil
} }
// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and // insertLedgerTx appends one append-only ledger row inside tx.
// providerPaymentID are set only on an intake credit (fund/refund) and are nil for a 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 {
// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial
// unique index, so a duplicate provider callback fails here (the idempotency key).
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return fmt.Errorf("payments: ledger id: %w", err) return fmt.Errorf("payments: ledger id: %w", err)
@@ -249,13 +246,11 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s
stmt := table.Ledger.INSERT( stmt := table.Ledger.INSERT(
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind, table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta, table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider, table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
).VALUES( ).VALUES(
id, accountID, kind, id, accountID, kind,
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta), sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider), uuidOrNull(productID), snap, now,
stringOrNull(providerPaymentID), snap, now,
) )
if _, err := stmt.ExecContext(ctx, tx); err != nil { if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("payments: insert %s ledger: %w", kind, err) return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
@@ -283,7 +278,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
return ErrInsufficientChips return ErrInsufficientChips
} }
src := dr.source src := dr.source
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil { if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, snapshot, now); err != nil {
return err return err
} }
} }
@@ -300,7 +295,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
// the chosen origin, in one transaction — a zero-price sale of a value. // the chosen origin, in one transaction — a zero-price sale of a value.
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error { func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error {
err := withTx(ctx, s.db, func(tx *sql.Tx) error { err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil { if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, snapshot, now); err != nil {
return err return err
} }
return applyBenefitTx(ctx, tx, accountID, origin, d, now) return applyBenefitTx(ctx, tx, accountID, origin, d, now)
@@ -424,11 +419,3 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression {
} }
return postgres.UUID(*id) return postgres.UUID(*id)
} }
// stringOrNull renders an optional string as a SQL string or NULL.
func stringOrNull(s *string) postgres.Expression {
if s == nil {
return postgres.NULL
}
return postgres.String(*s)
}
+3 -4
View File
@@ -102,11 +102,10 @@ func TestWalletSegments(t *testing.T) {
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable { if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
t.Errorf("vk-android wallet = %+v", got.Segments) t.Errorf("vk-android wallet = %+v", got.Segments)
} }
// VK iOS: only vk shown, and spendable — the freeze is purchase-only, so VK-wallet chips still // VK iOS: only vk shown, frozen (not spendable) but the balance is visible.
// spend there (only buying more chips for money is blocked).
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present) got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable { if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 spendable)", got.Segments) t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments)
} }
} }
@@ -1,20 +0,0 @@
//
// 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,6 +14,4 @@ type Config struct {
CooldownVsAiSeconds int32 CooldownVsAiSeconds int32
CooldownHintSeconds int32 CooldownHintSeconds int32
OrderTTLSeconds int32 OrderTTLSeconds int32
RewardDailyCap int32
RewardHourlyCap int32
} }
@@ -1,87 +0,0 @@
//
// 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,8 +23,6 @@ type configTable struct {
CooldownVsAiSeconds postgres.ColumnInteger CooldownVsAiSeconds postgres.ColumnInteger
CooldownHintSeconds postgres.ColumnInteger CooldownHintSeconds postgres.ColumnInteger
OrderTTLSeconds postgres.ColumnInteger OrderTTLSeconds postgres.ColumnInteger
RewardDailyCap postgres.ColumnInteger
RewardHourlyCap postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -72,11 +70,9 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds") CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds") CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds") OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap") allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap") mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn} defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
) )
return configTable{ return configTable{
@@ -89,8 +85,6 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSeconds: CooldownVsAiSecondsColumn, CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
CooldownHintSeconds: CooldownHintSecondsColumn, CooldownHintSeconds: CooldownHintSecondsColumn,
OrderTTLSeconds: OrderTTLSecondsColumn, OrderTTLSeconds: OrderTTLSecondsColumn,
RewardDailyCap: RewardDailyCapColumn,
RewardHourlyCap: RewardHourlyCapColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -10,7 +10,6 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke // UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program. // this method only once at the beginning of the program.
func UseSchema(schema string) { func UseSchema(schema string) {
AccountRisk = AccountRisk.FromSchema(schema)
Balances = Balances.FromSchema(schema) Balances = Balances.FromSchema(schema)
Benefits = Benefits.FromSchema(schema) Benefits = Benefits.FromSchema(schema)
CatalogAtom = CatalogAtom.FromSchema(schema) CatalogAtom = CatalogAtom.FromSchema(schema)
@@ -1,25 +0,0 @@
-- 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;
@@ -1,22 +0,0 @@
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
-- total; the hourly cap smooths a burst). Chips-per-view already exists
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
-- config, tuned in the admin without a release. Additive columns only — applies forward via
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
-- +goose Up
ALTER TABLE payments.config
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
ALTER TABLE payments.config
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
-- +goose Down
ALTER TABLE payments.config
DROP COLUMN reward_daily_cap,
DROP COLUMN reward_hourly_cap;
-104
View File
@@ -1,104 +0,0 @@
// 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[:])
}
@@ -1,85 +0,0 @@
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")
}
}
-19
View File
@@ -72,23 +72,6 @@ func (s *Server) registerRoutes() {
u.GET("/wallet", s.handleWallet) u.GET("/wallet", s.handleWallet)
u.GET("/wallet/catalog", s.handleWalletCatalog) u.GET("/wallet/catalog", s.handleWalletCatalog)
u.POST("/wallet/buy", s.handleWalletBuy) u.POST("/wallet/buy", s.handleWalletBuy)
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
u.POST("/wallet/reward", s.handleWalletReward)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
// the Robokassa Result callback when a merchant is configured.
u.POST("/wallet/order", s.handleWalletOrder)
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
} }
if s.links != nil { if s.links != nil {
// Account linking & merge. The request step always mails a code; // Account linking & merge. The request step always mails a code;
@@ -283,8 +266,6 @@ func statusForError(err error) (int, string) {
return http.StatusNotFound, "product_not_found" return http.StatusNotFound, "product_not_found"
case errors.Is(err, payments.ErrNotAValue): case errors.Is(err, payments.ErrNotAValue):
return http.StatusBadRequest, "not_a_value" return http.StatusBadRequest, "not_a_value"
case errors.Is(err, payments.ErrNotAPack):
return http.StatusBadRequest, "not_a_pack"
case errors.Is(err, account.ErrInvalidEmail): case errors.Is(err, account.ErrInvalidEmail):
return http.StatusBadRequest, "invalid_email" return http.StatusBadRequest, "invalid_email"
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
-399
View File
@@ -1,399 +0,0 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// Ledger/order provider tags per rail.
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
type walletOrderRequest struct {
ProductID string `json:"product_id"`
}
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
type walletOrderResponse struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title,omitempty"`
InvoiceAmount int64 `json:"invoice_amount,omitempty"`
}
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
// provider callback.
func (s *Server) handleWalletOrder(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
return
}
productID, err := uuid.Parse(req.ProductID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
switch cxt.Kind {
case payments.SourceDirect:
if s.robokassa.MerchantLogin == "" {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
// D36: a direct purchase requires a confirmed email anchor.
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
if !hasEmail {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
return
}
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
if err != nil {
s.abortErr(c, err)
return
}
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
case payments.SourceTelegram:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
if err != nil {
s.abortErr(c, err)
return
}
// The gateway mints the Stars invoice link from the title and amount via the bot (only
// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
// whole stars (the XTR minor unit is the star). No chips are credited until the verified
// successful_payment is forwarded back through the bot.
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
Rail: providerTelegram,
InvoiceTitle: res.Title,
InvoiceAmount: res.Amount.Minor(),
})
default:
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
}
}
// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
func (s *Server) handleRobokassaResult(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.String(http.StatusBadRequest, "bad request")
return
}
v := url.Values{}
for k, val := range params {
v.Set(k, val)
}
orderID, outSum, ok := s.robokassa.VerifyResult(v)
if !ok {
s.log.Warn("robokassa result: bad signature")
c.String(http.StatusBadRequest, "bad sign")
return
}
paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
if err != nil {
c.String(http.StatusBadRequest, "bad amount")
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusBadRequest, "rejected")
return
}
s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
}
// vkErrorResponse builds VK's error envelope for a payment callback.
func vkErrorResponse(code int, msg string, critical bool) gin.H {
return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
}
// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
// the sandbox notifications and are handled identically.
func (s *Server) handleVKCallback(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
return
}
ctx := c.Request.Context()
switch params["notification_type"] {
case "get_item", "get_item_test":
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
title, amount, err := s.payments.OrderItem(ctx, orderID)
if err != nil {
s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
c.JSON(http.StatusOK, gin.H{"response": gin.H{
"item_id": orderID.String(),
"title": title,
"price": amount.Minor(),
}})
case "order_status_change", "order_status_change_test":
if params["status"] != "chargeable" {
c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
return
}
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
price, err := strconv.ParseInt(params["item_price"], 10, 64)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
vkOrderID := params["order_id"]
outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
return
}
s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
default:
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
}
}
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
// the order in the invoice payload and the amount and currency Telegram is about to charge.
type telegramPreCheckoutRequest struct {
OrderID string `json:"order_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
// short message the bot surfaces to the payer on a decline.
type telegramPreCheckoutResponse struct {
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
}
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
// is refused here, before any star moves) and whose amount and currency match. A malformed
// reference is a decline, not an error.
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
var req telegramPreCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
if err != nil {
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
reason := ""
if !out.OK {
// Localise the decline to the order account's preferred language (the reason shows in the
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
lang := ""
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
lang = acc.PreferredLanguage
}
}
reason = telegramDeclineText(out.Reason, lang)
}
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
}
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
// anything else falls back to English), for display in the Telegram payment sheet.
func telegramDeclineText(code, lang string) string {
ru := lang == "ru"
switch code {
case payments.PreCheckoutAlreadyPaid:
if ru {
return "Этот заказ уже оплачен."
}
return "This order has already been paid."
case payments.PreCheckoutPriceChanged:
if ru {
return "Цена изменилась — начните покупку заново."
}
return "The price has changed; please start the purchase again."
default:
if ru {
return "Этот заказ больше недоступен."
}
return "This order is no longer available."
}
}
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
type telegramPaymentRequest struct {
OrderID string `json:"order_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
Amount int64 `json:"amount"`
TelegramUserID int64 `json:"telegram_user_id"`
}
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
// (the bot drops it); a 5xx means a transient failure the bot retries.
type telegramPaymentResponse struct {
Credited bool `json:"credited"`
}
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
// the bot retries.
func (s *Server) handleTelegramPayment(c *gin.Context) {
var req telegramPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
if err != nil {
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
// The star charge already happened but the order cannot be placed; record it loudly for
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
s.log.Error("telegram payment rejected (charge taken, not credited)",
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
}
+2 -65
View File
@@ -5,7 +5,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments" "scrabble/backend/internal/payments"
) )
@@ -21,15 +20,11 @@ type walletSegmentDTO struct {
// walletDTO is the user-facing wallet: the context-visible chip segments and the // walletDTO is the user-facing wallet: the context-visible chip segments and the
// context-applicable benefits (the no-ads term or forever flag, and the available hints). // context-applicable benefits (the no-ads term or forever flag, and the available hints).
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
// only when it is positive.
type walletDTO struct { type walletDTO struct {
Segments []walletSegmentDTO `json:"segments"` Segments []walletSegmentDTO `json:"segments"`
AdsForever bool `json:"ads_forever"` AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
Hints int `json:"hints"` Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
} }
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips. // walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
@@ -113,8 +108,7 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
} }
// handleWallet returns the caller's wallet — the segments and benefits visible in the current // handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when // trusted execution context.
// unconfigured), which gates the client's "watch for chips" button.
func (s *Server) handleWallet(c *gin.Context) { func (s *Server) handleWallet(c *gin.Context) {
uid, ok := userID(c) uid, ok := userID(c)
if !ok { if !ok {
@@ -131,13 +125,7 @@ func (s *Server) handleWallet(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
dto := walletDTOFrom(view) c.JSON(http.StatusOK, walletDTOFrom(view))
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
} else {
dto.RewardChips = payout
}
c.JSON(http.StatusOK, dto)
} }
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is // handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
@@ -174,54 +162,3 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
} }
c.JSON(http.StatusOK, walletDTOFrom(view)) c.JSON(http.StatusOK, walletDTOFrom(view))
} }
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce, the idempotency
// key for a single watched view (a retry credits once).
type walletRewardRequest struct {
Nonce string `json:"nonce"`
}
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
// updated wallet (like a spend).
func (s *Server) handleWalletReward(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletRewardRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
abortBadRequest(c, "nonce is required")
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
if err != nil {
s.abortErr(c, err)
return
}
if outcome.Capped {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
return
}
if outcome.Chips == 0 && !outcome.AlreadyCredited {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
view2 := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
view2.RewardChips = payout
}
c.JSON(http.StatusOK, view2)
}
-6
View File
@@ -31,7 +31,6 @@ import (
"scrabble/backend/internal/payments" "scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render" "scrabble/backend/internal/render"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
@@ -110,9 +109,6 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A // Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works). // nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
// order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
} }
// Server owns the gin engine, the underlying HTTP server and the readiness // Server owns the gin engine, the underlying HTTP server and the readiness
@@ -140,7 +136,6 @@ type Server struct {
banview *banview.View banview *banview.View
ads *ads.Service ads *ads.Service
payments *payments.Service payments *payments.Service
robokassa robokassa.Config
notifier notify.Publisher notifier notify.Publisher
console *adminconsole.Renderer console *adminconsole.Renderer
exportKey []byte exportKey []byte
@@ -194,7 +189,6 @@ func New(addr string, deps Deps) *Server {
banview: deps.BanView, banview: deps.BanView,
ads: deps.Ads, ads: deps.Ads,
payments: deps.Payments, payments: deps.Payments,
robokassa: deps.Robokassa,
notifier: notifier, notifier: notifier,
renderer: deps.Renderer, renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine}, http: &http.Server{Addr: addr, Handler: engine},
+12 -31
View File
@@ -236,7 +236,6 @@ The main host archives Postgres continuously with **pgBackRest** to **Selectel S
(encrypted at rest, path-style addressing) so the database can be restored to any moment — (encrypted at rest, path-style addressing) so the database can be restored to any moment —
protecting the money ledger and the game state against corruption or host loss. It is the protecting the money ledger and the game state against corruption or host loss. It is the
primary recovery path; the migration-window `pg_dump` above is the secondary net. primary recovery path; the migration-window `pg_dump` above is the secondary net.
**Live on the prod main host since v1.13.0** (armed + restore-drilled 2026-07-09).
**Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus **Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus
**continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every **continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every
@@ -259,9 +258,8 @@ absent/NaN-safe, so they stay quiet until archiving is armed.
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At `pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under 30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under
1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is 1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup is **negligible**: archive-push moves tiny compressed segments, and the daily full base backup
small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and checkpoint-bound is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch
(~1.5 min wall, minimal CPU/I-O) on the 2 vCPU host. Revisit if traffic grows ~100× (watch
`node_exporter` during a base backup). `node_exporter` during a base backup).
**Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on: **Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on:
@@ -278,22 +276,14 @@ small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and check
2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres 2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres
with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the
minute until step 3 creates the repository — the WAL is retained, not lost.) minute until step 3 creates the repository — the WAL is retained, not lost.)
3. On the main host, create the repository, verify archiving, take the first base backup. Run 3. On the main host, create the repository, take the first base backup, and verify:
pgBackRest **as the `postgres` OS user** (`-u postgres` — lock-dir/PGDATA consistency with
`archive-push`) and connect to the DB **as the superuser role** (`--pg1-user=scrabble`, the
`POSTGRES_USER`, not `postgres`); it inherits the container's `PGBACKREST_*` env:
```sh ```sh
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup docker exec scrabble-postgres pgbackrest --stanza=scrabble check
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble info # (info takes no --pg1-user)
``` ```
The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the 4. Enable the daily timer: `ansible-playbook site.yml -e pitr_enabled=true` (installs + starts
expected transient (WAL retained, not lost); clear the counter afterwards with `pgbackrest-backup.timer` on the main host).
`docker exec -u postgres scrabble-postgres psql -U scrabble -d scrabble -c "SELECT pg_stat_reset_shared('archiver');"`
so it does not trip the failing-archive alert.
4. Enable the daily timer: `ansible-playbook site.yml --limit main -e pitr_enabled=true`
(installs + starts `pgbackrest-backup.timer` on the main host).
5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a 5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a
real number, `failed_count` flat). real number, `failed_count` flat).
@@ -302,26 +292,17 @@ an **isolated, one-shot** target (a throwaway VM or a container with no ingress)
the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment: the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment:
```sh ```sh
# Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore
# empty PGDATA and no app network; restore, recover, verify, then wipe (-v drops the data). # start Postgres; it replays WAL to the target; then verify a known row / the ledger tail
IMG=$(docker inspect -f '{{.Config.Image}}' scrabble-postgres)
docker exec scrabble-postgres env | grep '^PGBACKREST_' > /tmp/drill.env; echo POSTGRES_PASSWORD=drill >> /tmp/drill.env
docker run -d --name pitr-drill --env-file /tmp/drill.env --entrypoint sleep "$IMG" infinity
docker exec pitr-drill sh -c 'rm -rf /var/lib/postgresql/data/*; chown -R postgres:postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data'
# --type=immediate = to the base backup's consistency point; --type=time "--target=<ts>" for PITR
docker exec -u postgres pitr-drill pgbackrest --stanza=scrabble --pg1-path=/var/lib/postgresql/data --type=immediate restore
docker exec -u postgres pitr-drill pg_ctl -D /var/lib/postgresql/data -w start
docker exec -u postgres pitr-drill psql -U scrabble -d scrabble -c "SELECT count(*) FROM backend.accounts;" # verify data intact
docker rm -fv pitr-drill; rm -f /tmp/drill.env # wipe the restored real data
``` ```
The target holds **real money + personal data** while it exists — keep it network-isolated and The target holds **real money + personal data** while it exists — keep it network-isolated and
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target **destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
timestamp, outcome) here: timestamp, outcome) here:
| Date | Target | Result | | Date | Target timestamp | Result |
| --- | --- | --- | | --- | --- | --- |
| 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. | | _pending first arming_ | — | — |
**bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the **bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
@@ -10,8 +10,6 @@ Requires=docker.service
[Service] [Service]
Type=oneshot Type=oneshot
# docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and # docker exec runs as the image's postgres user and inherits the container's PGBACKREST_*
# PGDATA consistency with archive-push, and connect to the database as the superuser role via # environment (the S3 repository + cipher), so no repository config is needed on the host.
# --pg1-user: that role is the POSTGRES_USER (scrabble), not "postgres". It inherits the ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
# container's PGBACKREST_* environment (the S3 repository + cipher), so no host-side config.
ExecStart=/usr/bin/docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
+1 -1
View File
@@ -86,7 +86,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any # The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request). # tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
handle @gateway { handle @gateway {
reverse_proxy gateway:8081 { reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot header_up -X-Scrabble-Honeypot
-4
View File
@@ -28,10 +28,6 @@ services:
# the manage-topics and delete-messages rights. # the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail; empty disables it. The rail stays inert until a chip pack carries an XTR (Stars)
# price, so it is safe to leave on — seeding a Stars price in the admin is the real go-live.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
-14
View File
@@ -161,13 +161,6 @@ services:
# recipient(s) (comma-separated allowed). Both empty disables the alert worker. # recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-} BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-} BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
# direct order and Result-callback endpoints unregistered (the rail is off).
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
# The dictionary lives on a named volume seeded from the image on first boot # The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the # inherits). The admin console writes new version subdirectories here, and the
@@ -207,9 +200,6 @@ services:
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-} VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
# elsewhere so production shows real ads).
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag). # Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
VERSION: ${APP_VERSION:-dev} VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
@@ -388,10 +378,6 @@ services:
# set the bot must be an admin there with the manage-topics and delete-messages rights. # set the bot must be an admin there with the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
# The optional standalone promo bot (its own token) answering /start with a button # The optional standalone promo bot (its own token) answering /start with a button
# into the main bot's app. Empty disables it; when set it needs the main bot's # into the main bot's app. Empty disables it; when set it needs the main bot's
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK). # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
-15
View File
@@ -18,25 +18,10 @@
@shell not path /assets/* @shell not path /assets/*
header @shell Cache-Control "no-cache" header @shell Cache-Control "no-cache"
# The static public offer page, rendered from ui/legal/offer_ru.md at build
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
# landing shell below (whose index is landing.html). A bare /offer redirects in.
handle /offer {
redir * /offer/ permanent
}
handle /offer/* {
file_server {
index index.html
}
}
# An unknown path falls back to the landing shell (the gateway's old "/" # An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below. # behaviour); "/" itself resolves through the index below.
handle {
try_files {path} /landing.html try_files {path} /landing.html
file_server { file_server {
index landing.html index landing.html
} }
}
} }
+2 -9
View File
@@ -971,13 +971,7 @@ the console; the backend calls them on the **gateway's bot-link relay**, which f
to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). It also joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional
carries the **Telegram Stars payment path** (§payments): a `CreateInvoice` command has the bot mint a
`createInvoiceLink` (XTR) and return it in the Ack; the bot's unary `ValidatePreCheckout` gates a
`pre_checkout_query` against the intake (declining an already-paid reusable invoice before the
charge); and its unary `ForwardPayment` delivers a completed `successful_payment` — durably queued in
a bot-side **SQLite outbox** (`platform/telegram/internal/outbox`, re-driven on restart) — which the
gateway proxies to the backend intake, credited once (idempotent on `telegram_payment_charge_id`). An optional
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot **standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app` answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway. button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
@@ -1334,8 +1328,7 @@ in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth a
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the **admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
catch-all — notably the landing at `/`, plus the static public offer at `/offer/` catch-all — notably the landing at `/` — goes to the landing container. The
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
**Telegram validator** runs as a separate container with **no public ingress**, **Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
+21 -48
View File
@@ -82,7 +82,7 @@ leaking out to the open web) is allowed.
| Execution context | Spendable chip segments | Spend priority | | Execution context | Spendable chip segments | Spend priority |
|---------------------|-------------------------|--------------------| |---------------------|-------------------------|--------------------|
| Inside VK (Android) | `vk` | — | | Inside VK (Android) | `vk` | — |
| Inside VK (iOS) | `vk` (purchase-frozen) | — | | Inside VK (iOS) | none — frozen (view only)| — |
| Inside Telegram | `telegram` | — | | Inside Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | | Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
@@ -90,11 +90,9 @@ leaking out to the open web) is allowed.
invisible-as-spendable there. invisible-as-spendable there.
- On the web the store has no jurisdiction, so all attached segments are spendable, drained - On the web the store has no jurisdiction, so all attached segments are spendable, drained
by priority direct → vk → tg. by priority direct → vk → tg.
- **VK iOS is a PURCHASE freeze, not a spend freeze.** Apple's ToS forbids only **buying** in-app - **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital
values there (for any currency) — so a purchase (money → chips) is refused. **Spending** VK-wallet goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or
chips (earned via rewarded ads or bought on the same VK account elsewhere, e.g. VK Android) and spend is possible. A previously bought benefit still *applies* there.
earning them are legal and stay allowed on VK iOS; a bought benefit also *applies* there. Only the
money-in step is blocked.
The account is **single** (identities merge, one profile/friends/stats). The gate is The account is **single** (identities merge, one profile/friends/stats). The gate is
**logical**: in a VK/TG context the server activates only the same-named segment. It rests **logical**: in a VK/TG context the server activates only the same-named segment. It rests
@@ -221,21 +219,12 @@ the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provide
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation — valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
the money is real, the chips are owed). The user sees only successful purchases. the money is real, the chips are owed). The user sees only successful purchases.
**TG Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse **TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
(backed by the intake): approve only if the order exists, is still creditable and is **not already intake = credited exactly once.
paid** — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
before the charge; the decline reason is localised to the order account's language.
`successful_payment` reaches the bot only (Bot API, not the Mini App), and the bot host is weak and
can lose connectivity, so the bot is a durable link. Store-and-forward on **SQLite** on the bot's
disk (`internal/outbox`): persist on receipt (idempotent on `telegram_payment_charge_id`) → forward
over the bot-link (a `ForwardPayment` unary; the gateway proxies to the intake) → on a durable
response, mark `forwarded`. Re-drives undelivered on restart and on a periodic tick. At-least-once
delivery + idempotent intake (dedup by `telegram_payment_charge_id`) = credited exactly once.
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a **Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
@@ -243,22 +232,14 @@ existing `botlink` push / email relay. "Payment failed" (an **active** provider
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
message). message).
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are **Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a account — tie into `accountdelete`, which already preserves messages). **External** refunds
deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and (chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine — **best-effort** revokes the benefit (never going negative; if chips were already spent, it
the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
payment id, so the two rows coexist under the same partial-unique index), and **best-effort revokes yet; the schema stays compatible).
the funded chips floored at 0** (never negative — D27, `balances_chips_chk`). When the chips were
already spent, the unrecoverable remainder is recorded as a per-account **loss + abuse flag**
(`payments.account_risk`, read by the E7 report). The refund ledger row's chip delta is what was
actually reclaimed, so the ledger stays reconcilable against the balance; the **full** reversal
(money, original chips, loss) rides in the row's snapshot. The order stays `paid` — the refund lives
in the ledger + a `refunded` payment event, not in the order status. A duplicate refund reverses
nothing. The ledger is **export-ready** for future tax reporting and reconciliation (the reconciler
itself is not built yet; the schema stays compatible).
## 10. Ads ## 10. Ads
@@ -268,17 +249,9 @@ ruble-paying in-app network exists. The ad provider is behind an **abstraction**
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob) network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
are rejected — no legal ruble income for a self-employed (НПД) developer. are rejected — no legal ruble income for a self-employed (НПД) developer.
**Rewarded** (voluntary video for chips) credits chips through payments. **VK Mini App ads expose **Rewarded** (voluntary video for chips) credits chips through payments on the network's
only a client-side watch result** (`VKWebAppShowNativeAds``data.result`) — there is no **server verify callback** (client not believed, like a payment). On-launch anti-fraud is
server-to-server verify — so a rewarded credit is **client-attested** (D29 amended: server verify is the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
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** (post-move fullscreen), configurable server-side values:
+2 -11
View File
@@ -110,11 +110,6 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) — сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) —
подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть
(`platform/telegram/internal/initdata`). (`platform/telegram/internal/initdata`).
**АМЕНД (E6, находка владельца по ToS):** VK iOS — **заморозка только ПОКУПОК** (деньги→Фишки),
а не траты. Apple запрещает там лишь **покупать** внутриигровые ценности за любую валюту; а
**тратить** Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте
в Android) и зарабатывать их — легально и на iOS. Код: `vkFrozen()` гейтит только `CreateOrder`
(покупку), не `spendableSources` (трату).
- **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без - **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без
валидной подписи на старте; старая сессия без записанной платформы) → запрет валидной подписи на старте; старая сессия без записанной платформы) → запрет
трат/покупок/применения чужого origin, только просмотр. трат/покупок/применения чужого origin, только просмотр.
@@ -163,13 +158,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
закладываем **абстракцией** (будущая крутилка для других платформ встроится без закладываем **абстракцией** (будущая крутилка для других платформ встроится без
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
дохода самозанятому (санкции + НПД не учитывает крипту). дохода самозанятому (санкции + НПД не учитывает крипту).
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments. Не - **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments по
**серверному verify-колбэку** рекламной сети (клиенту не верим, как платёж). Не
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики. гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
**АМЕНД (E6, по факту реализации):** у VK Mini App серверного verify-колбэка **нет**
`VKWebAppShowNativeAds` отдаёт только клиентский `data.result`. Поэтому начисление
**client-attested**, а защита (и экономический рычаг) — **серверный дневной и часовой кап**
(config `reward_daily_cap` / `reward_hourly_cap`, дефолт 50 / 10) + идемпотентность по клиентскому
nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения): - **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин** глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода (соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
+22 -46
View File
@@ -83,7 +83,7 @@
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты | | Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|----------------------|---------------------------|--------------------| |----------------------|---------------------------|--------------------|
| Внутри VK (Android) | `vk` | — | | Внутри VK (Android) | `vk` | — |
| Внутри VK (iOS) | `vk` (заморожена покупка) | — | | Внутри VK (iOS) | нет — заморожено (только просмотр) | — |
| Внутри Telegram | `telegram` | — | | Внутри Telegram | `telegram` | — |
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | | Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
@@ -91,11 +91,9 @@
`direct`) там невидимо как тратимое. `direct`) там невидимо как тратимое.
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по - В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
приоритету direct → vk → tg. приоритету direct → vk → tg.
- **VK iOS заморозка ПОКУПОК, а не траты.** ToS Apple запрещает там только **покупать** - **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые
внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата
**тратить** Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в невозможны. Ранее купленный бенефит там всё равно *действует*.
VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже
*действует*. Блокируется только шаг «деньги внутрь».
Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на **логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
@@ -222,22 +220,12 @@ provider_payment_id)`.
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена — Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки. деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
**TG Stars.** До Telegram дотягивается только **бот**, поэтому весь рельс идёт через обратный **Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
mTLS **bot-link** (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
заказа gateway шлёт команду `CreateInvoice`, а бот возвращает `createInvoiceLink` (XTR) в Ack, **SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
который Mini App открывает через `WebApp.openInvoice`. До списания звёзд бот отвечает на платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
`pre_checkout_query` через унарный вызов бот→gateway `ValidatePreCheckout` (за ним — приём): одобрить, `forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
только если заказ существует, ещё оплачиваем и **не оплачен ранее** — ссылка Stars-инвойса at-least-once + идемпотентный приём = начисление ровно один раз.
переиспользуема, так что этот гейт — единственное место, где повторная оплата отсекается **до**
списания; текст отказа локализован в язык аккаунта заказа.
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а хост бота
слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на **SQLite** на диске
бота (`internal/outbox`): сохранил при получении (идемпотентно по `telegram_payment_charge_id`) →
форвардит по bot-link (унарный `ForwardPayment`; gateway проксирует в приём) → при durable-ответе
пометил `forwarded`. Дореталивает недоставленное при рестарте и по периодическому тику. Доставка
at-least-once + идемпотентный приём (дедуп по `telegram_payment_charge_id`) = начисление ровно один
раз.
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded); **События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
@@ -245,20 +233,14 @@ at-least-once + идемпотентный приём (дедуп по `telegram
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
бота). бота).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует **Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК **ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund` (чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по **по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с **спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный (саму сверку пока не строим; схема остаётся совместимой).
остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не
отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности
и сверки (саму сверку пока не строим; схема остаётся совместимой).
## 10. Реклама ## 10. Реклама
@@ -268,16 +250,10 @@ web/native/TG держат только существующий наш **тек
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
рублёвого дохода самозанятому (НПД). рублёвого дохода самозанятому (НПД).
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен. **VK Mini **Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
App отдаёт только клиентский результат просмотра** (`VKWebAppShowNativeAds``data.result`) — **серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
серверной проверки нет — поэтому начисление **client-attested** (D29 амендим: server-verify у VK verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
невозможен). Защита — **серверный дневной и часовой кап** (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`) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения: **Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
+1 -5
View File
@@ -29,9 +29,6 @@ ARG VITE_VK_APP_ID=
ARG VITE_VK_ID_REDIRECT_URL= ARG VITE_VK_ID_REDIRECT_URL=
ARG VITE_GATEWAY_URL= ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION= ARG VITE_APP_VERSION=
# VITE_ADS_STUB=1 substitutes a toast stub for real ads (the test contour only); production leaves it
# empty so it always shows real ads (a failed real ad must not credit).
ARG VITE_ADS_STUB=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \ VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
@@ -39,8 +36,7 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_VK_APP_ID=$VITE_VK_APP_ID \ VITE_VK_APP_ID=$VITE_VK_APP_ID \
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \ VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION \ VITE_APP_VERSION=$VITE_APP_VERSION
VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script # Install with the lockfile first (the workspace file carries pnpm's build-script
# approval for esbuild), then build. Committed src/gen/ means no codegen here. # approval for esbuild), then build. Committed src/gen/ means no codegen here.
+1 -17
View File
@@ -153,16 +153,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
r, rerr := backend.ChatEligibility(ctx, externalID) r, rerr := backend.ChatEligibility(ctx, externalID)
return r.Registered, r.Eligible, rerr return r.Registered, r.Eligible, rerr
}) })
// The Telegram Stars payment bridge rides the same bot-link: the bot validates each
// pre_checkout and forwards each completed payment through these, backed by the backend intake.
botHub.SetPaymentBridge(
func(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
return backend.ValidatePreCheckout(ctx, orderID, amount, currency)
},
func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
return backend.TelegramPayment(ctx, orderID, chargeID, amount, telegramUserID)
},
)
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile) tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
if terr != nil { if terr != nil {
return terr return terr
@@ -209,12 +199,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI) vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
logger.Info("vk id web login enabled") logger.Info("vk id web login enabled")
} }
regOpts := []transcode.Option{transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)} registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
if botHub != nil {
// A Telegram-context wallet order mints its Stars invoice link through the connected bot.
regOpts = append(regOpts, transcode.WithTelegramStars(botHub))
}
registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
@@ -223,7 +208,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Tracker: tracker, Tracker: tracker,
Banlist: banlist, Banlist: banlist,
Honeytoken: cfg.Abuse.Honeytoken, Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub, Hub: hub,
RateLimit: cfg.RateLimit, RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval, Heartbeat: cfg.PushHeartbeatInterval,
+1 -99
View File
@@ -369,14 +369,12 @@ type WalletSegmentResp struct {
} }
// WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable // WalletResp is the caller's wallet: the context-visible chip segments and the context-applicable
// benefits (no-ads term end as unix millis / forever flag, and the available hints), plus the // benefits (no-ads term end as unix millis / forever flag, and the available hints).
// rewarded-video payout available in the current context (0 = unavailable — outside VK/unconfigured).
type WalletResp struct { type WalletResp struct {
Segments []WalletSegmentResp `json:"segments"` Segments []WalletSegmentResp `json:"segments"`
AdsForever bool `json:"ads_forever"` AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` AdsPaidUntil int64 `json:"ads_paid_until_ms"`
Hints int `json:"hints"` Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
} }
// walletBuyBody is the chip-spend request body. // walletBuyBody is the chip-spend request body.
@@ -384,19 +382,6 @@ type walletBuyBody struct {
ProductID string `json:"product_id"` ProductID string `json:"product_id"`
} }
// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce.
type walletRewardBody struct {
Nonce string `json:"nonce"`
}
// WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a
// backend daily/hourly cap). A reached cap or unconfigured payout surfaces as an APIError domain code.
func (c *Client) WalletReward(ctx context.Context, userID, nonce string) (WalletResp, error) {
var out WalletResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce}, &out)
return out, err
}
// Wallet fetches the caller's wallet in their current execution context. // Wallet fetches the caller's wallet in their current execution context.
func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) { func (c *Client) Wallet(ctx context.Context, userID string) (WalletResp, error) {
var out WalletResp var out WalletResp
@@ -411,89 +396,6 @@ func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (Walle
return out, err return out, err
} }
// walletOrderBody is the POST body of an order: the chip pack to fund.
type walletOrderBody struct {
ProductID string `json:"product_id"`
}
// WalletOrderResp is a created order: its id and the rail's launch details. RedirectURL is the
// provider's hosted-payment URL (direct); Rail names the settling rail; for the Telegram Stars rail
// InvoiceTitle and InvoiceAmount (whole stars) are the invoice the gateway mints via the bot.
type WalletOrderResp struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title"`
InvoiceAmount int64 `json:"invoice_amount"`
}
// WalletOrder opens a pending order to fund a chip pack and returns the rail's launch details.
func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) {
var out WalletOrderResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
return out, err
}
// preCheckoutResp is the backend intake's pre_checkout answer.
type preCheckoutResp struct {
OK bool `json:"ok"`
Reason string `json:"reason"`
}
// ValidatePreCheckout asks the backend intake whether a Telegram Stars pre_checkout_query for
// orderID paying amount in currency may be approved, before the charge. It returns the approval and
// a short decline reason for the payer. It backs the bot's pre_checkout gate through the bot-link.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
var out preCheckoutResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/precheckout", "", "",
map[string]any{"order_id": orderID, "amount": amount, "currency": currency}, &out)
return out.OK, out.Reason, err
}
// telegramPaymentResp is the backend intake's credit outcome.
type telegramPaymentResp struct {
Credited bool `json:"credited"`
}
// TelegramPayment forwards a completed Telegram Stars payment from the bot's outbox to the backend
// intake, which credits the order idempotently on chargeID. It reports whether the order was
// credited (or already had been); a transport error is a transient failure the bot retries.
func (c *Client) TelegramPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
var out telegramPaymentResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/payment", "", "",
map[string]any{
"order_id": orderID,
"telegram_payment_charge_id": chargeID,
"amount": amount,
"telegram_user_id": telegramUserID,
}, &out)
return out.Credited, err
}
// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa.
type robokassaResultResp struct {
Response string `json:"response"`
}
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
// single writer, which verifies the signature and credits) and returns the body to echo to
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
var out robokassaResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
return out.Response, err
}
// VKCallback forwards a gateway-verified VK payment callback's parameters to the backend intake and
// returns the backend's raw VK response envelope (`{"response":…}` or `{"error":…}`) for the gateway
// to relay to VK verbatim. The backend answers 200 in every case (VK's protocol), so a transport
// error here is a genuine proxy failure.
func (c *Client) VKCallback(ctx context.Context, params map[string]string) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/vk/callback", "", "", params, &out)
return out, err
}
// CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity.
type CatalogAtomResp struct { type CatalogAtomResp struct {
AtomType string `json:"atom_type"` AtomType string `json:"atom_type"`
-15
View File
@@ -48,18 +48,3 @@ func ChatGateCommand(externalID string, allow bool) *botlinkv1.Command {
}}, }},
} }
} }
// CreateInvoiceCommand builds a Telegram Stars invoice-mint command: the bot calls
// createInvoiceLink (in XTR) with the given title and description, the order id as the
// payload (echoed by Telegram in pre_checkout and successful_payment) and amountStars whole
// stars, and returns the link in its Ack result.
func CreateInvoiceCommand(title, description, orderID string, amountStars int64) *botlinkv1.Command {
return &botlinkv1.Command{
Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: title,
Description: description,
Payload: orderID,
Amount: amountStars,
}},
}
}
-120
View File
@@ -13,7 +13,6 @@ import (
"strconv" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
@@ -32,11 +31,6 @@ var ErrNoBot = errors.New("botlink: no bot connected")
// (at-most-once under backpressure). // (at-most-once under backpressure).
const outboundBuffer = 64 const outboundBuffer = 64
// invoiceMintTimeout bounds one synchronous invoice-mint round-trip (the gateway commands the bot
// and awaits the Ack carrying the createInvoiceLink result), so a hung or slow bot cannot stall the
// caller's wallet-order request indefinitely.
const invoiceMintTimeout = 15 * time.Second
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility // EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
// for the bot's join-time ResolveChatEligibility query: registered reports whether the // for the bot's join-time ResolveChatEligibility query: registered reports whether the
// identity maps to an account, eligible is the final gate the bot acts on (registered // identity maps to an account, eligible is the final gate the bot acts on (registered
@@ -44,17 +38,6 @@ const invoiceMintTimeout = 15 * time.Second
// chat-access endpoint. // chat-access endpoint.
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error) type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
// PreCheckoutResolver validates a Telegram Stars pre_checkout_query for the bot's
// ValidatePreCheckout query: it answers whether the order may still be charged (ok) and, when not,
// a short reason to show the payer. The gateway backs it with the backend intake.
type PreCheckoutResolver func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
// PaymentForwarder delivers a completed Telegram Stars payment for the bot's ForwardPayment call:
// it credits the order through the backend intake and reports whether it was credited (or already
// had been). A non-nil error is a transient failure the bot retries. The gateway backs it with the
// backend intake.
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
// Hub registers connected bots and routes send commands to them. A single bot is // Hub registers connected bots and routes send commands to them. A single bot is
// expected today; the registry already holds a set so adding more later needs no // expected today; the registry already holds a set so adding more later needs no
// rewrite. // rewrite.
@@ -63,10 +46,6 @@ type Hub struct {
log *zap.Logger log *zap.Logger
eligibility EligibilityResolver eligibility EligibilityResolver
// precheck and forward back the Telegram Stars payment RPCs; nil until SetPaymentBridge wires
// them, in which case the RPCs report Unavailable. Set once before the gRPC server serves.
precheck PreCheckoutResolver
forward PaymentForwarder
mu sync.Mutex mu sync.Mutex
links map[*link]struct{} links map[*link]struct{}
@@ -168,105 +147,6 @@ func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEli
return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil
} }
// SetPaymentBridge wires the Telegram Stars payment resolvers, backing ValidatePreCheckout and
// ForwardPayment. It must be called before the gRPC server starts serving (the fields are read
// without a lock, relying on that happens-before). A nil resolver leaves its RPC reporting
// Unavailable.
func (h *Hub) SetPaymentBridge(precheck PreCheckoutResolver, forward PaymentForwarder) {
h.precheck = precheck
h.forward = forward
}
// ValidatePreCheckout serves the bot's pre_checkout validation over the same mTLS channel: it
// delegates to the configured resolver (the backend intake) and returns whether the order may be
// charged. A resolver failure maps to Internal, which the bot treats as a decline (fail-closed).
func (h *Hub) ValidatePreCheckout(ctx context.Context, req *botlinkv1.PreCheckoutRequest) (*botlinkv1.PreCheckoutResponse, error) {
if h.precheck == nil {
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
}
ok, reason, err := h.precheck(ctx, req.GetOrderId(), req.GetAmount(), req.GetCurrency())
if err != nil {
h.log.Warn("validate pre_checkout failed", zap.String("order", req.GetOrderId()), zap.Error(err))
return nil, status.Error(codes.Internal, "validate pre_checkout")
}
return &botlinkv1.PreCheckoutResponse{Ok: ok, Reason: reason}, nil
}
// ForwardPayment serves the bot's completed-payment delivery: it credits the order through the
// configured forwarder (the backend intake) and reports the durable outcome. A forwarder failure
// maps to Internal, which the bot treats as transient and retries; a clean response (credited true
// or false) lets the bot forget the outbox row.
func (h *Hub) ForwardPayment(ctx context.Context, req *botlinkv1.ForwardPaymentRequest) (*botlinkv1.ForwardPaymentResponse, error) {
if h.forward == nil {
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
}
credited, err := h.forward(ctx, req.GetOrderId(), req.GetTelegramPaymentChargeId(), req.GetAmount(), req.GetTelegramUserId())
if err != nil {
h.log.Warn("forward telegram payment failed", zap.String("order", req.GetOrderId()), zap.Error(err))
return nil, status.Error(codes.Internal, "forward payment")
}
return &botlinkv1.ForwardPaymentResponse{Credited: credited}, nil
}
// MintInvoice commands the connected bot to mint a Telegram Stars invoice link for the order and
// returns the link. It is a bounded, synchronous round-trip (invoiceMintTimeout): no bot connected
// returns ErrNoBot, and a bot error or an empty link is an error the caller surfaces as a failed
// order launch.
func (h *Hub) MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error) {
cctx, cancel := context.WithTimeout(ctx, invoiceMintTimeout)
defer cancel()
ack, err := h.sendAwaitAck(cctx, CreateInvoiceCommand(title, description, orderID, amountStars))
if err != nil {
return "", err
}
if ack.GetResult() == "" {
return "", errors.New("botlink: bot returned an empty invoice link")
}
return ack.GetResult(), nil
}
// sendAwaitAck enqueues a command and waits for the bot's full Ack (or ctx). It mirrors SendAwait
// but returns the Ack — the invoice-mint path needs its result field — and reports ctx expiry as an
// error rather than a not-delivered, since a timed-out mint has no link to return.
func (h *Hub) sendAwaitAck(ctx context.Context, cmd *botlinkv1.Command) (*botlinkv1.Ack, error) {
l, ok := h.pick()
if !ok {
h.count("dropped")
return nil, ErrNoBot
}
id := h.nextID()
cmd.CommandId = id
ackc := make(chan *botlinkv1.Ack, 1)
h.mu.Lock()
h.pending[id] = ackc
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}()
select {
case l.out <- &botlinkv1.ToBot{Command: cmd}:
case <-ctx.Done():
h.count("dropped")
return nil, ctx.Err()
}
select {
case ack := <-ackc:
if e := ack.GetError(); e != "" {
h.count("error")
return nil, errors.New(e)
}
h.count(deliveredLabel(ack.GetDelivered()))
return ack, nil
case <-ctx.Done():
h.count("error")
return nil, ctx.Err()
}
}
// register adds a connected bot. // register adds a connected bot.
func (h *Hub) register(l *link) { func (h *Hub) register(l *link) {
h.mu.Lock() h.mu.Lock()
-108
View File
@@ -31,7 +31,6 @@ import (
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session" "scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode" "scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkpay"
"scrabble/gateway/internal/webui" "scrabble/gateway/internal/webui"
edgev1 "scrabble/gateway/proto/edge/v1" edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect" "scrabble/gateway/proto/edge/v1/edgev1connect"
@@ -78,7 +77,6 @@ type Server struct {
tracker *ratelimit.Tracker tracker *ratelimit.Tracker
banlist *ratelimit.Banlist banlist *ratelimit.Banlist
honeytoken string honeytoken string
vkAppSecret string
hub *push.Hub hub *push.Hub
heartbeat time.Duration heartbeat time.Duration
log *zap.Logger log *zap.Logger
@@ -112,9 +110,6 @@ type Deps struct {
// Honeytoken, when non-empty, is the planted bearer value whose presentation // Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm. // bans the caller and raises a high-severity alarm.
Honeytoken string Honeytoken string
// 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 Hub *push.Hub
RateLimit config.RateLimitConfig RateLimit config.RateLimitConfig
Heartbeat time.Duration Heartbeat time.Duration
@@ -156,7 +151,6 @@ func NewServer(d Deps) *Server {
registry: d.Registry, registry: d.Registry,
sessions: d.Sessions, sessions: d.Sessions,
backend: d.Backend, backend: d.Backend,
vkAppSecret: d.VKAppSecret,
limiter: limiter, limiter: limiter,
tracker: tracker, tracker: tracker,
banlist: banlist, banlist: banlist,
@@ -202,12 +196,6 @@ func (s *Server) HTTPHandler() http.Handler {
// through this session-gated route (not public); see dictBytesHandler. // through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler()) mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
// The client posts its local-move-preview adoption telemetry here (session-gated). // The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine // The index.html boot guard beacons here when it turns a client away on the unsupported-engine
@@ -494,102 +482,6 @@ func (s *Server) exportDownloadHandler() http.Handler {
}) })
} }
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
// so it retries. The gateway does not verify the signature — the backend holds the secret.
func (s *Server) robokassaResultHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
resp, err := s.backend.RobokassaResult(r.Context(), params)
if err != nil {
s.log.Warn("robokassa result proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(resp))
})
}
// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits
// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret),
// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or
// bad signature is rejected before reaching the backend.
func (s *Server) vkCallbackHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "vk-callback")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
if !vkpay.Verify(params, s.vkAppSecret) {
s.log.Warn("vk callback: bad signature")
http.Error(w, "bad signature", http.StatusForbidden)
return
}
resp, err := s.backend.VKCallback(r.Context(), params)
if err != nil {
s.log.Warn("vk callback proxy failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(resp)
})
}
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
// return. The payment opens in a separate window, so on return this closes that window and drops the
// customer back into the live app (never a cold app start); a link is the fallback if the window
// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet
// updates in place from the payment push, with a return-focus refetch as the fallback.
func (s *Server) robokassaReturnHandler(message string) http.Handler {
page := []byte(`<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">
<p style="font-size: 1.1rem">` + message + `</p>
<p style="color: #5b6472">Можно закрыть это окно и вернуться в приложение.</p>
<p><a href="/app/">Открыть приложение</a></p>
<script>setTimeout(function () { try { window.close(); } catch (e) {} }, 1200);</script>
</body></html>`)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(page)
})
}
func (s *Server) dictBytesHandler() http.Handler { func (s *Server) dictBytesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil { if s.backend == nil {
-14
View File
@@ -37,19 +37,6 @@ func encodeAck(ok bool) []byte {
return b.FinishedBytes() return b.FinishedBytes()
} }
// encodeWalletOrder builds a WalletOrderResponse payload: the created order id and the provider
// launch URL the client opens.
func encodeWalletOrder(o backendclient.WalletOrderResp) []byte {
b := flatbuffers.NewBuilder(128)
oid := b.CreateString(o.OrderID)
url := b.CreateString(o.RedirectURL)
fb.WalletOrderResponseStart(b)
fb.WalletOrderResponseAddOrderId(b, oid)
fb.WalletOrderResponseAddRedirectUrl(b, url)
b.Finish(fb.WalletOrderResponseEnd(b))
return b.FinishedBytes()
}
// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which // encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which
// deletion step-up the account uses ("email" | "phrase"). // deletion step-up the account uses ("email" | "phrase").
func encodeDeleteRequestResult(method string) []byte { func encodeDeleteRequestResult(method string) []byte {
@@ -116,7 +103,6 @@ func encodeWallet(w backendclient.WalletResp) []byte {
fb.WalletAddAdsForever(b, w.AdsForever) fb.WalletAddAdsForever(b, w.AdsForever)
fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil) fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil)
fb.WalletAddHints(b, int32(w.Hints)) fb.WalletAddHints(b, int32(w.Hints))
fb.WalletAddRewardChips(b, int32(w.RewardChips))
b.Finish(fb.WalletEnd(b)) b.Finish(fb.WalletEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
-63
View File
@@ -55,8 +55,6 @@ const (
MsgWalletGet = "wallet.get" MsgWalletGet = "wallet.get"
MsgWalletCatalog = "wallet.catalog" MsgWalletCatalog = "wallet.catalog"
MsgWalletBuy = "wallet.buy" MsgWalletBuy = "wallet.buy"
MsgWalletOrder = "wallet.order"
MsgWalletReward = "wallet.reward"
) )
// Request is one decoded Execute call. // Request is one decoded Execute call.
@@ -113,8 +111,6 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true} r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true} r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true} r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
r.ops[MsgWalletReward] = Op{Handler: walletRewardHandler(backend), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
@@ -172,17 +168,6 @@ func WithVKLink(ex VKIDExchanger) Option {
} }
} }
// WithTelegramStars re-registers the wallet.order op with a Telegram Stars invoice minter (the
// bot-link), so a Telegram-context order mints its Stars invoice link through the bot. A nil minter
// is a no-op, leaving the default (Stars-unavailable) handler in place.
func WithTelegramStars(minter InvoiceMinter) Option {
return func(r *Registry, backend *backendclient.Client) {
if minter != nil {
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, minter), Auth: true}
}
}
}
// Lookup returns the operation for messageType, and whether it is registered. // Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) { func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType] op, ok := r.ops[messageType]
@@ -346,54 +331,6 @@ func walletBuyHandler(backend *backendclient.Client) Handler {
} }
} }
// InvoiceMinter mints a Telegram Stars invoice link for a created order via the bot (only the bot
// reaches Telegram). The gateway bot-link Hub implements it; it is nil where the bot-link is off,
// which leaves the Telegram rail unavailable.
type InvoiceMinter interface {
MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error)
}
// errTelegramStarsUnavailable is returned when a Telegram Stars order is requested but no invoice
// minter (the bot-link) is wired — the rail is not available on this gateway.
var errTelegramStarsUnavailable = errors.New("telegram stars rail unavailable")
func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletOrderRequest(req.Payload, 0)
o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId()))
if err != nil {
return nil, err
}
// Telegram Stars: mint the invoice link here via the bot-link and return it to the client
// as the redirect URL it opens with WebApp.openInvoice. The title doubles as the invoice
// description (both are required and the pack title is the whole product).
if o.Rail == "telegram" {
if minter == nil {
return nil, errTelegramStarsUnavailable
}
link, merr := minter.MintInvoice(ctx, o.InvoiceTitle, o.InvoiceTitle, o.OrderID, o.InvoiceAmount)
if merr != nil {
return nil, merr
}
o.RedirectURL = link
}
return encodeWalletOrder(o), nil
}
}
// walletRewardHandler credits a watched rewarded video and returns the updated wallet. The nonce is
// the client's per-view idempotency key.
func walletRewardHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletRewardRequest(req.Payload, 0)
w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()))
if err != nil {
return nil, err
}
return encodeWallet(w), nil
}
}
func blockStatusHandler(backend *backendclient.Client) Handler { func blockStatusHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
bs, err := backend.BlockStatus(ctx, req.UserID) bs, err := backend.BlockStatus(ctx, req.UserID)
-40
View File
@@ -1,40 +0,0 @@
// Package vkpay verifies VK Mini Apps payment ("голоса") callback signatures. VK signs each
// payment notification (get_item / order_status_change) and expects the receiving server to verify
// it with the app's secret before acting. The signature is MD5 — mandated by VK's payment protocol,
// not a security choice on our part — so this package uses crypto/md5 deliberately.
package vkpay
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
)
// Verify reports whether params carry a valid VK payment signature under secret. VK computes the
// signature as the MD5 of the sig-excluded parameters, sorted alphabetically by name and
// concatenated as key=value with no separators, with the app secret appended. The comparison is
// case-insensitive over the hex digest.
func Verify(params map[string]string, secret string) bool {
got := params["sig"]
if got == "" || secret == "" {
return false
}
keys := make([]string, 0, len(params))
for k := range params {
if k == "sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteString("=")
b.WriteString(params[k])
}
b.WriteString(secret)
sum := md5.Sum([]byte(b.String()))
return strings.EqualFold(hex.EncodeToString(sum[:]), got)
}
-74
View File
@@ -1,74 +0,0 @@
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")
}
}
+3 -32
View File
@@ -73,13 +73,10 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk= github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk=
github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw= github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw=
github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4= github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4=
@@ -93,13 +90,11 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
@@ -114,7 +109,6 @@ github.com/moby/sys/reexec v0.1.0 h1:RrBi8e0EBTLEgfruBOFcxtElzRGTEUkeIFaVXgU7wok
github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8=
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
@@ -152,6 +146,8 @@ github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5s
github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY= github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY=
github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk= github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg= github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -180,7 +176,6 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
@@ -188,14 +183,10 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
@@ -209,26 +200,6 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/ccorpus2 v1.6.0/go.mod h1:Wifvo4Q/qS/h1aRoC2TffcHsnxwTikmi1AuLANuucJQ=
modernc.org/ebnf v1.1.0/go.mod h1:CNIo7vuji3SyjIP/VhEumIKlAguC1g64mcdk/+VJW/w=
modernc.org/ebnfutil v1.1.0/go.mod h1:hdAyhM1jZSq9ygKhEeYgerbagyuLxyxzXcakBPyNqUI=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/lex v1.1.1/go.mod h1:6r8o8DLJkAnOsQaGi8fMoi+Vt6LTbDaCrkUK729D8xM=
modernc.org/lexer v1.0.4/go.mod h1:tOajb8S4sdfOYitzCgXDFmbVJ/LE0v1fNJ7annTw36U=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/scannertest v1.0.2/go.mod h1:RzTm5RwglF/6shsKoEivo8N91nQIoWtcWI7ns+zPyGA=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
-21
View File
@@ -866,9 +866,6 @@ table Wallet {
ads_forever:bool; ads_forever:bool;
ads_paid_until_ms:long; ads_paid_until_ms:long;
hints:int; hints:int;
// reward_chips is the chips a rewarded-video view earns in the current context (0 = unavailable
// here — outside VK, or unconfigured); the client shows the "watch for chips" button only when > 0.
reward_chips:int;
} }
// WalletBuyRequest buys a chip-priced value with chips: the product to buy. // WalletBuyRequest buys a chip-priced value with chips: the product to buy.
@@ -876,24 +873,6 @@ table WalletBuyRequest {
product_id:string; product_id:string;
} }
// WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a
// retry credits once).
table WalletRewardRequest {
nonce:string;
}
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
table WalletOrderRequest {
product_id:string;
}
// WalletOrderResponse returns the created order id and the provider launch URL the client opens
// (the Robokassa hosted-payment page); chips are credited later, by the verified server callback.
table WalletOrderResponse {
order_id:string;
redirect_url:string;
}
// CatalogAtom is one atom line of a storefront product: the base value type it grants // CatalogAtom is one atom line of a storefront product: the base value type it grants
// ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries. // ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries.
table CatalogAtom { table CatalogAtom {
+1 -16
View File
@@ -97,20 +97,8 @@ func (rcv *Wallet) MutateHints(n int32) bool {
return rcv._tab.MutateInt32Slot(10, n) return rcv._tab.MutateInt32Slot(10, n)
} }
func (rcv *Wallet) RewardChips() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *Wallet) MutateRewardChips(n int32) bool {
return rcv._tab.MutateInt32Slot(12, n)
}
func WalletStart(builder *flatbuffers.Builder) { func WalletStart(builder *flatbuffers.Builder) {
builder.StartObject(5) builder.StartObject(4)
} }
func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) { func WalletAddSegments(builder *flatbuffers.Builder, segments flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(segments), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(segments), 0)
@@ -127,9 +115,6 @@ func WalletAddAdsPaidUntilMs(builder *flatbuffers.Builder, adsPaidUntilMs int64)
func WalletAddHints(builder *flatbuffers.Builder, hints int32) { func WalletAddHints(builder *flatbuffers.Builder, hints int32) {
builder.PrependInt32Slot(3, hints, 0) builder.PrependInt32Slot(3, hints, 0)
} }
func WalletAddRewardChips(builder *flatbuffers.Builder, rewardChips int32) {
builder.PrependInt32Slot(4, rewardChips, 0)
}
func WalletEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func WalletEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
-60
View File
@@ -1,60 +0,0 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletOrderRequest struct {
_tab flatbuffers.Table
}
func GetRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletOrderRequest{}
x.Init(buf, n+offset)
return x
}
func FinishWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletOrderRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletOrderRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletOrderRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletOrderRequest) ProductId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletOrderRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func WalletOrderRequestAddProductId(builder *flatbuffers.Builder, productId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(productId), 0)
}
func WalletOrderRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
-71
View File
@@ -1,71 +0,0 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletOrderResponse struct {
_tab flatbuffers.Table
}
func GetRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletOrderResponse{}
x.Init(buf, n+offset)
return x
}
func FinishWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletOrderResponse{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletOrderResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletOrderResponse) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletOrderResponse) OrderId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *WalletOrderResponse) RedirectUrl() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletOrderResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func WalletOrderResponseAddOrderId(builder *flatbuffers.Builder, orderId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(orderId), 0)
}
func WalletOrderResponseAddRedirectUrl(builder *flatbuffers.Builder, redirectUrl flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(redirectUrl), 0)
}
func WalletOrderResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
-60
View File
@@ -1,60 +0,0 @@
// 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()
}
+24 -392
View File
@@ -225,7 +225,6 @@ type Command struct {
// *Command_SendToUser // *Command_SendToUser
// *Command_SendToChannel // *Command_SendToChannel
// *Command_ChatGate // *Command_ChatGate
// *Command_CreateInvoice
Payload isCommand_Payload `protobuf_oneof:"payload"` Payload isCommand_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
@@ -311,15 +310,6 @@ func (x *Command) GetChatGate() *ChatGateCommand {
return nil return nil
} }
func (x *Command) GetCreateInvoice() *CreateInvoiceCommand {
if x != nil {
if x, ok := x.Payload.(*Command_CreateInvoice); ok {
return x.CreateInvoice
}
}
return nil
}
type isCommand_Payload interface { type isCommand_Payload interface {
isCommand_Payload() isCommand_Payload()
} }
@@ -340,10 +330,6 @@ type Command_ChatGate struct {
ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"` ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"`
} }
type Command_CreateInvoice struct {
CreateInvoice *CreateInvoiceCommand `protobuf:"bytes,6,opt,name=create_invoice,json=createInvoice,proto3,oneof"`
}
func (*Command_Notify) isCommand_Payload() {} func (*Command_Notify) isCommand_Payload() {}
func (*Command_SendToUser) isCommand_Payload() {} func (*Command_SendToUser) isCommand_Payload() {}
@@ -352,20 +338,15 @@ func (*Command_SendToChannel) isCommand_Payload() {}
func (*Command_ChatGate) isCommand_Payload() {} func (*Command_ChatGate) isCommand_Payload() {}
func (*Command_CreateInvoice) isCommand_Payload() {}
// Ack reports the outcome of the Command with command_id. delivered mirrors the // Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the // connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an // user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered. result // unexpected transport/render failure, distinct from a clean not-delivered.
// carries a command's return value when it has one (the created invoice link for a
// create_invoice command); it is empty otherwise.
type Ack struct { type Ack struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"` Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"`
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
Result string `protobuf:"bytes,4,opt,name=result,proto3" json:"result,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -421,13 +402,6 @@ func (x *Ack) GetError() string {
return "" return ""
} }
func (x *Ack) GetResult() string {
if x != nil {
return x.Result
}
return ""
}
// ChatGateCommand sets a Telegram user's write access in the moderated discussion // ChatGateCommand sets a Telegram user's write access in the moderated discussion
// chat. external_id is the user's Telegram identity (as in the backend identities // chat. external_id is the user's Telegram identity (as in the backend identities
// table); allow grants the right to write when true and revokes it when false. The // table); allow grants the right to write when true and revokes it when false. The
@@ -588,314 +562,6 @@ func (x *ChatEligibilityResponse) GetEligible() bool {
return false return false
} }
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
// echoes back in the pre_checkout_query and the successful_payment; amount is the
// price in whole stars; title and description are shown on the invoice. The bot
// returns the link in its Ack result.
type CreateInvoiceCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
Payload string `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateInvoiceCommand) Reset() {
*x = CreateInvoiceCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateInvoiceCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateInvoiceCommand) ProtoMessage() {}
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
}
func (x *CreateInvoiceCommand) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *CreateInvoiceCommand) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *CreateInvoiceCommand) GetPayload() string {
if x != nil {
return x.Payload
}
return ""
}
func (x *CreateInvoiceCommand) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
// (whole stars) in currency may be approved before the charge.
type PreCheckoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreCheckoutRequest) Reset() {
*x = PreCheckoutRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreCheckoutRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreCheckoutRequest) ProtoMessage() {}
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
}
func (x *PreCheckoutRequest) GetOrderId() string {
if x != nil {
return x.OrderId
}
return ""
}
func (x *PreCheckoutRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *PreCheckoutRequest) GetCurrency() string {
if x != nil {
return x.Currency
}
return ""
}
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
// short user-facing decline message when ok is false.
type PreCheckoutResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreCheckoutResponse) Reset() {
*x = PreCheckoutResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreCheckoutResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreCheckoutResponse) ProtoMessage() {}
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
}
func (x *PreCheckoutResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *PreCheckoutResponse) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
// and the payer's Telegram user id.
type ForwardPaymentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
TelegramPaymentChargeId string `protobuf:"bytes,2,opt,name=telegram_payment_charge_id,json=telegramPaymentChargeId,proto3" json:"telegram_payment_charge_id,omitempty"`
Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"`
TelegramUserId int64 `protobuf:"varint,4,opt,name=telegram_user_id,json=telegramUserId,proto3" json:"telegram_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ForwardPaymentRequest) Reset() {
*x = ForwardPaymentRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ForwardPaymentRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ForwardPaymentRequest) ProtoMessage() {}
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
}
func (x *ForwardPaymentRequest) GetOrderId() string {
if x != nil {
return x.OrderId
}
return ""
}
func (x *ForwardPaymentRequest) GetTelegramPaymentChargeId() string {
if x != nil {
return x.TelegramPaymentChargeId
}
return ""
}
func (x *ForwardPaymentRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *ForwardPaymentRequest) GetTelegramUserId() int64 {
if x != nil {
return x.TelegramUserId
}
return 0
}
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
// was credited (or already had been); false means the payment was recorded but could
// not be matched to a creditable order (an operator follows up). Either way the bot
// may forget the outbox row — only a transport error triggers a retry.
type ForwardPaymentResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Credited bool `protobuf:"varint,1,opt,name=credited,proto3" json:"credited,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ForwardPaymentResponse) Reset() {
*x = ForwardPaymentResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ForwardPaymentResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ForwardPaymentResponse) ProtoMessage() {}
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
}
func (x *ForwardPaymentResponse) GetCredited() bool {
if x != nil {
return x.Credited
}
return false
}
var File_botlink_v1_botlink_proto protoreflect.FileDescriptor var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" + const file_botlink_v1_botlink_proto_rawDesc = "" +
@@ -910,7 +576,7 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\x05Hello\x12\x1f\n" + "\x05Hello\x12\x1f\n" +
"\vinstance_id\x18\x01 \x01(\tR\n" + "\vinstance_id\x18\x01 \x01(\tR\n" +
"instanceId\x12!\n" + "instanceId\x12!\n" +
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xb2\x03\n" + "\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xde\x02\n" +
"\aCommand\x12\x1d\n" + "\aCommand\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" +
@@ -918,15 +584,13 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" + "\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" +
"sendToUser\x12X\n" + "sendToUser\x12X\n" +
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" + "\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" +
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGate\x12R\n" + "\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGateB\t\n" +
"\x0ecreate_invoice\x18\x06 \x01(\v2).scrabble.botlink.v1.CreateInvoiceCommandH\x00R\rcreateInvoiceB\t\n" + "\apayload\"X\n" +
"\apayload\"p\n" +
"\x03Ack\x12\x1d\n" + "\x03Ack\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
"\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" + "\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\x12\x16\n" + "\x05error\x18\x03 \x01(\tR\x05error\"H\n" +
"\x06result\x18\x04 \x01(\tR\x06result\"H\n" +
"\x0fChatGateCommand\x12\x1f\n" + "\x0fChatGateCommand\x12\x1f\n" +
"\vexternal_id\x18\x01 \x01(\tR\n" + "\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x14\n" + "externalId\x12\x14\n" +
@@ -938,31 +602,10 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\n" + "\n" +
"registered\x18\x01 \x01(\bR\n" + "registered\x18\x01 \x01(\bR\n" +
"registered\x12\x1a\n" + "registered\x12\x1a\n" +
"\beligible\x18\x02 \x01(\bR\beligible\"\x80\x01\n" + "\beligible\x18\x02 \x01(\bR\beligible2\xc4\x01\n" +
"\x14CreateInvoiceCommand\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x18\n" +
"\apayload\x18\x03 \x01(\tR\apayload\x12\x16\n" +
"\x06amount\x18\x04 \x01(\x03R\x06amount\"c\n" +
"\x12PreCheckoutRequest\x12\x19\n" +
"\border_id\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06amount\x18\x02 \x01(\x03R\x06amount\x12\x1a\n" +
"\bcurrency\x18\x03 \x01(\tR\bcurrency\"=\n" +
"\x13PreCheckoutResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\"\xb1\x01\n" +
"\x15ForwardPaymentRequest\x12\x19\n" +
"\border_id\x18\x01 \x01(\tR\aorderId\x12;\n" +
"\x1atelegram_payment_charge_id\x18\x02 \x01(\tR\x17telegramPaymentChargeId\x12\x16\n" +
"\x06amount\x18\x03 \x01(\x03R\x06amount\x12(\n" +
"\x10telegram_user_id\x18\x04 \x01(\x03R\x0etelegramUserId\"4\n" +
"\x16ForwardPaymentResponse\x12\x1a\n" +
"\bcredited\x18\x01 \x01(\bR\bcredited2\x99\x03\n" +
"\aBotLink\x12D\n" + "\aBotLink\x12D\n" +
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" + "\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" +
"\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponse\x12h\n" + "\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
"\x13ValidatePreCheckout\x12'.scrabble.botlink.v1.PreCheckoutRequest\x1a(.scrabble.botlink.v1.PreCheckoutResponse\x12i\n" +
"\x0eForwardPayment\x12*.scrabble.botlink.v1.ForwardPaymentRequest\x1a+.scrabble.botlink.v1.ForwardPaymentResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
var ( var (
file_botlink_v1_botlink_proto_rawDescOnce sync.Once file_botlink_v1_botlink_proto_rawDescOnce sync.Once
@@ -976,7 +619,7 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
return file_botlink_v1_botlink_proto_rawDescData return file_botlink_v1_botlink_proto_rawDescData
} }
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_botlink_v1_botlink_proto_goTypes = []any{ var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot (*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot (*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
@@ -986,37 +629,27 @@ var file_botlink_v1_botlink_proto_goTypes = []any{
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand (*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest (*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse (*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand (*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest (*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest
(*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse (*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
} }
var file_botlink_v1_botlink_proto_depIdxs = []int32{ var file_botlink_v1_botlink_proto_depIdxs = []int32{
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello 2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack 4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command 3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest 8, // 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 9, // 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 10, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand 5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand 0, // 7: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot 6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest 1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest 7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest 9, // [9:11] is the sub-list for method output_type
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot 7, // [7:9] is the sub-list for method input_type
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse 7, // [7:7] is the sub-list for extension type_name
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse 7, // [7:7] is the sub-list for extension extendee
12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse 0, // [0:7] is the sub-list for field type_name
12, // [12:16] is the sub-list for method output_type
8, // [8:12] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
} }
func init() { file_botlink_v1_botlink_proto_init() } func init() { file_botlink_v1_botlink_proto_init() }
@@ -1033,7 +666,6 @@ func file_botlink_v1_botlink_proto_init() {
(*Command_SendToUser)(nil), (*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil), (*Command_SendToChannel)(nil),
(*Command_ChatGate)(nil), (*Command_ChatGate)(nil),
(*Command_CreateInvoice)(nil),
} }
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
@@ -1041,7 +673,7 @@ func file_botlink_v1_botlink_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 13, NumMessages: 8,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
+1 -68
View File
@@ -27,24 +27,6 @@ service BotLink {
// same mTLS channel when a user joins the chat, to decide whether to grant the // same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort). // write permission. Delivery of the answer is request/response (not best-effort).
rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse); rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse);
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
rpc ValidatePreCheckout(PreCheckoutRequest) returns (PreCheckoutResponse);
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
rpc ForwardPayment(ForwardPaymentRequest) returns (ForwardPaymentResponse);
} }
// FromBot is a message the bot sends to the gateway: the opening Hello, then one // FromBot is a message the bot sends to the gateway: the opening Hello, then one
@@ -79,21 +61,17 @@ message Command {
scrabble.telegram.v1.SendToUserRequest send_to_user = 3; scrabble.telegram.v1.SendToUserRequest send_to_user = 3;
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4; scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
ChatGateCommand chat_gate = 5; ChatGateCommand chat_gate = 5;
CreateInvoiceCommand create_invoice = 6;
} }
} }
// Ack reports the outcome of the Command with command_id. delivered mirrors the // Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the // connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an // user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered. result // unexpected transport/render failure, distinct from a clean not-delivered.
// carries a command's return value when it has one (the created invoice link for a
// create_invoice command); it is empty otherwise.
message Ack { message Ack {
string command_id = 1; string command_id = 1;
bool delivered = 2; bool delivered = 2;
string error = 3; string error = 3;
string result = 4;
} }
// ChatGateCommand sets a Telegram user's write access in the moderated discussion // ChatGateCommand sets a Telegram user's write access in the moderated discussion
@@ -121,48 +99,3 @@ message ChatEligibilityResponse {
bool registered = 1; bool registered = 1;
bool eligible = 2; bool eligible = 2;
} }
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
// echoes back in the pre_checkout_query and the successful_payment; amount is the
// price in whole stars; title and description are shown on the invoice. The bot
// returns the link in its Ack result.
message CreateInvoiceCommand {
string title = 1;
string description = 2;
string payload = 3;
int64 amount = 4;
}
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
// (whole stars) in currency may be approved before the charge.
message PreCheckoutRequest {
string order_id = 1;
int64 amount = 2;
string currency = 3;
}
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
// short user-facing decline message when ok is false.
message PreCheckoutResponse {
bool ok = 1;
string reason = 2;
}
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
// and the payer's Telegram user id.
message ForwardPaymentRequest {
string order_id = 1;
string telegram_payment_charge_id = 2;
int64 amount = 3;
int64 telegram_user_id = 4;
}
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
// was credited (or already had been); false means the payment was recorded but could
// not be matched to a creditable order (an operator follows up). Either way the bot
// may forget the outbox row only a transport error triggers a retry.
message ForwardPaymentResponse {
bool credited = 1;
}
-104
View File
@@ -28,8 +28,6 @@ const _ = grpc.SupportPackageIsVersion9
const ( const (
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link" BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility" BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility"
BotLink_ValidatePreCheckout_FullMethodName = "/scrabble.botlink.v1.BotLink/ValidatePreCheckout"
BotLink_ForwardPayment_FullMethodName = "/scrabble.botlink.v1.BotLink/ForwardPayment"
) )
// BotLinkClient is the client API for BotLink service. // BotLinkClient is the client API for BotLink service.
@@ -50,22 +48,6 @@ type BotLinkClient interface {
// same mTLS channel when a user joins the chat, to decide whether to grant the // same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort). // write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error) ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error)
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error)
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error)
} }
type botLinkClient struct { type botLinkClient struct {
@@ -99,26 +81,6 @@ func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatElig
return out, nil return out, nil
} }
func (c *botLinkClient) ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PreCheckoutResponse)
err := c.cc.Invoke(ctx, BotLink_ValidatePreCheckout_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *botLinkClient) ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ForwardPaymentResponse)
err := c.cc.Invoke(ctx, BotLink_ForwardPayment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// BotLinkServer is the server API for BotLink service. // BotLinkServer is the server API for BotLink service.
// All implementations must embed UnimplementedBotLinkServer // All implementations must embed UnimplementedBotLinkServer
// for forward compatibility. // for forward compatibility.
@@ -137,22 +99,6 @@ type BotLinkServer interface {
// same mTLS channel when a user joins the chat, to decide whether to grant the // same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort). // write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error)
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error)
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error)
mustEmbedUnimplementedBotLinkServer() mustEmbedUnimplementedBotLinkServer()
} }
@@ -169,12 +115,6 @@ func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot])
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) { func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented") return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented")
} }
func (UnimplementedBotLinkServer) ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidatePreCheckout not implemented")
}
func (UnimplementedBotLinkServer) ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ForwardPayment not implemented")
}
func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {} func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {}
func (UnimplementedBotLinkServer) testEmbeddedByValue() {} func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
@@ -221,42 +161,6 @@ func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _BotLink_ValidatePreCheckout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PreCheckoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BotLinkServer).ValidatePreCheckout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BotLink_ValidatePreCheckout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BotLinkServer).ValidatePreCheckout(ctx, req.(*PreCheckoutRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BotLink_ForwardPayment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ForwardPaymentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BotLinkServer).ForwardPayment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BotLink_ForwardPayment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BotLinkServer).ForwardPayment(ctx, req.(*ForwardPaymentRequest))
}
return interceptor(ctx, in, info, handler)
}
// BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service. // BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -268,14 +172,6 @@ var BotLink_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveChatEligibility", MethodName: "ResolveChatEligibility",
Handler: _BotLink_ResolveChatEligibility_Handler, Handler: _BotLink_ResolveChatEligibility_Handler,
}, },
{
MethodName: "ValidatePreCheckout",
Handler: _BotLink_ValidatePreCheckout_Handler,
},
{
MethodName: "ForwardPayment",
Handler: _BotLink_ForwardPayment_Handler,
},
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
+3 -16
View File
@@ -91,16 +91,6 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
the seeded Mini App rather than the bot profile. the seeded Mini App rather than the bot profile.
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`, - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits. default 25) to respect the Bot API flood limits.
- **Payments (Telegram Stars).** When `TELEGRAM_STARS_OUTBOX_DIR` is set (default `/data`), the
bot handles the Stars rail. Only the bot reaches Telegram, so it mints the invoice on a
`CreateInvoice` bot-link command (`createInvoiceLink`, XTR — the link goes back to the Mini App
for `WebApp.openInvoice`); it gates each `pre_checkout_query` through the bot-link
(`ValidatePreCheckout`, backed by the backend intake — declining an already-paid reusable
invoice before the charge); and it records each `successful_payment` in a durable **SQLite
outbox** (`internal/outbox`, `stars.db` on the writable volume) before forwarding it over the
bot-link (`ForwardPayment`). The outbox is re-driven on startup and every 30 s, so a gateway or
backend outage never loses a paid order; crediting is idempotent on `telegram_payment_charge_id`.
The rail stays inert until a chip pack carries an XTR price (seeded in the admin).
The send commands address a recipient by the identity `external_id` (as in the backend The send commands address a recipient by the identity `external_id` (as in the backend
`identities` table), so a future VK / MAX bot reuses them; only the validator's initData `identities` table), so a future VK / MAX bot reuses them; only the validator's initData
@@ -114,11 +104,9 @@ parsing is Telegram-specific.
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
relay. relay.
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials - `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's on the gateway (`Hello` / `Command` / `Ack`), now also carrying a `ChatGateCommand` (set
chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time
Ack result), plus unary `ResolveChatEligibility` (the bot's join-time query), query) over the same mTLS channel. Generated Go is committed under `pkg`.
`ValidatePreCheckout` and `ForwardPayment` (the Stars rail) over the same mTLS channel.
Generated Go is committed under `pkg`.
## Deep-link scheme ## Deep-link scheme
@@ -165,7 +153,6 @@ Bot (`cmd/bot`):
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating | | `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay | | `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) | | `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
| `TELEGRAM_STARS_OUTBOX_DIR` | `/data` | directory for the Telegram Stars payment outbox (`stars.db`, a writable volume); empty disables the Stars rail |
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it | | `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs | | `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs | | `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
-25
View File
@@ -23,7 +23,6 @@ import (
"scrabble/platform/telegram/internal/bot" "scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink" "scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config" "scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/promobot" "scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support" "scrabble/platform/telegram/internal/support"
) )
@@ -91,24 +90,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
GameChannelID: cfg.GameChannelID, GameChannelID: cfg.GameChannelID,
SupportChatID: cfg.SupportChatID, SupportChatID: cfg.SupportChatID,
SupportStore: supportStore, SupportStore: supportStore,
AcceptPayments: cfg.StarsOutboxDir != "",
}, logger) }, logger)
if err != nil { if err != nil {
return err return err
} }
// The Telegram Stars payment outbox: a durable SQLite store on the bot host's writable volume.
// Opened when the rail is enabled; a failure to open is fatal, since accepting Stars without a
// durable outbox would risk losing a paid-for order.
var paymentOutbox *outbox.Store
if cfg.StarsOutboxDir != "" {
paymentOutbox, err = outbox.Open(filepath.Join(cfg.StarsOutboxDir, "stars.db"))
if err != nil {
return err
}
defer func() { _ = paymentOutbox.Close() }()
}
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName) tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
if err != nil { if err != nil {
return err return err
@@ -129,12 +115,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
// the bot after the client is built — the late binding that breaks the bot <-> // the bot after the client is built — the late binding that breaks the bot <->
// client construction cycle. // client construction cycle.
b.SetEligibilityResolver(client.ResolveChatEligibility) b.SetEligibilityResolver(client.ResolveChatEligibility)
// The Telegram Stars payment path rides the same bot-link: pre_checkout validation and
// completed-payment forwarding, backed by the durable outbox — wired here for the same
// late-binding reason.
if paymentOutbox != nil {
b.SetPaymentHandlers(client.ValidatePreCheckout, client.ForwardPayment, paymentOutbox)
}
// The optional standalone promo bot: a second bot (its own token) that only answers // The optional standalone promo bot: a second bot (its own token) that only answers
// /start with a button opening the main bot's Mini App. It is self-contained — no // /start with a button opening the main bot's Mini App. It is self-contained — no
@@ -180,11 +160,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
logger.Error("bot-link client stopped", zap.Error(err)) logger.Error("bot-link client stopped", zap.Error(err))
} }
}) })
// Re-drive the Stars payment outbox: at startup (recovering payments left by a restart or a past
// outage) and periodically thereafter.
if paymentOutbox != nil {
wg.Go(func() { b.RunPaymentDrainer(ctx) })
}
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with // The promo bot runs its own getUpdates long-poll on its own token (no 409 with
// the main bot's lease). // the main bot's lease).
if promo != nil { if promo != nil {
-13
View File
@@ -12,16 +12,3 @@ require (
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
scrabble/pkg v0.0.0 scrabble/pkg v0.0.0
) )
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+3 -29
View File
@@ -16,7 +16,6 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/support" "scrabble/platform/telegram/internal/support"
) )
@@ -50,10 +49,6 @@ type Config struct {
// SupportStore persists the support relay's state (topic mapping, block list, // SupportStore persists the support relay's state (topic mapping, block list,
// relayed message ids); required when SupportChatID is set, ignored otherwise. // relayed message ids); required when SupportChatID is set, ignored otherwise.
SupportStore *support.Store SupportStore *support.Store
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
AcceptPayments bool
} }
// EligibilityResolver answers whether the Telegram user identified by externalID // EligibilityResolver answers whether the Telegram user identified by externalID
@@ -96,12 +91,6 @@ type Bot struct {
supportLocks *keyedMutex supportLocks *keyedMutex
// admins caches the support chat's administrator ids (who may reply and act). // admins caches the support chat's administrator ids (who may reply and act).
admins *adminCache admins *adminCache
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
precheck PreCheckoutValidator
forward PaymentForwarder
outbox *outbox.Store
} }
// New builds the bot wrapper, registering the /start handler and a default handler // New builds the bot wrapper, registering the /start handler and a default handler
@@ -134,10 +123,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// callback handler by their "sup:" data prefix. // callback handler by their "sup:" data prefix.
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback)) opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
} }
// Allowed updates default to "all except chat_member" (which already includes // Allowed updates default to "all except chat_member". Specify an explicit set only
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we // when we need chat_member (moderated chat) — and then re-add callback_query (which
// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail, // the explicit set would otherwise drop) when the support relay needs it.
// pre_checkout_query, which the explicit set would otherwise drop.
if cfg.ChatID != 0 { if cfg.ChatID != 0 {
allowed := tgbot.AllowedUpdates{ allowed := tgbot.AllowedUpdates{
models.AllowedUpdateMessage, models.AllowedUpdateMessage,
@@ -147,9 +135,6 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if t.supportEnabled() { if t.supportEnabled() {
allowed = append(allowed, models.AllowedUpdateCallbackQuery) allowed = append(allowed, models.AllowedUpdateCallbackQuery)
} }
if cfg.AcceptPayments {
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
}
opts = append(opts, tgbot.WithAllowedUpdates(allowed)) opts = append(opts, tgbot.WithAllowedUpdates(allowed))
} }
if cfg.TestEnv { if cfg.TestEnv {
@@ -361,17 +346,6 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleChatMember(ctx, update.ChatMember) t.handleChatMember(ctx, update.ChatMember)
return return
} }
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
// message is never mistaken for a support DM or given a launch reply.
if update.PreCheckoutQuery != nil {
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
return
}
if update.Message != nil && update.Message.SuccessfulPayment != nil {
t.handleSuccessfulPayment(ctx, update.Message)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler — // Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to // is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply. // relay. Everything else falls through to the Mini App launch reply.
-166
View File
@@ -1,166 +0,0 @@
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."
}
+2 -30
View File
@@ -83,34 +83,6 @@ func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string)
return resp.GetEligible(), nil return resp.GetEligible(), nil
} }
// ValidatePreCheckout asks the gateway whether a Telegram Stars pre_checkout_query for orderID
// paying amount in currency may be approved, before the charge. The bot calls it on every
// pre_checkout_query over the same mTLS connection and approves only on ok; the reason is a short
// decline message (already localised by the backend) to show the payer.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) {
resp, err := c.client.ValidatePreCheckout(ctx, &botlinkv1.PreCheckoutRequest{OrderId: orderID, Amount: amount, Currency: currency})
if err != nil {
return false, "", err
}
return resp.GetOk(), resp.GetReason(), nil
}
// ForwardPayment delivers a completed Stars payment to the gateway for crediting. The bot calls it
// from the outbox drain; it reports whether the order was credited (or already had been). A non-nil
// error is a transient failure the bot retries.
func (c *Client) ForwardPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) {
resp, err := c.client.ForwardPayment(ctx, &botlinkv1.ForwardPaymentRequest{
OrderId: orderID,
TelegramPaymentChargeId: chargeID,
Amount: amount,
TelegramUserId: telegramUserID,
})
if err != nil {
return false, err
}
return resp.GetCredited(), nil
}
// Run keeps the bot-link command stream open, re-opening it after each break, until // Run keeps the bot-link command stream open, re-opening it after each break, until
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath. // ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
func (c *Client) Run(ctx context.Context) error { func (c *Client) Run(ctx context.Context) error {
@@ -149,8 +121,8 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
if cmd == nil { if cmd == nil {
continue continue
} }
delivered, result, herr := c.exec.Handle(ctx, cmd) delivered, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result} ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
if herr != nil { if herr != nil {
ack.Error = herr.Error() ack.Error = herr.Error()
} }
+7 -31
View File
@@ -27,10 +27,6 @@ type Sender interface {
// chat, but only when they are currently in it; it reports whether a restriction // chat, but only when they are currently in it; it reports whether a restriction
// was applied. // was applied.
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged
// with payload (the order id, echoed back in pre_checkout and successful_payment), and
// returns the link.
CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error)
} }
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors // Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
@@ -52,42 +48,22 @@ func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
return &Executor{sender: sender, channelID: channelID, log: log} return &Executor{sender: sender, channelID: channelID, log: log}
} }
// Handle dispatches one command to the matching Bot API call. It returns whether the command was // Handle dispatches one command to the matching Bot API send.
// delivered, an optional result string (the created invoice link for a create_invoice command; empty func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
// otherwise), and an error for an unexpected or malformed failure.
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, string, error) {
switch p := cmd.GetPayload().(type) { switch p := cmd.GetPayload().(type) {
case *botlinkv1.Command_Notify: case *botlinkv1.Command_Notify:
d, err := e.notify(ctx, p.Notify) return e.notify(ctx, p.Notify)
return d, "", err
case *botlinkv1.Command_SendToUser: case *botlinkv1.Command_SendToUser:
d, err := e.sendToUser(ctx, p.SendToUser) return e.sendToUser(ctx, p.SendToUser)
return d, "", err
case *botlinkv1.Command_SendToChannel: case *botlinkv1.Command_SendToChannel:
d, err := e.sendToChannel(ctx, p.SendToChannel) return e.sendToChannel(ctx, p.SendToChannel)
return d, "", err
case *botlinkv1.Command_ChatGate: case *botlinkv1.Command_ChatGate:
d, err := e.chatGate(ctx, p.ChatGate) return e.chatGate(ctx, p.ChatGate)
return d, "", err
case *botlinkv1.Command_CreateInvoice:
return e.createInvoice(ctx, p.CreateInvoice)
default: default:
return false, "", fmt.Errorf("botlink: empty command") return false, fmt.Errorf("botlink: empty command")
} }
} }
// createInvoice mints a Telegram Stars invoice link for the order and returns it in the Ack result.
// A Bot API failure is a hard error carried back in the Ack, so the gateway's synchronous mint fails
// (rather than returning an empty link).
func (e *Executor) createInvoice(ctx context.Context, req *botlinkv1.CreateInvoiceCommand) (bool, string, error) {
link, err := e.sender.CreateInvoiceLink(ctx, req.GetTitle(), req.GetDescription(), req.GetPayload(), req.GetAmount())
if err != nil {
e.log.Warn("create invoice link failed", zap.String("order", req.GetPayload()), zap.Error(err))
return false, "", err
}
return true, link, nil
}
// chatGate applies a chat-gate command: it parses the target Telegram user id and // chatGate applies a chat-gate command: it parses the target Telegram user id and
// sets their write access in the moderated chat (a no-op when they are not in it). A // sets their write access in the moderated chat (a no-op when they are not in it). A
// Bot API failure is logged and reported as not-delivered, not a hard error. // Bot API failure is logged and reported as not-delivered, not a hard error.
@@ -16,8 +16,6 @@ type fakeSender struct {
notify []notifyCall notify []notifyCall
text []textCall text []textCall
gate []gateCall gate []gateCall
invoice []invoiceCall
invoiceLink string // CreateInvoiceLink's returned link
applied bool // ApplyChatGate's reported result applied bool // ApplyChatGate's reported result
err error err error
} }
@@ -34,10 +32,6 @@ type gateCall struct {
userID int64 userID int64
allow bool allow bool
} }
type invoiceCall struct {
title, description, payload string
amount int64
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error { func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam}) f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
@@ -54,11 +48,6 @@ func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool)
return f.applied, f.err return f.applied, f.err
} }
func (f *fakeSender) CreateInvoiceLink(_ context.Context, title, description, payload string, amountStars int64) (string, error) {
f.invoice = append(f.invoice, invoiceCall{title, description, payload, amountStars})
return f.invoiceLink, f.err
}
func yourTurnPayload(gameID string) []byte { func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0) b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID) gid := b.CreateString(gameID)
@@ -78,7 +67,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en")) delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -96,7 +85,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) { func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en")) delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -110,7 +99,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
func TestExecutorNotifyInvalidExternalID(t *testing.T) { func TestExecutorNotifyInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil) exec := NewExecutor(&fakeSender{}, 0, nil)
if _, _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil { if _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
t.Error("expected an error for a non-numeric external_id") t.Error("expected an error for a non-numeric external_id")
} }
} }
@@ -119,7 +108,7 @@ func TestExecutorSendToUser(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}} cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
delivered, _, err := exec.Handle(context.Background(), cmd) delivered, err := exec.Handle(context.Background(), cmd)
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -137,7 +126,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
func TestExecutorChatGateApplied(t *testing.T) { func TestExecutorChatGateApplied(t *testing.T) {
sender := &fakeSender{applied: true} sender := &fakeSender{applied: true}
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("777", true)) delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -149,7 +138,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
func TestExecutorChatGateNotInChat(t *testing.T) { func TestExecutorChatGateNotInChat(t *testing.T) {
sender := &fakeSender{applied: false} // user not in the chat sender := &fakeSender{applied: false} // user not in the chat
exec := NewExecutor(sender, 0, nil) exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("888", false)) delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false))
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -163,53 +152,24 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
func TestExecutorChatGateInvalidExternalID(t *testing.T) { func TestExecutorChatGateInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil) exec := NewExecutor(&fakeSender{}, 0, nil)
if _, _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil { if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
t.Error("expected an error for a non-numeric external_id") t.Error("expected an error for a non-numeric external_id")
} }
} }
func TestExecutorCreateInvoice(t *testing.T) {
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
}}}
delivered, result, err := exec.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || result != "https://t.me/$abc" {
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
}
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
t.Errorf("invoice calls = %+v", sender.invoice)
}
}
func TestExecutorCreateInvoiceError(t *testing.T) {
sender := &fakeSender{err: context.DeadlineExceeded}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "x", Description: "x", Payload: "order-2", Amount: 10,
}}}
if _, _, err := exec.Handle(context.Background(), cmd); err == nil {
t.Error("expected an error when minting the invoice fails")
}
}
func TestExecutorSendToChannel(t *testing.T) { func TestExecutorSendToChannel(t *testing.T) {
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}} channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
t.Run("unconfigured", func(t *testing.T) { t.Run("unconfigured", func(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil) exec := NewExecutor(&fakeSender{}, 0, nil)
if _, _, err := exec.Handle(context.Background(), channelCmd); err == nil { if _, err := exec.Handle(context.Background(), channelCmd); err == nil {
t.Error("expected an error when no channel is configured") t.Error("expected an error when no channel is configured")
} }
}) })
t.Run("configured", func(t *testing.T) { t.Run("configured", func(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
exec := NewExecutor(sender, 555, nil) exec := NewExecutor(sender, 555, nil)
delivered, _, err := exec.Handle(context.Background(), channelCmd) delivered, err := exec.Handle(context.Background(), channelCmd)
if err != nil { if err != nil {
t.Fatalf("handle: %v", err) t.Fatalf("handle: %v", err)
} }
@@ -52,11 +52,6 @@ type BotConfig struct {
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the // (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
// container user (UID 65532) and backed by a persistent volume. // container user (UID 65532) and backed by a persistent volume.
SupportStateDir string SupportStateDir string
// StarsOutboxDir is the directory holding the Telegram Stars payment outbox SQLite file
// (TELEGRAM_STARS_OUTBOX_DIR, optional; empty disables the Stars rail). It must be writable by
// the container user (UID 65532) and backed by a persistent volume — a lost outbox loses any
// payment not yet forwarded to the gateway.
StarsOutboxDir string
// PromoBotToken is the API token of the optional standalone promo bot run in this // PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that // container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables // opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
@@ -162,7 +157,6 @@ func LoadBot() (BotConfig, error) {
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam), PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"), SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{ BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"), GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
-104
View File
@@ -1,104 +0,0 @@
// Package outbox is the Telegram Stars payment outbox: a small SQLite store on the bot host's
// writable volume that durably records each completed Stars payment the moment Telegram delivers it,
// so a gateway or backend outage cannot lose it. The bot forwards pending rows to the gateway and
// marks them delivered; rows still undelivered are re-driven on restart. It is pure-Go SQLite
// (modernc.org/sqlite, no CGO) so it runs on the distroless image.
package outbox
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Record is one completed Stars payment awaiting delivery to the gateway.
type Record struct {
// ChargeID is the telegram_payment_charge_id — the primary key here and the credit idempotency
// key at the backend, so a re-delivered or retried payment is never credited twice.
ChargeID string
// OrderID is the invoice payload (our order id) the payment settles.
OrderID string
// Amount is the stars paid (the XTR minor unit is the whole star).
Amount int64
// UserID is the payer's Telegram user id.
UserID int64
}
// Store is the SQLite-backed payment outbox.
type Store struct {
db *sql.DB
}
// Open opens (creating if absent) the outbox database at path and ensures its schema. The parent
// directory must exist and be writable by the container user.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("outbox: open %s: %w", path, err)
}
// A single connection serialises writes and sidesteps SQLite's "database is locked" under the
// bot's low, bursty payment volume.
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS stars_payments (
charge_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
amount INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
forwarded INTEGER NOT NULL DEFAULT 0
)`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("outbox: init schema: %w", err)
}
return &Store{db: db}, nil
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Add records a completed payment. It is idempotent on the charge id: a payment Telegram re-delivers
// (or one already recorded and forwarded) is ignored, so it is never forwarded — and credited —
// twice.
func (s *Store) Add(ctx context.Context, r Record) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO stars_payments (charge_id, order_id, amount, user_id, created_at, forwarded)
VALUES (?, ?, ?, ?, ?, 0)
ON CONFLICT(charge_id) DO NOTHING`,
r.ChargeID, r.OrderID, r.Amount, r.UserID, time.Now().Unix())
if err != nil {
return fmt.Errorf("outbox: add %s: %w", r.ChargeID, err)
}
return nil
}
// Pending returns up to limit payments not yet forwarded, oldest first.
func (s *Store) Pending(ctx context.Context, limit int) ([]Record, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT charge_id, order_id, amount, user_id FROM stars_payments
WHERE forwarded = 0 ORDER BY created_at LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("outbox: read pending: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var r Record
if err := rows.Scan(&r.ChargeID, &r.OrderID, &r.Amount, &r.UserID); err != nil {
return nil, fmt.Errorf("outbox: scan: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkForwarded flags a payment as delivered so it is not forwarded again.
func (s *Store) MarkForwarded(ctx context.Context, chargeID string) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE stars_payments SET forwarded = 1 WHERE charge_id = ?`, chargeID); err != nil {
return fmt.Errorf("outbox: mark forwarded %s: %w", chargeID, err)
}
return nil
}
@@ -1,124 +0,0 @@
package outbox
import (
"context"
"path/filepath"
"testing"
)
// openTemp opens a fresh outbox in a temp dir.
func openTemp(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "stars.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestOutboxAddPendingMark(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "ch1", OrderID: "ord1", Amount: 40, UserID: 777}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending = %+v, want [%+v]", pending, rec)
}
if err := s.MarkForwarded(ctx, "ch1"); err != nil {
t.Fatalf("mark: %v", err)
}
pending, err = s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending after mark: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending after mark = %+v, want empty", pending)
}
}
func TestOutboxAddIdempotent(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "dup", OrderID: "ord1", Amount: 80, UserID: 1}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add 1: %v", err)
}
// A re-delivered payment (same charge id) must not add a second row, even with different fields.
if err := s.Add(ctx, Record{ChargeID: "dup", OrderID: "other", Amount: 999, UserID: 2}); err != nil {
t.Fatalf("add 2: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending = %+v, want the single original row", pending)
}
}
func TestOutboxAddIgnoresForwarded(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "ch", OrderID: "o", Amount: 40, UserID: 5}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.MarkForwarded(ctx, "ch"); err != nil {
t.Fatalf("mark: %v", err)
}
// A re-delivery after forwarding must not resurrect the row as pending (no double credit).
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("re-add: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending = %+v, want empty (already forwarded)", pending)
}
}
// TestOutboxReopenReDrives proves a restart re-drives undelivered payments: a payment persisted but
// not marked forwarded is still pending after the store is reopened from the same file.
func TestOutboxReopenReDrives(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "stars.db")
s, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
rec := Record{ChargeID: "persist", OrderID: "o1", Amount: 40, UserID: 9}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.Close(); err != nil {
t.Fatalf("close: %v", err)
}
reopened, err := Open(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer func() { _ = reopened.Close() }()
pending, err := reopened.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending after reopen = %+v, want the undelivered payment", pending)
}
}
-11
View File
@@ -58,14 +58,3 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn
expect(await web.getAttribute('href')).toContain('/app/'); expect(await web.getAttribute('href')).toContain('/app/');
await expect(page.getByText('Веб-версия')).toBeVisible(); await expect(page.getByText('Веб-версия')).toBeVisible();
}); });
// The footer carries a public-offer link to the static /offer/ page (rendered from
// ui/legal/offer_ru.md at build; the legal document a purchase accepts).
test('the landing footer links to the public offer at /offer/', async ({ page }) => {
await page.goto('/landing.html');
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
const offer = page.getByRole('link', { name: 'Публичная оферта' });
await expect(offer).toBeVisible();
expect(await offer.getAttribute('href')).toBe('/offer/');
});
+2 -21
View File
@@ -37,27 +37,8 @@ test('wallet: balances, benefits and the storefront render for a durable account
await expect(page.getByTestId('product')).toHaveCount(3); await expect(page.getByTestId('product')).toHaveCount(3);
const pack = page.locator('[data-kind="pack"]'); const pack = page.locator('[data-kind="pack"]');
await expect(pack).toContainText('₽'); await expect(pack).toContainText('₽');
// The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link. // The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'.
await expect(pack.getByTestId('buy-pack')).toBeEnabled(); await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled();
await expect(page.getByTestId('offer')).toContainText('Public offer');
});
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it.
await page.evaluate(() => {
(window as { __opened?: string }).__opened = '';
window.open = ((u: string) => {
(window as { __opened?: string }).__opened = u;
return null;
}) as typeof window.open;
});
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
await expect
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
.toContain('robokassa');
}); });
test('wallet: the tab sits between Friends and About', async ({ page }) => { test('wallet: the tab sits between Friends and About', async ({ page }) => {
-145
View File
@@ -1,145 +0,0 @@
# Публичная оферта
Публичная оферта о заключении договора купли-продажи.
## 1. Общие положения
В настоящей Публичной оферте содержатся условия заключения Договора купли-продажи (далее по тексту — «Договор купли-продажи» и/или «Договор»). Настоящей офертой признается предложение, адресованное одному или нескольким конкретным лицам, которое достаточно определенно и выражает намерение лица, сделавшего предложение, считать себя заключившим Договор с адресатом, которым будет принято предложение.
Совершение указанных в настоящей Оферте действий является подтверждением согласия обеих Сторон заключить Договор купли-продажи на условиях, в порядке и объеме, изложенных в настоящей Оферте.
Нижеизложенный текст Публичной оферты является официальным публичным предложением Продавца, адресованный заинтересованному кругу лиц заключить Договор купли-продажи в соответствии с положениями пункта 2 статьи 437 Гражданского кодекса РФ.
Договор купли-продажи считается заключенным и приобретает силу с момента совершения Сторонами действий, предусмотренных в настоящей Оферте, и, означающих безоговорочное, а также полное принятие всех условий настоящей Оферты без каких-либо изъятий или ограничений на условиях присоединения.
### Термины и определения
**Договор** — текст настоящей Оферты с Приложениями, являющимися неотъемлемой частью настоящей Оферты, акцептованный Покупателем путем совершения конклюдентных действий, предусмотренных настоящей Офертой.
**Конклюдентные действия** — это поведение, которое выражает согласие с предложением контрагента заключить, изменить или расторгнуть договор. Действия состоят в полном или частичном выполнении условий, которые предложил контрагент.
**Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`.
**Стороны Договора (Стороны)** — Продавец и Покупатель.
**Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ.
## 2. Предмет Договора
**2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму.
**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`.
**2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности:
- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи;
- путем составления и заполнения заявки на оформление заказа Товара;
- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя;
- оплаты Товара Покупателем.
Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента.
## 3. Права и обязанности Сторон
### 3.1. Права и обязанности Продавца
**3.1.1.** Продавец вправе требовать оплаты Товаров и их доставки в порядке и на условиях, предусмотренных Договором;
**3.1.2.** Отказать в заключении Договора на основании настоящей Оферты Покупателю в случае его недобросовестного поведения, в частности, в случае:
- более 2 (Двух) отказов от Товаров надлежащего качества в течение года;
- предоставления заведомо недостоверной персональной информации;
- возврата испорченного Покупателем Товара или Товара, бывшего в употреблении;
- иных случаях недобросовестного поведения, свидетельствующих о заключении Покупателем Договора с целью злоупотребления правами, и отсутствия обычной экономической цели Договора — приобретения Товара.
**3.1.3.** Продавец обязуется передать Покупателю Товар надлежащего качества и в надлежащей упаковке;
**3.1.4.** Передать Товар свободным от прав третьих лиц;
**3.1.5.** Организовать доставку Товаров Покупателю;
**3.1.6.** Предоставить Покупателю всю необходимую информацию в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
### 3.2. Права и обязанности Покупателя
**3.2.1.** Покупатель вправе требовать передачи Товара в порядке и на условиях, предусмотренных Договором.
**3.2.2.** Требовать предоставления всей необходимой информации в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
**3.2.3.** Отказаться от Товара по основаниям, предусмотренным Договором и действующим законодательством Российской Федерации.
**3.2.4.** Покупатель обязуется предоставить Продавцу достоверную информацию, необходимую для надлежащего исполнения Договора;
**3.2.5.** Принять и оплатить Товар в соответствии с условиями Договора;
**3.2.6.** Покупатель гарантирует, что все условия Договора ему понятны; Покупатель принимает условия без оговорок, а также в полном объеме.
## 4. Цена и порядок расчетов
**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`.
**4.2.** Все расчеты по Договору производятся в безналичном порядке.
## 5. Обмен и возврат Товара
**5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463.
**5.2.** Требование Покупателя об обмене либо о возврате Товара рассматривается индивидуально при условии неиспользования приобретенного товара и наличии уважительных причин (технический сбой, ошибка в описании товара).
## 6. Конфиденциальность и безопасность
**6.1.** При реализации настоящего Договора Стороны обеспечивают конфиденциальность и безопасность персональных данных в соответствии с актуальной редакцией ФЗ от 27.07.2006 г. № 152-ФЗ «О персональных данных» и ФЗ от 27.07.2006 г. № 149-ФЗ «Об информации, информационных технологиях и о защите информации».
**6.2.** Стороны обязуются сохранять конфиденциальность информации, полученной в ходе исполнения настоящего Договора, и принять все возможные меры, чтобы предохранить полученную информацию от разглашения.
**6.3.** Под конфиденциальной информацией понимается любая информация, передаваемая Продавцом и Покупателем в процессе реализации Договора и подлежащая защите, исключения указаны ниже.
**6.4.** Такая информация может содержаться в предоставляемых Продавцом локальных нормативных актах, договорах, письмах, отчетах, аналитических материалах, результатах исследований, схемах, графиках, спецификациях и других документах, оформленных как на бумажных, так и на электронных носителях.
## 7. Форс-мажор
**7.1.** Стороны освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по Договору, если надлежащее исполнение оказалось невозможным вследствие непреодолимой силы, то есть чрезвычайных и непредотвратимых при данных условиях обстоятельств, под которыми понимаются: запретные действия властей, эпидемии, блокада, эмбарго, землетрясения, наводнения, пожары или другие стихийные бедствия.
**7.2.** В случае наступления этих обстоятельств Сторона обязана в течение 30 (Тридцати) рабочих дней уведомить об этом другую Сторону.
**7.3.** Документ, выданный уполномоченным государственным органом, является достаточным подтверждением наличия и продолжительности действия непреодолимой силы.
**7.4.** Если обстоятельства непреодолимой силы продолжают действовать более 60 (Шестидесяти) рабочих дней, то каждая Сторона вправе отказаться от настоящего Договора в одностороннем порядке.
## 8. Ответственность Сторон
**8.1.** В случае неисполнения и/или ненадлежащего исполнения своих обязательств по Договору, Стороны несут ответственность в соответствии с условиями настоящей Оферты.
**8.2.** Сторона, не исполнившая или ненадлежащим образом исполнившая обязательства по Договору, обязана возместить другой Стороне причиненные такими нарушениями убытки.
## 9. Срок действия настоящей Оферты
**9.1.** Оферта вступает в силу с момента размещения на Сайте Продавца и действует до момента её отзыва Продавцом.
**9.2.** Продавец оставляет за собой право внести изменения в условия Оферты и/или отозвать Оферту в любой момент по своему усмотрению. Сведения об изменении или отзыве Оферты доводятся до Покупателя по выбору Продавца посредством размещения на сайте Продавца в сети «Интернет», в Личном кабинете Покупателя, либо путем направления соответствующего уведомления на электронный или почтовый адрес, указанный Покупателем при заключении Договора или в ходе его исполнения.
**9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору.
**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме.
## 10. Дополнительные условия
**10.1.** Договор, его заключение и исполнение регулируется действующим законодательством Российской Федерации. Все вопросы, не урегулированные настоящей Офертой или урегулированные не полностью, регулируются в соответствии с материальным правом Российской Федерации.
**10.2.** В случае возникновения спора, который может возникнуть между Сторонами в ходе исполнения ими своих обязательств по Договору, заключенному на условиях настоящей Оферты, Стороны обязаны урегулировать спор мирным путем до начала судебного разбирательства.
Судебное разбирательство осуществляется в соответствии с законодательством Российской Федерации.
Споры или разногласия, по которым Стороны не достигли договоренности, подлежат разрешению в соответствии с законодательством РФ. Досудебный порядок урегулирования спора является обязательным.
**10.3.** В качестве языка Договора, заключаемого на условиях настоящей Оферты, а также языка, используемого при любом взаимодействии Сторон (включая ведение переписки, предоставление требований / уведомлений / разъяснений, предоставление документов и т. д.), Стороны определили русский язык.
**10.4.** Все документы, подлежащие предоставлению в соответствии с условиями настоящей Оферты, должны быть составлены на русском языке либо иметь перевод на русский язык, удостоверенный в установленном порядке.
**10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем.
**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок.
## 11. Реквизиты Продавца
Денисов Илья Аркадьевич, ИНН 290210610742.
-1
View File
@@ -28,7 +28,6 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0", "core-js-bundle": "^3.49.0",
"marked": "^18.0.5",
"svelte": "^5.15.0", "svelte": "^5.15.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
-10
View File
@@ -39,9 +39,6 @@ importers:
core-js-bundle: core-js-bundle:
specifier: ^3.49.0 specifier: ^3.49.0
version: 3.49.0 version: 3.49.0
marked:
specifier: ^18.0.5
version: 18.0.5
svelte: svelte:
specifier: ^5.15.0 specifier: ^5.15.0
version: 5.56.0 version: 5.56.0
@@ -1631,11 +1628,6 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3885,8 +3877,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.5: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
minimatch@10.2.5: minimatch@10.2.5:
+3 -7
View File
@@ -7,9 +7,7 @@
// //
// Three independent gates on the natural chunk boundaries, each with realistic headroom: // Three independent gates on the natural chunk boundaries, each with realistic headroom:
// - app entry (main): the app's own code; grows with features. // - app entry (main): the app's own code; grows with features.
// - shared (svelte+i18n): the framework runtime plus the flat i18n map; drifts on a dep/Svelte // - shared (svelte+i18n): near-static framework runtime; only drifts on a dep/Svelte bump.
// bump and, slightly, as feature copy adds i18n keys (raised to 31 for
// the rewarded-ad strings).
// - landing own: the landing's own code; kept minimal. // - landing own: the landing's own code; kept minimal.
// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is // Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is
// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still // ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still
@@ -30,11 +28,9 @@ const DIST = 'dist';
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN // live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen, // hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands // storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the // in the shared chunk). The heavy parts — the dict loader, the move generator and the preload
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget. // orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 125, shared: 31, landing: 5 }; const BUDGET = { app: 123, shared: 30, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+1 -14
View File
@@ -123,10 +123,7 @@
</div> </div>
</section> </section>
<footer class="ft"> <footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer>
<a class="offer" href="/offer/">{t('landing.offer')}</a>
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
</footer>
</main> </main>
<style> <style>
@@ -278,18 +275,8 @@
} }
.ft { .ft {
margin-top: auto; margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
.ft .offer {
color: inherit;
}
.ft .offer:hover {
color: var(--accent);
}
</style> </style>
-3
View File
@@ -86,9 +86,6 @@ export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
export { VKLoginRequest } from './scrabblefb/vklogin-request.js'; export { VKLoginRequest } from './scrabblefb/vklogin-request.js';
export { Wallet } from './scrabblefb/wallet.js'; export { Wallet } from './scrabblefb/wallet.js';
export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js'; export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js';
export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js';
export { WalletOrderResponse } from './scrabblefb/wallet-order-response.js';
export { WalletRewardRequest } from './scrabblefb/wallet-reward-request.js';
export { WalletSegment } from './scrabblefb/wallet-segment.js'; export { WalletSegment } from './scrabblefb/wallet-segment.js';
export { WordCheckResult } from './scrabblefb/word-check-result.js'; export { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.js'; export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
@@ -1,48 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletOrderRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest {
return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
productId():string|null
productId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
productId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletOrderRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addProductId(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, productIdOffset, 0);
}
static endWalletOrderRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletOrderRequest(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletOrderRequest.startWalletOrderRequest(builder);
WalletOrderRequest.addProductId(builder, productIdOffset);
return WalletOrderRequest.endWalletOrderRequest(builder);
}
}
@@ -1,60 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletOrderResponse {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderResponse {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse {
return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
orderId():string|null
orderId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
orderId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
redirectUrl():string|null
redirectUrl(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
redirectUrl(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletOrderResponse(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addOrderId(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, orderIdOffset, 0);
}
static addRedirectUrl(builder:flatbuffers.Builder, redirectUrlOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, redirectUrlOffset, 0);
}
static endWalletOrderResponse(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletOrderResponse(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset, redirectUrlOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletOrderResponse.startWalletOrderResponse(builder);
WalletOrderResponse.addOrderId(builder, orderIdOffset);
WalletOrderResponse.addRedirectUrl(builder, redirectUrlOffset);
return WalletOrderResponse.endWalletOrderResponse(builder);
}
}
@@ -1,48 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletRewardRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletRewardRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletRewardRequest(bb:flatbuffers.ByteBuffer, obj?:WalletRewardRequest):WalletRewardRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletRewardRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
nonce():string|null
nonce(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
nonce(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletRewardRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, nonceOffset, 0);
}
static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletRewardRequest.startWalletRewardRequest(builder);
WalletRewardRequest.addNonce(builder, nonceOffset);
return WalletRewardRequest.endWalletRewardRequest(builder);
}
}
+2 -12
View File
@@ -48,13 +48,8 @@ hints():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
} }
rewardChips():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startWallet(builder:flatbuffers.Builder) { static startWallet(builder:flatbuffers.Builder) {
builder.startObject(5); builder.startObject(4);
} }
static addSegments(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset) { static addSegments(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset) {
@@ -85,22 +80,17 @@ static addHints(builder:flatbuffers.Builder, hints:number) {
builder.addFieldInt32(3, hints, 0); builder.addFieldInt32(3, hints, 0);
} }
static addRewardChips(builder:flatbuffers.Builder, rewardChips:number) {
builder.addFieldInt32(4, rewardChips, 0);
}
static endWallet(builder:flatbuffers.Builder):flatbuffers.Offset { static endWallet(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createWallet(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset, adsForever:boolean, adsPaidUntilMs:bigint, hints:number, rewardChips:number):flatbuffers.Offset { static createWallet(builder:flatbuffers.Builder, segmentsOffset:flatbuffers.Offset, adsForever:boolean, adsPaidUntilMs:bigint, hints:number):flatbuffers.Offset {
Wallet.startWallet(builder); Wallet.startWallet(builder);
Wallet.addSegments(builder, segmentsOffset); Wallet.addSegments(builder, segmentsOffset);
Wallet.addAdsForever(builder, adsForever); Wallet.addAdsForever(builder, adsForever);
Wallet.addAdsPaidUntilMs(builder, adsPaidUntilMs); Wallet.addAdsPaidUntilMs(builder, adsPaidUntilMs);
Wallet.addHints(builder, hints); Wallet.addHints(builder, hints);
Wallet.addRewardChips(builder, rewardChips);
return Wallet.endWallet(builder); return Wallet.endWallet(builder);
} }
} }
-48
View File
@@ -1,48 +0,0 @@
// The ads-network abstraction (D28). VK is the only network now; a future network for another
// platform implements the same rewardedReady/showRewarded surface without touching the callers. On
// the contour a build flag (VITE_ADS_STUB) substitutes a toast stub for the real ad, so the reward
// flow is testable without waiting for a real ad — production never sets the flag, so a real ad that
// fails to load there must not credit (no stub fallback in prod).
import { executionContext } from './wallet';
import { vkRewardedReady, vkShowRewarded } from './vk';
/**
* adsStubEnabled reports the contour test stub (VITE_ADS_STUB=1). Production never sets it, so it
* always shows real ads. The mock e2e forces it on so the reward flow is exercisable without a VK
* bridge. To capture the real VK ad result on the contour (the diagnostic), deploy with the flag
* off first; flip it on afterwards for routine stub testing.
*/
export function adsStubEnabled(): boolean {
if (import.meta.env.MODE === 'mock') return true;
return (import.meta.env as Record<string, string | undefined>).VITE_ADS_STUB === '1';
}
/** RewardedResult is one rewarded-ad view: whether it was watched, and whether it was the test stub
* (so the caller can show the "ad fired" marker toast). */
export interface RewardedResult {
watched: boolean;
stub: boolean;
}
/**
* rewardedReady reports whether a rewarded ad is ready to show, gating the "watch for chips" button.
* The stub is always ready; a real ad is ready only inside VK with preloaded material. Rewarded is
* VK-only (D28).
*/
export async function rewardedReady(): Promise<boolean> {
if (adsStubEnabled()) return true;
if (executionContext() !== 'vk') return false;
return vkRewardedReady();
}
/**
* showRewarded shows a rewarded ad and reports whether it was watched, the raw provider result, and
* whether it was the stub. On the stub it resolves watched immediately (the caller shows the "ad
* fired" toast).
*/
export async function showRewarded(): Promise<RewardedResult> {
if (adsStubEnabled()) {
return { watched: true, stub: true };
}
return { watched: await vkShowRewarded(), stub: false };
}
-10
View File
@@ -137,9 +137,6 @@ export const app = $state<{
/** Whether an operator feedback reply awaits the player, for the lobby badge (combined /** Whether an operator feedback reply awaits the player, for the lobby badge (combined
* with friend requests) and the Settings Info badge. */ * with friend requests) and the Settings Info badge. */
feedbackReplyUnread: boolean; feedbackReplyUnread: boolean;
/** A monotone counter bumped when a payment-intake push signals the wallet changed; an open
* Wallet screen watches it and re-fetches in place. */
walletRefresh: number;
/** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
* the bot instead of a scary error on the Friends screen. */ * the bot instead of a scary error on the Friends screen. */
@@ -178,7 +175,6 @@ export const app = $state<{
chatUnread: {}, chatUnread: {},
messageUnread: {}, messageUnread: {},
feedbackReplyUnread: false, feedbackReplyUnread: false,
walletRefresh: 0,
staleInvite: false, staleInvite: false,
welcomeRedeem: false, welcomeRedeem: false,
resync: 0, resync: 0,
@@ -443,12 +439,6 @@ function openStream(): void {
if (e.sub === 'profile') { if (e.sub === 'profile') {
void refreshProfile(); void refreshProfile();
} }
// A payment-intake event credited (or refunded) the viewer's wallet: bump the signal an
// open Wallet screen watches, so it re-fetches in place (the return-focus poll is the
// fallback when the live stream is down).
if (e.sub === 'payment') {
app.walletRefresh++;
}
void refreshNotifications(); void refreshNotifications();
} }
}, },
-8
View File
@@ -35,7 +35,6 @@ import type {
Variant, Variant,
WordCheckResult, WordCheckResult,
Wallet, Wallet,
WalletOrder,
Catalog, Catalog,
} from './model'; } from './model';
@@ -148,13 +147,6 @@ export interface GatewayClient {
/** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked /** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked
* server-side: an untrusted/frozen context or an insufficient balance is refused. */ * server-side: an untrusted/frozen context or an insufficient balance is refused. */
walletBuy(productId: string): Promise<Wallet>; walletBuy(productId: string): Promise<Wallet>;
/** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
walletOrder(productId: string): Promise<WalletOrder>;
/** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is
* the per-view idempotency key. Client-attested + a server daily/hourly cap; a reached cap rejects
* with a domain code. */
walletReward(nonce: string): Promise<Wallet>;
// --- friends --- // --- friends ---
friendsList(): Promise<AccountRef[]>; friendsList(): Promise<AccountRef[]>;
-31
View File
@@ -15,11 +15,8 @@ import {
decodeOutgoingList, decodeOutgoingList,
decodeProfile, decodeProfile,
decodeWallet, decodeWallet,
encodeWalletReward,
decodeCatalog, decodeCatalog,
encodeWalletBuy, encodeWalletBuy,
encodeWalletOrder,
decodeWalletOrder,
decodeSession, decodeSession,
decodeFeedbackState, decodeFeedbackState,
decodeFeedbackUnread, decodeFeedbackUnread,
@@ -67,7 +64,6 @@ describe('codec', () => {
fb.Wallet.addAdsForever(b, false); fb.Wallet.addAdsForever(b, false);
fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000)); fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000));
fb.Wallet.addHints(b, 5); fb.Wallet.addHints(b, 5);
fb.Wallet.addRewardChips(b, 7);
b.finish(fb.Wallet.endWallet(b)); b.finish(fb.Wallet.endWallet(b));
const w = decodeWallet(b.asUint8Array()); const w = decodeWallet(b.asUint8Array());
@@ -75,33 +71,6 @@ describe('codec', () => {
expect(w.adsForever).toBe(false); expect(w.adsForever).toBe(false);
expect(w.adsPaidUntilMs).toBe(1_700_000_000_000); expect(w.adsPaidUntilMs).toBe(1_700_000_000_000);
expect(w.hints).toBe(5); expect(w.hints).toBe(5);
expect(w.rewardChips).toBe(7);
});
it('round-trips the wallet reward request (nonce)', () => {
const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest(
new ByteBuffer(encodeWalletReward('nonce-9')),
);
expect(req.nonce()).toBe('nonce-9');
});
it('round-trips the wallet order request and response', () => {
// order request: pack id survives the wire
const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7')));
expect(req.productId()).toBe('pack-7');
// order response: the created id and the provider launch URL decode back
const b = new Builder(64);
const oid = b.createString('order-123');
const url = b.createString('https://pay.example/abc');
fb.WalletOrderResponse.startWalletOrderResponse(b);
fb.WalletOrderResponse.addOrderId(b, oid);
fb.WalletOrderResponse.addRedirectUrl(b, url);
b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b));
const order = decodeWalletOrder(b.asUint8Array());
expect(order.orderId).toBe('order-123');
expect(order.redirectUrl).toBe('https://pay.example/abc');
}); });
it('round-trips the catalog view (value + pack)', () => { it('round-trips the catalog view (value + pack)', () => {
-27
View File
@@ -39,7 +39,6 @@ import type {
MoveResult, MoveResult,
Profile, Profile,
Wallet, Wallet,
WalletOrder,
WalletSegment, WalletSegment,
Catalog, Catalog,
CatalogProduct, CatalogProduct,
@@ -374,15 +373,6 @@ export function encodeWalletBuy(productId: string): Uint8Array {
return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b)); return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b));
} }
// encodeWalletOrder wraps the pack id for a money order (POST /user/wallet/order).
export function encodeWalletOrder(productId: string): Uint8Array {
const b = new Builder(64);
const pid = b.createString(productId);
fb.WalletOrderRequest.startWalletOrderRequest(b);
fb.WalletOrderRequest.addProductId(b, pid);
return finish(b, fb.WalletOrderRequest.endWalletOrderRequest(b));
}
// decodeWallet reads the wallet payload: the context-visible chip segments and the // decodeWallet reads the wallet payload: the context-visible chip segments and the
// context-applicable benefits. // context-applicable benefits.
export function decodeWallet(buf: Uint8Array): Wallet { export function decodeWallet(buf: Uint8Array): Wallet {
@@ -398,26 +388,9 @@ export function decodeWallet(buf: Uint8Array): Wallet {
adsForever: w.adsForever(), adsForever: w.adsForever(),
adsPaidUntilMs: Number(w.adsPaidUntilMs()), adsPaidUntilMs: Number(w.adsPaidUntilMs()),
hints: w.hints(), hints: w.hints(),
rewardChips: w.rewardChips(),
}; };
} }
// encodeWalletReward wraps a watched rewarded video for crediting (POST /user/wallet/reward): the
// per-view idempotency nonce.
export function encodeWalletReward(nonce: string): Uint8Array {
const b = new Builder(64);
const n = b.createString(nonce);
fb.WalletRewardRequest.startWalletRewardRequest(b);
fb.WalletRewardRequest.addNonce(b, n);
return finish(b, fb.WalletRewardRequest.endWalletRewardRequest(b));
}
// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens.
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
return { orderId: s(o.orderId()), redirectUrl: s(o.redirectUrl()) };
}
// decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced // decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced
// value or a chip pack priced in the context's payment method, with its atom composition. // value or a chip pack priced in the context's payment method, with its atom composition.
export function decodeCatalog(buf: Uint8Array): Catalog { export function decodeCatalog(buf: Uint8Array): Catalog {
+1 -11
View File
@@ -242,16 +242,7 @@ export const en = {
'wallet.store': 'Store', 'wallet.store': 'Store',
'wallet.empty': 'The store is empty for now', 'wallet.empty': 'The store is empty for now',
'wallet.buy': 'Buy', 'wallet.buy': 'Buy',
'wallet.rewardWatch': 'Watch an ad for {n} 🪙', 'wallet.soon': 'Soon',
'wallet.rewardCta': 'Watch',
'wallet.rewardCredited': '+{n} 🪙 for watching',
'wallet.rewardCapped': "You've reached today's reward limit",
'wallet.rewardFailed': 'The ad could not be shown',
'wallet.offer': 'Public offer',
'wallet.platformNoBuy': 'Purchases are not available on this platform',
'wallet.iosBlockedPre': 'Purchases on iOS are prohibited by Apple policy. Try the ',
'wallet.iosBlockedLink': 'other version',
'wallet.iosBlockedPost': ' of the game.',
'wallet.gpStub': 'To buy chips, install the RuStore build.', 'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.cur.RUB': '₽', 'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes', 'wallet.cur.VOTE': 'votes',
@@ -284,7 +275,6 @@ export const en = {
'landing.captionTelegram': 'Telegram', 'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK', 'landing.captionVK': 'VK',
'landing.captionWeb': 'Web', 'landing.captionWeb': 'Web',
'landing.offer': 'Public offer',
'install.title': 'Install the app', 'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.', 'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
+1 -11
View File
@@ -242,16 +242,7 @@ export const ru: Record<MessageKey, string> = {
'wallet.store': 'Магазин', 'wallet.store': 'Магазин',
'wallet.empty': 'Магазин пока пуст', 'wallet.empty': 'Магазин пока пуст',
'wallet.buy': 'Купить', 'wallet.buy': 'Купить',
'wallet.rewardWatch': 'Ролик за {n} 🪙', 'wallet.soon': 'Скоро',
'wallet.rewardCta': 'Смотреть',
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
'wallet.rewardCapped': 'Достигнут дневной лимит наград',
'wallet.rewardFailed': 'Не удалось показать ролик',
'wallet.offer': 'Публичная оферта',
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
'wallet.iosBlockedLink': 'другой версией',
'wallet.iosBlockedPost': ' игры.',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.cur.RUB': '₽', 'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов', 'wallet.cur.VOTE': 'голосов',
@@ -284,7 +275,6 @@ export const ru: Record<MessageKey, string> = {
'landing.captionTelegram': 'Telegram', 'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK', 'landing.captionVK': 'VK',
'landing.captionWeb': 'Веб-версия', 'landing.captionWeb': 'Веб-версия',
'landing.offer': 'Публичная оферта',
'install.title': 'Установить приложение', 'install.title': 'Установить приложение',
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.', 'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
-18
View File
@@ -41,7 +41,6 @@ import type {
Variant, Variant,
WordCheckResult, WordCheckResult,
Wallet, Wallet,
WalletOrder,
Catalog, Catalog,
} from '../model'; } from '../model';
import { valueForLetter } from '../alphabet'; import { valueForLetter } from '../alphabet';
@@ -126,7 +125,6 @@ export class MockGateway implements GatewayClient {
adsForever: false, adsForever: false,
adsPaidUntilMs: 0, adsPaidUntilMs: 0,
hints: 5, hints: 5,
rewardChips: 5,
}; };
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that // The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
// reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method). // reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
@@ -227,28 +225,12 @@ export class MockGateway implements GatewayClient {
} }
return this.cloneWallet(); return this.cloneWallet();
} }
async walletOrder(productId: string): Promise<WalletOrder> {
const p = this.mockCatalog.products.find((x) => x.productId === productId);
if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
// No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches
// the provider hand-off without leaving the app.
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
}
async walletReward(_nonce: string): Promise<Wallet> {
// Mock rewarded credit: add the configured payout to the vk segment (no cap in the mock).
const vk = this.mockWallet.segments.find((s) => s.source === 'vk');
if (vk) vk.chips += this.mockWallet.rewardChips;
return this.cloneWallet();
}
private cloneWallet(): Wallet { private cloneWallet(): Wallet {
return { return {
segments: this.mockWallet.segments.map((s) => ({ ...s })), segments: this.mockWallet.segments.map((s) => ({ ...s })),
adsForever: this.mockWallet.adsForever, adsForever: this.mockWallet.adsForever,
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs, adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
hints: this.mockWallet.hints, hints: this.mockWallet.hints,
rewardChips: this.mockWallet.rewardChips,
}; };
} }
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> { async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
-11
View File
@@ -168,21 +168,10 @@ export interface Wallet {
/** No-ads term end as unix milliseconds; 0 = no active term. */ /** No-ads term end as unix milliseconds; 0 = no active term. */
adsPaidUntilMs: number; adsPaidUntilMs: number;
hints: number; hints: number;
/** 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 this is positive. */
rewardChips: number;
} }
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/ /** 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. */ * "noads_days"/"tournament") and how many of it the product carries. */
// WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the
// client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server
// callback.
export interface WalletOrder {
orderId: string;
redirectUrl: string;
}
export interface CatalogAtom { export interface CatalogAtom {
atomType: string; atomType: string;
quantity: number; quantity: number;
-23
View File
@@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest';
import { renderOfferHtml } from './offer';
describe('renderOfferHtml', () => {
it('wraps rendered markdown in a standalone Russian HTML document', () => {
const html = renderOfferHtml('# Публичная оферта\n\nо заключении договора купли-продажи');
expect(html).toContain('<!doctype html>');
expect(html).toContain('lang="ru"');
expect(html).toContain('<title>Публичная оферта — Эрудит</title>');
// The markdown heading is rendered, not left as literal source.
expect(html).toContain('<h1>Публичная оферта</h1>');
expect(html).not.toContain('# Публичная оферта');
// A back link to the landing root is always present.
expect(html).toContain('href="/"');
});
it('renders headings, bold clause numbers and lists', () => {
const html = renderOfferHtml('## 2. Предмет\n\n**2.1.** Текст пункта.\n\n- первый\n- второй');
expect(html).toContain('<h2>2. Предмет</h2>');
expect(html).toContain('<strong>2.1.</strong>');
expect(html).toContain('<li>первый</li>');
});
});
-93
View File
@@ -1,93 +0,0 @@
import { marked } from 'marked';
/**
* renderOfferHtml renders the public-offer markdown source into a standalone,
* self-contained HTML document served statically at `/offer/`. The build emits
* the result as `dist/offer/index.html` (see the `emit-offer` plugin in
* `vite.config.ts`), which the landing container serves.
*
* The input `markdown` is trusted repository content (the owner-edited
* `ui/legal/offer_ru.md`), not user input, so the rendered HTML is deliberately
* not sanitised. The page carries its own minimal light/dark styling so it needs
* neither the app bundle nor `app.css`.
*/
export function renderOfferHtml(markdown: string): string {
const body = marked.parse(markdown, { async: false }) as string;
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Публичная оферта Эрудит</title>
<link rel="canonical" href="https://erudit-game.ru/offer/" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--text: #1a1c20;
--accent: #2563eb;
--rule: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--text: #e7e9ee;
--accent: #6ea8fe;
--rule: #242832;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 64px;
}
a {
color: var(--accent);
}
.back {
display: inline-block;
margin-bottom: 20px;
text-decoration: none;
}
h1 {
font-size: 1.6rem;
text-align: center;
}
h2 {
font-size: 1.2rem;
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid var(--rule);
}
h3 {
font-size: 1.05rem;
margin-top: 1.5em;
}
ul {
padding-left: 1.25em;
}
li {
margin: 0.25em 0;
}
</style>
</head>
<body>
<main>
<a class="back" href="/"> На главную</a>
${body}
</main>
</body>
</html>
`;
}
-15
View File
@@ -39,7 +39,6 @@ interface TelegramWebApp {
close?: () => void; close?: () => void;
openTelegramLink?: (url: string) => void; openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void; openLink?: (url: string) => void;
openInvoice?: (url: string, callback?: (status: string) => void) => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void; setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void; setBackgroundColor?: (color: string) => void;
@@ -171,20 +170,6 @@ export function telegramOpenLink(url: string): boolean {
return true; return true;
} }
/**
* telegramOpenInvoice opens a Telegram Stars invoice inside the Mini App (WebApp.openInvoice) and
* calls onStatus with the outcome ('paid' | 'cancelled' | 'failed' | 'pending'). Returns false
* outside Telegram or when the SDK lacks the method. The invoice url is the createInvoiceLink the
* bot minted for the order; the chips are credited server-side by the forwarded payment, so 'paid'
* only signals to refresh the wallet the live push is the authoritative update.
*/
export function telegramOpenInvoice(url: string, onStatus?: (status: string) => void): boolean {
const w = webApp();
if (!w?.openInvoice) return false;
w.openInvoice(url, onStatus);
return true;
}
/** /**
* telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink, * telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink,
* so Telegram opens it directly in its in-app browser instead of the WebView's generic "open * so Telegram opens it directly in its in-app browser instead of the WebView's generic "open
-6
View File
@@ -237,12 +237,6 @@ export function createTransport(baseUrl: string): GatewayClient {
async walletBuy(productId: string) { async walletBuy(productId: string) {
return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId))); return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId)));
}, },
async walletOrder(productId: string) {
return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId)));
},
async walletReward(nonce: string) {
return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce)));
},
async friendsList() { async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty())); return codec.decodeFriendList(await exec('friends.list', codec.empty()));
-43
View File
@@ -182,49 +182,6 @@ export async function vkShowImages(url: string): Promise<boolean> {
} }
} }
/**
* vkShowOrderBox opens VK's payment box for a "голоса" item purchase; item is the order id the
* backend created (VK echoes it to our payment callback, which credits). Resolves true when the
* payment completes, false on cancel/failure or outside VK. The chips are credited by the server
* callback, so the wallet refreshes from the payment push regardless of this result.
*/
export async function vkShowOrderBox(item: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShowOrderBox', { type: 'item', item });
return true;
} catch {
return false;
}
}
/**
* vkRewardedReady reports whether a rewarded ad is preloaded and ready to show
* (VKWebAppCheckNativeAds, required before a rewarded view). A false or an unavailable method
* hides the "watch for chips" button, since tapping it would show nothing.
*/
export async function vkRewardedReady(): Promise<boolean> {
try {
const data = await (await bridge()).send('VKWebAppCheckNativeAds', { ad_format: 'reward' });
return !!(data as { result?: boolean }).result;
} catch {
return false;
}
}
/**
* vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched
* (data.result VK returns only this boolean, no server-verifiable token). A rejected call reports
* not-watched.
*/
export async function vkShowRewarded(): Promise<boolean> {
try {
const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' });
return !!(data as { result?: boolean }).result;
} catch {
return false;
}
}
/** /**
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile)
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false * the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
+1 -1
View File
@@ -7,7 +7,7 @@ function seg(source: string, chips: number, spendable = true): WalletSegment {
} }
function wallet(segments: WalletSegment[]): Wallet { function wallet(segments: WalletSegment[]): Wallet {
return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0, rewardChips: 0 }; return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0 };
} }
describe('money formatting', () => { describe('money formatting', () => {
+8 -163
View File
@@ -1,17 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import { app, handleError, showToast } from '../lib/app.svelte'; import { handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { normalizeSubtype } from '../lib/platform';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet'; import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
import { isGooglePlayBuild } from '../lib/distribution'; import { isGooglePlayBuild } from '../lib/distribution';
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
import { telegramOpenInvoice } from '../lib/telegram';
import { rewardedReady, showRewarded } from '../lib/ads';
import { GatewayError } from '../lib/client';
import type { Wallet, Catalog, CatalogProduct } from '../lib/model'; import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront. // The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
@@ -23,61 +17,16 @@
let wallet = $state<Wallet | null>(null); let wallet = $state<Wallet | null>(null);
let catalog = $state<Catalog | null>(null); let catalog = $state<Catalog | null>(null);
let loading = $state(true); let loading = $state(true);
// The product whose purchase is in flight, or null. It both guards against a concurrent purchase let busy = $state(false);
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
let busyId = $state<string | null>(null);
const busy = $derived(busyId !== null);
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips). // The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
let warnProduct = $state<CatalogProduct | null>(null); let warnProduct = $state<CatalogProduct | null>(null);
const context: SpendContext = executionContext(); const context: SpendContext = executionContext();
const gpBuild = isGooglePlayBuild(); const gpBuild = isGooglePlayBuild();
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
// The "other version" link points at this deployment's own site root (the landing lists every
// platform), so it follows prod vs the contour without a hardcoded host.
const siteRoot = typeof location !== 'undefined' ? `${location.origin}/` : '/';
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []); const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []); const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
let rewardReady = $state(false);
const canReward = $derived(!!wallet && wallet.rewardChips > 0 && rewardReady);
// onWatchAd shows a rewarded ad and, if watched, credits the payout server-side (client-attested +
// a server daily/hourly cap). A stub view (contour test) shows an "ad fired" marker; a reached cap
// is surfaced as its own toast, distinct from a generic error.
async function onWatchAd() {
if (busy) return;
busyId = 'reward';
try {
const res = await showRewarded();
if (res.stub) showToast('ad fired');
if (!res.watched) {
showToast(t('wallet.rewardFailed'));
return;
}
const nonce = crypto.randomUUID();
const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0;
const w = await gateway.walletReward(nonce);
wallet = w;
const gained = (w.segments.find((s) => s.source === 'vk')?.chips ?? 0) - before;
showToast(gained > 0 ? t('wallet.rewardCredited', { n: gained }) : t('wallet.rewardCredited', { n: w.rewardChips }));
} catch (e) {
if (e instanceof GatewayError && e.code === 'reward_capped') {
showToast(t('wallet.rewardCapped'));
} else {
handleError(e);
}
} finally {
busyId = null;
}
}
async function load() { async function load() {
try { try {
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]); const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
@@ -89,29 +38,7 @@
loading = false; loading = false;
} }
} }
onMount(() => { onMount(load);
void load();
// Probe rewarded-ad readiness once so the "watch for chips" button only shows when a view would
// actually play.
void rewardedReady().then((r) => (rewardReady = r));
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
// from the provider's payment window.
const onVisible = () => {
if (!document.hidden) void load();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
});
// Main path: a payment-intake push bumps app.walletRefresh; re-fetch in place when it changes
// (a credit landed while the screen is open).
let seenRefresh = app.walletRefresh;
$effect(() => {
if (app.walletRefresh !== seenRefresh) {
seenRefresh = app.walletRefresh;
void load();
}
});
function sourceLabel(source: string): string { function sourceLabel(source: string): string {
return t(`wallet.source.${source}` as MessageKey); return t(`wallet.source.${source}` as MessageKey);
@@ -142,42 +69,13 @@
async function doBuy(p: CatalogProduct) { async function doBuy(p: CatalogProduct) {
warnProduct = null; warnProduct = null;
if (busy) return; if (busy) return;
busyId = p.productId; busy = true;
try { try {
wallet = await gateway.walletBuy(p.productId); wallet = await gateway.walletBuy(p.productId);
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
busyId = null; busy = false;
}
}
// onOrder funds a chip pack with money: it opens a pending order and sends the player to the
// provider's hosted-payment page. The chips are credited later, by the verified server callback;
// paying accepts the public offer (linked below the packs).
async function onOrder(p: CatalogProduct) {
if (busy) return;
busyId = p.productId;
try {
const order = await gateway.walletOrder(p.productId);
if (context === 'vk') {
// VK settles the payment in-app via the bridge; the chips arrive on the server callback.
await vkShowOrderBox(order.orderId);
} else if (context === 'telegram') {
// Telegram Stars: the bot minted an invoice link (returned as redirectUrl); open it in-app.
// The chips are credited server-side by the forwarded payment and arrive on the live push;
// refresh on 'paid' as a fallback. Outside a Stars-capable client, fall back to the link.
const opened = telegramOpenInvoice(order.redirectUrl, (status) => {
if (status === 'paid') void load();
});
if (!opened) openExternalUrl(order.redirectUrl);
} else {
openExternalUrl(order.redirectUrl);
}
} catch (e) {
handleError(e);
} finally {
busyId = null;
} }
} }
</script> </script>
@@ -207,25 +105,11 @@
<section> <section>
<h3>{t('wallet.store')}</h3> <h3>{t('wallet.store')}</h3>
{#if purchaseBlocked}
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
{/if}
{#if canReward}
<div class="row reward" data-testid="reward-row">
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
>{t('wallet.rewardCta')}</button
>
</div>
{/if}
{#each values as p (p.productId)} {#each values as p (p.productId)}
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}> <div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
<span class="name">{p.title}</span> <span class="name">{p.title}</span>
<span class="price">🪙 {p.chips}</span> <span class="price">🪙 {p.chips}</span>
<button class="buy" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)} <button class="buy" data-testid="buy" disabled={busy} onclick={() => onBuy(p)}>{t('wallet.buy')}</button>
>{t('wallet.buy')}</button
>
</div> </div>
{/each} {/each}
@@ -236,24 +120,9 @@
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}> <div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
<span class="name">{p.title}</span> <span class="name">{p.title}</span>
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span> <span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button <button class="buy" disabled>{t('wallet.soon')}</button>
class="buy"
class:blocked={purchaseBlocked}
class:working={busyId === p.productId}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
>
</div> </div>
{/each} {/each}
{#if packs.length > 0}
<p class="offer" data-testid="offer">
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
</p>
{/if}
{/if} {/if}
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)} {#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
@@ -325,24 +194,8 @@
cursor: pointer; cursor: pointer;
} }
.buy:disabled { .buy:disabled {
cursor: default;
}
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
.buy.blocked,
.buy.working {
opacity: 0.5; opacity: 0.5;
} cursor: default;
.ios-note {
margin: 0 0 4px;
padding: 8px 12px;
color: var(--text-muted);
font-size: 0.85rem;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
.ios-note a {
color: var(--accent);
} }
.empty, .empty,
.stub { .stub {
@@ -354,14 +207,6 @@
border: 1px dashed var(--border); border: 1px dashed var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.offer {
margin: 2px 0 0;
text-align: center;
font-size: 0.85rem;
}
.offer a {
color: var(--text-muted);
}
.warn-body { .warn-body {
margin: 0 0 14px; margin: 0 0 14px;
} }
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["vite.config.ts", "src/lib/offer.ts"] "include": ["vite.config.ts"]
} }
-18
View File
@@ -3,7 +3,6 @@ import { resolve } from 'node:path';
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import { renderOfferHtml } from './src/lib/offer';
/** /**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its * injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
@@ -39,22 +38,6 @@ function emitPolyfills(): Plugin {
}; };
} }
/**
* emitOffer renders the public offer markdown (legal/offer_ru.md) to a standalone
* static page and emits it as dist/offer/index.html, which the landing container
* serves at /offer/ (deploy/landing/Caddyfile). The markdown is the owner-editable
* source of truth, so the page is regenerated from it on every build.
*/
function emitOffer(): Plugin {
return {
name: 'emit-offer',
generateBundle() {
const md = readFileSync(resolve(import.meta.dirname, 'legal/offer_ru.md'), 'utf8');
this.emitFile({ type: 'asset', fileName: 'offer/index.html', source: renderOfferHtml(md) });
},
};
}
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock` // not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -77,7 +60,6 @@ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
svelte(), svelte(),
emitPolyfills(), emitPolyfills(),
emitOffer(),
injectBootVersion(), injectBootVersion(),
// App-shell precache for the offline mode: a custom (injectManifest) service worker precaches // App-shell precache for the offline mode: a custom (injectManifest) service worker precaches
// index.html + the hashed assets so the installed web PWA cold-launches with no network. It // index.html + the hashed assets so the installed web PWA cold-launches with no network. It