Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -366,13 +366,6 @@ jobs:
|
||||
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
|
||||
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
|
||||
# (independent of the shop's own mode). Empty login leaves the direct rail off.
|
||||
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||
ROBOKASSA_TEST: "1"
|
||||
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
||||
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
||||
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
||||
@@ -504,38 +497,6 @@ jobs:
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
exit 1
|
||||
|
||||
- name: Probe the /offer/ public offer page is served
|
||||
run: |
|
||||
set -u
|
||||
# /offer/ is a static page baked into the landing image (rendered from
|
||||
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
|
||||
# silently falls through to the landing shell (also 200) — so assert offer-specific
|
||||
# content, never just the status.
|
||||
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
|
||||
if echo "$out" | grep -q "290210610742"; then
|
||||
echo "ok: /offer/ serves the public offer page"
|
||||
else
|
||||
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
|
||||
docker logs --tail 50 scrabble-landing || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Probe the /pay/ callback route reaches the gateway
|
||||
run: |
|
||||
set -u
|
||||
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
|
||||
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
|
||||
# a 404 landing.html), which proves the edge route is wired.
|
||||
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
|
||||
echo "$out" | grep -E "HTTP/" || true
|
||||
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
|
||||
echo "ok: /pay/ reaches the gateway (non-landing response)"
|
||||
else
|
||||
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
|
||||
docker logs --tail 50 scrabble-gateway || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Probe the /dict edge route reaches the gateway
|
||||
run: |
|
||||
set -u
|
||||
|
||||
@@ -33,8 +33,8 @@ status — without re-deriving decisions.
|
||||
| E1 | Trusted platform signal | 1 | DONE |
|
||||
| E2 | Currency + benefit core | 1 | DONE |
|
||||
| E3 | Wallet UI | 1 | DONE |
|
||||
| E4 | Durability (PITR) | 2 | DONE |
|
||||
| E5 | Payment intake | 2 | DONE |
|
||||
| E4 | Durability (PITR) | 2 | WIP |
|
||||
| E5 | Payment intake | 2 | TODO |
|
||||
| E6 | Ads | 2 | TODO |
|
||||
| E7 | Admin & reports | 2 | TODO |
|
||||
| E8 | Guest limits | — | TODO |
|
||||
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
|
||||
|
||||
## E4 — Durability (PITR)
|
||||
|
||||
**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** ·
|
||||
depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
|
||||
**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before
|
||||
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
|
||||
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.
|
||||
- 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
|
||||
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
|
||||
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
|
||||
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
|
||||
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
|
||||
intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming).
|
||||
**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates
|
||||
the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
|
||||
then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance
|
||||
window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with
|
||||
`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps:
|
||||
`deploy/README.md` (point-in-time recovery — arming).
|
||||
|
||||
**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
|
||||
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
|
||||
valid + the custom PG image builds (archiving inert on the contour).
|
||||
|
||||
**Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an
|
||||
isolated one-shot target; cost + perf assessment reviewed; runbook current in
|
||||
`deploy/README.md`.
|
||||
**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL
|
||||
archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook
|
||||
current in `deploy/README.md`.
|
||||
|
||||
**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`
|
||||
(docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer
|
||||
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
|
||||
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
|
||||
from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance
|
||||
window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
|
||||
|
||||
---
|
||||
|
||||
## E5 — Payment intake
|
||||
|
||||
**Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
|
||||
|
||||
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
|
||||
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
|
||||
(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД
|
||||
receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a
|
||||
chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is
|
||||
**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`:
|
||||
the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the
|
||||
`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36
|
||||
confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public
|
||||
`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the
|
||||
`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a
|
||||
self-closing provider-return page (the payment opens in a separate window) and a return-focus
|
||||
refetch fallback. The **VK Votes rail** is delivered too: the client opens
|
||||
`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack
|
||||
title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent
|
||||
on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`,
|
||||
already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on
|
||||
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
|
||||
bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse
|
||||
bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's
|
||||
`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`**
|
||||
unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the
|
||||
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
|
||||
order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox**
|
||||
(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same
|
||||
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
|
||||
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
|
||||
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
|
||||
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
|
||||
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
|
||||
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
|
||||
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
|
||||
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
|
||||
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
|
||||
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
|
||||
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
|
||||
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
|
||||
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
|
||||
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
|
||||
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement).
|
||||
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
|
||||
|
||||
**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,
|
||||
@@ -568,14 +525,12 @@ receipts, and refunds.
|
||||
|
||||
**TG bot outbox (`platform/telegram/`).**
|
||||
|
||||
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
|
||||
store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
|
||||
mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
|
||||
directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
|
||||
mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
|
||||
`telegram_payment_charge_id`.
|
||||
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
|
||||
`CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.
|
||||
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
|
||||
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
|
||||
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
|
||||
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
|
||||
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
|
||||
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
|
||||
|
||||
**Events & notifications.**
|
||||
|
||||
@@ -587,13 +542,9 @@ receipts, and refunds.
|
||||
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
|
||||
handles Votes tax itself; TG Stars — no receipt.
|
||||
|
||||
**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
|
||||
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
|
||||
VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort,
|
||||
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
|
||||
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
|
||||
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
|
||||
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).
|
||||
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
|
||||
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
|
||||
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
|
||||
|
||||
**Tests.**
|
||||
|
||||
|
||||
@@ -308,7 +308,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
Notifier: hub,
|
||||
ExportSignKey: cfg.ExportSignKey,
|
||||
Renderer: renderer,
|
||||
Robokassa: cfg.Robokassa,
|
||||
})
|
||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||
|
||||
@@ -317,13 +316,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Info("servers starting",
|
||||
zap.String("http_addr", cfg.HTTPAddr),
|
||||
zap.String("grpc_addr", cfg.GRPCAddr))
|
||||
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
|
||||
// still credits). Runs until ctx is cancelled.
|
||||
go runOrderReaper(ctx, paymentsSvc, logger)
|
||||
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
|
||||
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
|
||||
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
|
||||
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- pushSrv.Run(ctx) }()
|
||||
go func() { errc <- srv.Run(ctx) }()
|
||||
@@ -333,52 +325,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
|
||||
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
|
||||
// order.
|
||||
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if n, err := p.ExpireOrders(ctx); err != nil {
|
||||
log.Warn("order reaper: sweep failed", zap.Error(err))
|
||||
} else if n > 0 {
|
||||
log.Info("order reaper: expired pending orders", zap.Int("count", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh
|
||||
// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit
|
||||
// already committed to the ledger; this is only the in-app push so an open wallet updates in place.
|
||||
func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) {
|
||||
t := time.NewTicker(3 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
evs, err := p.UndispatchedEvents(ctx, 50)
|
||||
if err != nil {
|
||||
log.Warn("payment dispatcher: read failed", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
for _, e := range evs {
|
||||
pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment))
|
||||
if err := p.MarkEventDispatched(ctx, e.EventID); err != nil {
|
||||
log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
|
||||
// configured, otherwise the development log mailer (the code is logged, not sent).
|
||||
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
|
||||
|
||||
@@ -393,21 +393,6 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
|
||||
// recovery anchor a first purchase requires (D36).
|
||||
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||
ids, err := s.Identities(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id.Kind == "email" && id.Confirmed {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
||||
// by limit and offset.
|
||||
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
@@ -65,9 +64,6 @@ type Config struct {
|
||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||
RendererURL string
|
||||
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
|
||||
// leaves the direct order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -157,13 +153,6 @@ func Load() (Config, error) {
|
||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||
}
|
||||
|
||||
robo := robokassa.Config{
|
||||
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
|
||||
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
|
||||
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
|
||||
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
|
||||
}
|
||||
|
||||
c := Config{
|
||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||
@@ -181,7 +170,6 @@ func Load() (Config, error) {
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
Robokassa: robo,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,469 +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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
|
||||
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) {
|
||||
t.Fatalf("fund = %v, want ErrAmountMismatch", err)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 0 {
|
||||
t.Error("fund ledger row written on an amount mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
|
||||
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
|
||||
func TestPaymentsEventDispatchDrain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
|
||||
t.Fatalf("record event: %v", err)
|
||||
}
|
||||
|
||||
// testDB is shared, so filter the queue to our account.
|
||||
find := func() *payments.PaymentEvent {
|
||||
evs, err := svc.UndispatchedEvents(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("undispatched: %v", err)
|
||||
}
|
||||
for i := range evs {
|
||||
if evs[i].AccountID == acc {
|
||||
return &evs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
mine := find()
|
||||
if mine == nil {
|
||||
t.Fatal("recorded event not in the undispatched queue")
|
||||
}
|
||||
if mine.Type != "succeeded" {
|
||||
t.Errorf("event type = %s, want succeeded", mine.Type)
|
||||
}
|
||||
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
|
||||
t.Fatalf("mark dispatched: %v", err)
|
||||
}
|
||||
if find() != nil {
|
||||
t.Error("event still undispatched after MarkEventDispatched")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
|
||||
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
|
||||
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
// Age the order well past the configured TTL, then sweep it to expired.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil {
|
||||
t.Fatalf("age order: %v", err)
|
||||
}
|
||||
if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 {
|
||||
t.Fatalf("expire orders = %d (err %v), want at least 1", n, err)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "expired" {
|
||||
t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID))
|
||||
}
|
||||
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
|
||||
if err != nil {
|
||||
t.Fatalf("fund after expiry: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 100 {
|
||||
t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance = %d, want 100 (expired order honoured)", got)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
|
||||
}
|
||||
}
|
||||
|
||||
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
|
||||
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
return res.OrderID, acc
|
||||
}
|
||||
|
||||
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
|
||||
func TestPaymentsRefundFull(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
|
||||
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
|
||||
}
|
||||
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
|
||||
if orderStatus(t, orderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
|
||||
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
|
||||
func TestPaymentsRefundAfterSpend(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
// Simulate 70 chips already spent, leaving 30 in the funded segment.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("simulate spend: %v", err)
|
||||
}
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.Revoked != 30 || out.Loss != 70 {
|
||||
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
|
||||
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
|
||||
// more — the ledger idempotency index holds.
|
||||
func TestPaymentsRefundIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
|
||||
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
|
||||
t.Fatalf("first refund: %v", err)
|
||||
}
|
||||
// Re-credit the segment to prove the duplicate does not revoke again.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("re-credit: %v", err)
|
||||
}
|
||||
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate refund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyRefunded {
|
||||
t.Error("duplicate refund not flagged AlreadyRefunded")
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
|
||||
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
|
||||
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,6 @@ const (
|
||||
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
|
||||
// so it re-fetches profile.get. It carries no payload. In-app only.
|
||||
NotifyProfile = "profile"
|
||||
// NotifyPayment tells the client that the viewer's wallet changed from a payment-intake event
|
||||
// (a credit or a refund), so it re-fetches the wallet in place. It carries no payload. In-app
|
||||
// only; the payment_events dispatcher emits it after the crediting transaction commits.
|
||||
NotifyPayment = "payment"
|
||||
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
|
||||
// carrying the blocked account, so every one of the blocker's sessions updates the
|
||||
// in-game block/add-friend controls and the struck name in place. It is delivered
|
||||
|
||||
@@ -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
|
||||
// fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount
|
||||
// field (Robokassa OutSum) takes.
|
||||
func (m Money) Major() string {
|
||||
// String renders the amount as "<value> <currency>", with the currency's
|
||||
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
|
||||
func (m Money) String() string {
|
||||
scale := m.currency.minorPerUnit()
|
||||
if scale == 1 {
|
||||
return fmt.Sprintf("%d", m.minor)
|
||||
return fmt.Sprintf("%d %s", m.minor, m.currency)
|
||||
}
|
||||
neg := m.minor < 0
|
||||
abs := m.minor
|
||||
@@ -168,10 +167,5 @@ func (m Money) Major() string {
|
||||
if neg {
|
||||
sign = "-"
|
||||
}
|
||||
return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale)
|
||||
}
|
||||
|
||||
// String renders the amount as "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
|
||||
func (m Money) String() string {
|
||||
return m.Major() + " " + string(m.currency)
|
||||
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
|
||||
}
|
||||
|
||||
@@ -1,164 +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
|
||||
}
|
||||
|
||||
// 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 spend 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,506 +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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
|
||||
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
@@ -230,11 +230,8 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
|
||||
// providerPaymentID are set only on an intake credit (fund/refund) and are nil for a
|
||||
// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial
|
||||
// unique index, so a duplicate provider callback fails here (the idempotency key).
|
||||
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error {
|
||||
// insertLedgerTx appends one append-only ledger row inside tx.
|
||||
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID *uuid.UUID, snapshot []byte, now time.Time) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
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(
|
||||
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
|
||||
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
|
||||
table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider,
|
||||
table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
|
||||
table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
|
||||
).VALUES(
|
||||
id, accountID, kind,
|
||||
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
|
||||
uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider),
|
||||
stringOrNull(providerPaymentID), snap, now,
|
||||
uuidOrNull(productID), snap, now,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error {
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, 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 applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||
@@ -424,11 +419,3 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression {
|
||||
}
|
||||
return postgres.UUID(*id)
|
||||
}
|
||||
|
||||
// stringOrNull renders an optional string as a SQL string or NULL.
|
||||
func stringOrNull(s *string) postgres.Expression {
|
||||
if s == nil {
|
||||
return postgres.NULL
|
||||
}
|
||||
return postgres.String(*s)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ package table
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
AccountRisk = AccountRisk.FromSchema(schema)
|
||||
Balances = Balances.FromSchema(schema)
|
||||
Benefits = Benefits.FromSchema(schema)
|
||||
CatalogAtom = CatalogAtom.FromSchema(schema)
|
||||
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
@@ -73,21 +73,6 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
}
|
||||
if s.payments != nil {
|
||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
|
||||
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
|
||||
// the Robokassa Result callback when a merchant is configured.
|
||||
u.POST("/wallet/order", s.handleWalletOrder)
|
||||
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
||||
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
|
||||
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
||||
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
|
||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||
if s.robokassa.MerchantLogin != "" {
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
}
|
||||
}
|
||||
if s.links != nil {
|
||||
// Account linking & merge. The request step always mails a code;
|
||||
// a required merge is revealed only after the code is verified, and the
|
||||
@@ -281,8 +266,6 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusNotFound, "product_not_found"
|
||||
case errors.Is(err, payments.ErrNotAValue):
|
||||
return http.StatusBadRequest, "not_a_value"
|
||||
case errors.Is(err, payments.ErrNotAPack):
|
||||
return http.StatusBadRequest, "not_a_pack"
|
||||
case errors.Is(err, account.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "invalid_email"
|
||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||
|
||||
@@ -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(¶ms); err != nil {
|
||||
c.String(http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
v := url.Values{}
|
||||
for k, val := range params {
|
||||
v.Set(k, val)
|
||||
}
|
||||
orderID, outSum, ok := s.robokassa.VerifyResult(v)
|
||||
if !ok {
|
||||
s.log.Warn("robokassa result: bad signature")
|
||||
c.String(http.StatusBadRequest, "bad sign")
|
||||
return
|
||||
}
|
||||
paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "bad amount")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid)
|
||||
if err != nil {
|
||||
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
|
||||
s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.String(http.StatusBadRequest, "rejected")
|
||||
return
|
||||
}
|
||||
s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
return
|
||||
}
|
||||
if !outcome.AlreadyCredited {
|
||||
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
|
||||
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
|
||||
}
|
||||
|
||||
// vkErrorResponse builds VK's error envelope for a payment callback.
|
||||
func vkErrorResponse(code int, msg string, critical bool) gin.H {
|
||||
return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
|
||||
}
|
||||
|
||||
// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
|
||||
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
|
||||
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
|
||||
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
|
||||
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
|
||||
// the sandbox notifications and are handled identically.
|
||||
func (s *Server) handleVKCallback(c *gin.Context) {
|
||||
var params map[string]string
|
||||
if err := c.ShouldBindJSON(¶ms); err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
switch params["notification_type"] {
|
||||
case "get_item", "get_item_test":
|
||||
orderID, err := uuid.Parse(params["item"])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
||||
return
|
||||
}
|
||||
title, amount, err := s.payments.OrderItem(ctx, orderID)
|
||||
if err != nil {
|
||||
s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"response": gin.H{
|
||||
"item_id": orderID.String(),
|
||||
"title": title,
|
||||
"price": amount.Minor(),
|
||||
}})
|
||||
case "order_status_change", "order_status_change_test":
|
||||
if params["status"] != "chargeable" {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
|
||||
return
|
||||
}
|
||||
orderID, err := uuid.Parse(params["item"])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
||||
return
|
||||
}
|
||||
price, err := strconv.ParseInt(params["item_price"], 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
|
||||
return
|
||||
}
|
||||
paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
|
||||
return
|
||||
}
|
||||
vkOrderID := params["order_id"]
|
||||
outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, paid)
|
||||
if err != nil {
|
||||
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
|
||||
s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
|
||||
return
|
||||
}
|
||||
s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
|
||||
return
|
||||
}
|
||||
if !outcome.AlreadyCredited {
|
||||
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||
s.log.Error("record vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
|
||||
appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
|
||||
c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
|
||||
default:
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
|
||||
}
|
||||
}
|
||||
|
||||
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
|
||||
// the order in the invoice payload and the amount and currency Telegram is about to charge.
|
||||
type telegramPreCheckoutRequest struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
|
||||
// short message the bot surfaces to the payer on a decline.
|
||||
type telegramPreCheckoutResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
|
||||
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
|
||||
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
|
||||
// is refused here, before any star moves) and whose amount and currency match. A malformed
|
||||
// reference is a decline, not an error.
|
||||
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
|
||||
var req telegramPreCheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
orderID, err := uuid.Parse(req.OrderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
|
||||
return
|
||||
}
|
||||
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
|
||||
return
|
||||
}
|
||||
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
|
||||
if err != nil {
|
||||
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
|
||||
return
|
||||
}
|
||||
reason := ""
|
||||
if !out.OK {
|
||||
// Localise the decline to the order account's preferred language (the reason shows in the
|
||||
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
|
||||
lang := ""
|
||||
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
|
||||
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
|
||||
lang = acc.PreferredLanguage
|
||||
}
|
||||
}
|
||||
reason = telegramDeclineText(out.Reason, lang)
|
||||
}
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
|
||||
}
|
||||
|
||||
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
|
||||
// anything else falls back to English), for display in the Telegram payment sheet.
|
||||
func telegramDeclineText(code, lang string) string {
|
||||
ru := lang == "ru"
|
||||
switch code {
|
||||
case payments.PreCheckoutAlreadyPaid:
|
||||
if ru {
|
||||
return "Этот заказ уже оплачен."
|
||||
}
|
||||
return "This order has already been paid."
|
||||
case payments.PreCheckoutPriceChanged:
|
||||
if ru {
|
||||
return "Цена изменилась — начните покупку заново."
|
||||
}
|
||||
return "The price has changed; please start the purchase again."
|
||||
default:
|
||||
if ru {
|
||||
return "Этот заказ больше недоступен."
|
||||
}
|
||||
return "This order is no longer available."
|
||||
}
|
||||
}
|
||||
|
||||
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
|
||||
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
|
||||
type telegramPaymentRequest struct {
|
||||
OrderID string `json:"order_id"`
|
||||
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
TelegramUserID int64 `json:"telegram_user_id"`
|
||||
}
|
||||
|
||||
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
|
||||
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
|
||||
// (the bot drops it); a 5xx means a transient failure the bot retries.
|
||||
type telegramPaymentResponse struct {
|
||||
Credited bool `json:"credited"`
|
||||
}
|
||||
|
||||
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
|
||||
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
|
||||
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
|
||||
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
|
||||
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
|
||||
// the bot retries.
|
||||
func (s *Server) handleTelegramPayment(c *gin.Context) {
|
||||
var req telegramPaymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
orderID, err := uuid.Parse(req.OrderID)
|
||||
if err != nil {
|
||||
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
|
||||
if err != nil {
|
||||
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
|
||||
// The star charge already happened but the order cannot be placed; record it loudly for
|
||||
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
|
||||
s.log.Error("telegram payment rejected (charge taken, not credited)",
|
||||
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
|
||||
return
|
||||
}
|
||||
if !outcome.AlreadyCredited {
|
||||
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
@@ -110,9 +109,6 @@ type Deps struct {
|
||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||
Renderer *render.Client
|
||||
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
||||
// order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -140,7 +136,6 @@ type Server struct {
|
||||
banview *banview.View
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
robokassa robokassa.Config
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
@@ -194,7 +189,6 @@ func New(addr string, deps Deps) *Server {
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
payments: deps.Payments,
|
||||
robokassa: deps.Robokassa,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
|
||||
+12
-31
@@ -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 —
|
||||
protecting the money ledger and the game state against corruption or host loss. It is the
|
||||
primary recovery path; the migration-window `pg_dump` above is the secondary net.
|
||||
**Live on the prod main host since v1.13.0** (armed + restore-drilled 2026-07-09).
|
||||
|
||||
**Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus
|
||||
**continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every
|
||||
@@ -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
|
||||
30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under
|
||||
1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is
|
||||
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup is
|
||||
small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and checkpoint-bound
|
||||
(~1.5 min wall, minimal CPU/I-O) on the 2 vCPU host. Revisit if traffic grows ~100× (watch
|
||||
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup
|
||||
is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch
|
||||
`node_exporter` during a base backup).
|
||||
|
||||
**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
|
||||
with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the
|
||||
minute until step 3 creates the repository — the WAL is retained, not lost.)
|
||||
3. On the main host, create the repository, verify archiving, take the first base backup. Run
|
||||
pgBackRest **as the `postgres` OS user** (`-u postgres` — lock-dir/PGDATA consistency with
|
||||
`archive-push`) and connect to the DB **as the superuser role** (`--pg1-user=scrabble`, the
|
||||
`POSTGRES_USER`, not `postgres`); it inherits the container's `PGBACKREST_*` env:
|
||||
3. On the main host, create the repository, take the first base backup, and verify:
|
||||
```sh
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble info # (info takes no --pg1-user)
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble check
|
||||
```
|
||||
The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the
|
||||
expected transient (WAL retained, not lost); clear the counter afterwards with
|
||||
`docker exec -u postgres scrabble-postgres psql -U scrabble -d scrabble -c "SELECT pg_stat_reset_shared('archiver');"`
|
||||
so it does not trip the failing-archive alert.
|
||||
4. Enable the daily timer: `ansible-playbook site.yml --limit main -e pitr_enabled=true`
|
||||
(installs + starts `pgbackrest-backup.timer` on the main host).
|
||||
4. Enable the daily timer: `ansible-playbook site.yml -e pitr_enabled=true` (installs + starts
|
||||
`pgbackrest-backup.timer` on the main host).
|
||||
5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a
|
||||
real number, `failed_count` flat).
|
||||
|
||||
@@ -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:
|
||||
|
||||
```sh
|
||||
# Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an
|
||||
# empty PGDATA and no app network; restore, recover, verify, then wipe (-v drops the data).
|
||||
IMG=$(docker inspect -f '{{.Config.Image}}' scrabble-postgres)
|
||||
docker exec scrabble-postgres env | grep '^PGBACKREST_' > /tmp/drill.env; echo POSTGRES_PASSWORD=drill >> /tmp/drill.env
|
||||
docker run -d --name pitr-drill --env-file /tmp/drill.env --entrypoint sleep "$IMG" infinity
|
||||
docker exec pitr-drill sh -c 'rm -rf /var/lib/postgresql/data/*; chown -R postgres:postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data'
|
||||
# --type=immediate = to the base backup's consistency point; --type=time "--target=<ts>" for PITR
|
||||
docker exec -u postgres pitr-drill pgbackrest --stanza=scrabble --pg1-path=/var/lib/postgresql/data --type=immediate restore
|
||||
docker exec -u postgres pitr-drill pg_ctl -D /var/lib/postgresql/data -w start
|
||||
docker exec -u postgres pitr-drill psql -U scrabble -d scrabble -c "SELECT count(*) FROM backend.accounts;" # verify data intact
|
||||
docker rm -fv pitr-drill; rm -f /tmp/drill.env # wipe the restored real data
|
||||
pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore
|
||||
# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail
|
||||
```
|
||||
|
||||
The target holds **real money + personal data** while it exists — keep it network-isolated and
|
||||
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
|
||||
timestamp, outcome) here:
|
||||
|
||||
| Date | Target | 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
|
||||
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
|
||||
|
||||
@@ -10,8 +10,6 @@ Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and
|
||||
# PGDATA consistency with archive-push, and connect to the database as the superuser role via
|
||||
# --pg1-user: that role is the POSTGRES_USER (scrabble), not "postgres". It inherits the
|
||||
# container's PGBACKREST_* environment (the S3 repository + cipher), so no host-side config.
|
||||
ExecStart=/usr/bin/docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
|
||||
# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_*
|
||||
# environment (the S3 repository + cipher), so no repository config is needed on the host.
|
||||
ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /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 {
|
||||
reverse_proxy gateway:8081 {
|
||||
header_up -X-Scrabble-Honeypot
|
||||
|
||||
@@ -28,10 +28,6 @@ services:
|
||||
# the manage-topics and delete-messages rights.
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
||||
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
|
||||
# Stars rail; empty disables it. The rail stays inert until a chip pack carries an XTR (Stars)
|
||||
# price, so it is safe to leave on — seeding a Stars price in the admin is the real go-live.
|
||||
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
|
||||
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||
|
||||
@@ -161,13 +161,6 @@ services:
|
||||
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
|
||||
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
|
||||
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
|
||||
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
|
||||
# direct order and Result-callback endpoints unregistered (the rail is off).
|
||||
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
|
||||
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
|
||||
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
|
||||
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
|
||||
# The dictionary lives on a named volume seeded from the image on first boot
|
||||
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
||||
# inherits). The admin console writes new version subdirectories here, and the
|
||||
@@ -385,10 +378,6 @@ services:
|
||||
# set the bot must be an admin there with the manage-topics and delete-messages rights.
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
||||
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
|
||||
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
|
||||
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
|
||||
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
|
||||
# The optional standalone promo bot (its own token) answering /start with a button
|
||||
# into the main bot's app. Empty disables it; when set it needs the main bot's
|
||||
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
|
||||
|
||||
@@ -18,25 +18,10 @@
|
||||
@shell not path /assets/*
|
||||
header @shell Cache-Control "no-cache"
|
||||
|
||||
# The static public offer page, rendered from ui/legal/offer_ru.md at build
|
||||
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
|
||||
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
|
||||
# landing shell below (whose index is landing.html). A bare /offer redirects in.
|
||||
handle /offer {
|
||||
redir * /offer/ permanent
|
||||
}
|
||||
handle /offer/* {
|
||||
file_server {
|
||||
index index.html
|
||||
}
|
||||
}
|
||||
|
||||
# An unknown path falls back to the landing shell (the gateway's old "/"
|
||||
# behaviour); "/" itself resolves through the index below.
|
||||
handle {
|
||||
try_files {path} /landing.html
|
||||
file_server {
|
||||
index landing.html
|
||||
}
|
||||
try_files {path} /landing.html
|
||||
file_server {
|
||||
index landing.html
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
|
||||
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
|
||||
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). It also
|
||||
carries the **Telegram Stars payment path** (§payments): a `CreateInvoice` command has the bot mint a
|
||||
`createInvoiceLink` (XTR) and return it in the Ack; the bot's unary `ValidatePreCheckout` gates a
|
||||
`pre_checkout_query` against the intake (declining an already-paid reusable invoice before the
|
||||
charge); and its unary `ForwardPayment` delivers a completed `successful_payment` — durably queued in
|
||||
a bot-side **SQLite outbox** (`platform/telegram/internal/outbox`, re-driven on restart) — which the
|
||||
gateway proxies to the backend intake, credited once (idempotent on `telegram_payment_charge_id`). An optional
|
||||
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional
|
||||
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
|
||||
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
|
||||
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
|
||||
@@ -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
|
||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
|
||||
catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
|
||||
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
|
||||
catch-all — notably the landing at `/` — goes to the landing container. The
|
||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
|
||||
|
||||
+14
-31
@@ -219,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 —
|
||||
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
|
||||
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
|
||||
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
|
||||
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
|
||||
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
|
||||
(backed by the intake): approve only if the order exists, is still creditable and is **not already
|
||||
paid** — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
|
||||
before the charge; the decline reason is localised to the order account's language.
|
||||
|
||||
`successful_payment` reaches the bot only (Bot API, not the Mini App), and the bot host is weak and
|
||||
can lose connectivity, so the bot is a durable link. Store-and-forward on **SQLite** on the bot's
|
||||
disk (`internal/outbox`): persist on receipt (idempotent on `telegram_payment_charge_id`) → forward
|
||||
over the bot-link (a `ForwardPayment` unary; the gateway proxies to the intake) → on a durable
|
||||
response, mark `forwarded`. Re-drives undelivered on restart and on a periodic tick. At-least-once
|
||||
delivery + idempotent intake (dedup by `telegram_payment_charge_id`) = credited exactly once.
|
||||
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
|
||||
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
|
||||
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
|
||||
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
|
||||
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
|
||||
intake = credited exactly once.
|
||||
|
||||
**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
|
||||
@@ -241,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
|
||||
message).
|
||||
|
||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are
|
||||
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa
|
||||
refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a
|
||||
deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and
|
||||
Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine —
|
||||
the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger
|
||||
row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's
|
||||
payment id, so the two rows coexist under the same partial-unique index), and **best-effort revokes
|
||||
the funded chips floored at 0** (never negative — D27, `balances_chips_chk`). When the chips were
|
||||
already spent, the unrecoverable remainder is recorded as a per-account **loss + abuse flag**
|
||||
(`payments.account_risk`, read by the E7 report). The refund ledger row's chip delta is what was
|
||||
actually reclaimed, so the ledger stays reconcilable against the balance; the **full** reversal
|
||||
(money, original chips, loss) rides in the row's snapshot. The order stays `paid` — the refund lives
|
||||
in the ledger + a `refunded` payment event, not in the order status. A duplicate refund reverses
|
||||
nothing. The ledger is **export-ready** for future tax reporting and reconciliation (the reconciler
|
||||
itself is not built yet; the schema stays compatible).
|
||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
|
||||
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
|
||||
account — tie into `accountdelete`, which already preserves messages). **External** refunds
|
||||
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
|
||||
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
|
||||
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
|
||||
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
|
||||
yet; the schema stays compatible).
|
||||
|
||||
## 10. Ads
|
||||
|
||||
|
||||
+14
-30
@@ -220,22 +220,12 @@ provider_payment_id)`.
|
||||
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
|
||||
|
||||
**TG Stars.** До Telegram дотягивается только **бот**, поэтому весь рельс идёт через обратный
|
||||
mTLS **bot-link** (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути
|
||||
заказа gateway шлёт команду `CreateInvoice`, а бот возвращает `createInvoiceLink` (XTR) в Ack,
|
||||
который Mini App открывает через `WebApp.openInvoice`. До списания звёзд бот отвечает на
|
||||
`pre_checkout_query` через унарный вызов бот→gateway `ValidatePreCheckout` (за ним — приём): одобрить,
|
||||
только если заказ существует, ещё оплачиваем и **не оплачен ранее** — ссылка Stars-инвойса
|
||||
переиспользуема, так что этот гейт — единственное место, где повторная оплата отсекается **до**
|
||||
списания; текст отказа локализован в язык аккаунта заказа.
|
||||
|
||||
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а хост бота
|
||||
слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на **SQLite** на диске
|
||||
бота (`internal/outbox`): сохранил при получении (идемпотентно по `telegram_payment_charge_id`) →
|
||||
форвардит по bot-link (унарный `ForwardPayment`; gateway проксирует в приём) → при durable-ответе
|
||||
пометил `forwarded`. Дореталивает недоставленное при рестарте и по периодическому тику. Доставка
|
||||
at-least-once + идемпотентный приём (дедуп по `telegram_payment_charge_id`) = начисление ровно один
|
||||
раз.
|
||||
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
|
||||
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
|
||||
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
|
||||
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
|
||||
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
|
||||
at-least-once + идемпотентный приём = начисление ровно один раз.
|
||||
|
||||
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
|
||||
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
|
||||
@@ -243,20 +233,14 @@ at-least-once + идемпотентный приём (дедуп по `telegram
|
||||
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
|
||||
бота).
|
||||
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
|
||||
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
|
||||
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
|
||||
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
|
||||
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
||||
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
||||
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
||||
полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный
|
||||
остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает
|
||||
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
|
||||
с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
|
||||
`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не
|
||||
отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности
|
||||
и сверки (саму сверку пока не строим; схема остаётся совместимой).
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
|
||||
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
|
||||
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
|
||||
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
|
||||
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
|
||||
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
|
||||
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
|
||||
(саму сверку пока не строим; схема остаётся совместимой).
|
||||
|
||||
## 10. Реклама
|
||||
|
||||
|
||||
@@ -153,16 +153,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
r, rerr := backend.ChatEligibility(ctx, externalID)
|
||||
return r.Registered, r.Eligible, rerr
|
||||
})
|
||||
// The Telegram Stars payment bridge rides the same bot-link: the bot validates each
|
||||
// pre_checkout and forwards each completed payment through these, backed by the backend intake.
|
||||
botHub.SetPaymentBridge(
|
||||
func(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
|
||||
return backend.ValidatePreCheckout(ctx, orderID, amount, currency)
|
||||
},
|
||||
func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
|
||||
return backend.TelegramPayment(ctx, orderID, chargeID, amount, telegramUserID)
|
||||
},
|
||||
)
|
||||
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
|
||||
if terr != nil {
|
||||
return terr
|
||||
@@ -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)
|
||||
logger.Info("vk id web login enabled")
|
||||
}
|
||||
regOpts := []transcode.Option{transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)}
|
||||
if botHub != nil {
|
||||
// A Telegram-context wallet order mints its Stars invoice link through the connected bot.
|
||||
regOpts = append(regOpts, transcode.WithTelegramStars(botHub))
|
||||
}
|
||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
@@ -223,7 +208,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
|
||||
@@ -396,89 +396,6 @@ func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (Walle
|
||||
return out, err
|
||||
}
|
||||
|
||||
// walletOrderBody is the POST body of an order: the chip pack to fund.
|
||||
type walletOrderBody struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// WalletOrderResp is a created order: its id and the rail's launch details. RedirectURL is the
|
||||
// provider's hosted-payment URL (direct); Rail names the settling rail; for the Telegram Stars rail
|
||||
// InvoiceTitle and InvoiceAmount (whole stars) are the invoice the gateway mints via the bot.
|
||||
type WalletOrderResp struct {
|
||||
OrderID string `json:"order_id"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
Rail string `json:"rail"`
|
||||
InvoiceTitle string `json:"invoice_title"`
|
||||
InvoiceAmount int64 `json:"invoice_amount"`
|
||||
}
|
||||
|
||||
// WalletOrder opens a pending order to fund a chip pack and returns the rail's launch details.
|
||||
func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) {
|
||||
var out WalletOrderResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// preCheckoutResp is the backend intake's pre_checkout answer.
|
||||
type preCheckoutResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ValidatePreCheckout asks the backend intake whether a Telegram Stars pre_checkout_query for
|
||||
// orderID paying amount in currency may be approved, before the charge. It returns the approval and
|
||||
// a short decline reason for the payer. It backs the bot's pre_checkout gate through the bot-link.
|
||||
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
|
||||
var out preCheckoutResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/precheckout", "", "",
|
||||
map[string]any{"order_id": orderID, "amount": amount, "currency": currency}, &out)
|
||||
return out.OK, out.Reason, err
|
||||
}
|
||||
|
||||
// telegramPaymentResp is the backend intake's credit outcome.
|
||||
type telegramPaymentResp struct {
|
||||
Credited bool `json:"credited"`
|
||||
}
|
||||
|
||||
// TelegramPayment forwards a completed Telegram Stars payment from the bot's outbox to the backend
|
||||
// intake, which credits the order idempotently on chargeID. It reports whether the order was
|
||||
// credited (or already had been); a transport error is a transient failure the bot retries.
|
||||
func (c *Client) TelegramPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
|
||||
var out telegramPaymentResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/payment", "", "",
|
||||
map[string]any{
|
||||
"order_id": orderID,
|
||||
"telegram_payment_charge_id": chargeID,
|
||||
"amount": amount,
|
||||
"telegram_user_id": telegramUserID,
|
||||
}, &out)
|
||||
return out.Credited, err
|
||||
}
|
||||
|
||||
// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa.
|
||||
type robokassaResultResp struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
|
||||
// single writer, which verifies the signature and credits) and returns the body to echo to
|
||||
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
|
||||
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
|
||||
var out robokassaResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
|
||||
return out.Response, err
|
||||
}
|
||||
|
||||
// VKCallback forwards a gateway-verified VK payment callback's parameters to the backend intake and
|
||||
// returns the backend's raw VK response envelope (`{"response":…}` or `{"error":…}`) for the gateway
|
||||
// to relay to VK verbatim. The backend answers 200 in every case (VK's protocol), so a transport
|
||||
// error here is a genuine proxy failure.
|
||||
func (c *Client) VKCallback(ctx context.Context, params map[string]string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/vk/callback", "", "", params, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity.
|
||||
type CatalogAtomResp struct {
|
||||
AtomType string `json:"atom_type"`
|
||||
|
||||
@@ -48,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,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
@@ -32,11 +31,6 @@ var ErrNoBot = errors.New("botlink: no bot connected")
|
||||
// (at-most-once under backpressure).
|
||||
const outboundBuffer = 64
|
||||
|
||||
// invoiceMintTimeout bounds one synchronous invoice-mint round-trip (the gateway commands the bot
|
||||
// and awaits the Ack carrying the createInvoiceLink result), so a hung or slow bot cannot stall the
|
||||
// caller's wallet-order request indefinitely.
|
||||
const invoiceMintTimeout = 15 * time.Second
|
||||
|
||||
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
|
||||
// for the bot's join-time ResolveChatEligibility query: registered reports whether the
|
||||
// identity maps to an account, eligible is the final gate the bot acts on (registered
|
||||
@@ -44,17 +38,6 @@ const invoiceMintTimeout = 15 * time.Second
|
||||
// chat-access endpoint.
|
||||
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
|
||||
|
||||
// PreCheckoutResolver validates a Telegram Stars pre_checkout_query for the bot's
|
||||
// ValidatePreCheckout query: it answers whether the order may still be charged (ok) and, when not,
|
||||
// a short reason to show the payer. The gateway backs it with the backend intake.
|
||||
type PreCheckoutResolver func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
|
||||
|
||||
// PaymentForwarder delivers a completed Telegram Stars payment for the bot's ForwardPayment call:
|
||||
// it credits the order through the backend intake and reports whether it was credited (or already
|
||||
// had been). A non-nil error is a transient failure the bot retries. The gateway backs it with the
|
||||
// backend intake.
|
||||
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
|
||||
|
||||
// Hub registers connected bots and routes send commands to them. A single bot is
|
||||
// expected today; the registry already holds a set so adding more later needs no
|
||||
// rewrite.
|
||||
@@ -63,10 +46,6 @@ type Hub struct {
|
||||
|
||||
log *zap.Logger
|
||||
eligibility EligibilityResolver
|
||||
// precheck and forward back the Telegram Stars payment RPCs; nil until SetPaymentBridge wires
|
||||
// them, in which case the RPCs report Unavailable. Set once before the gRPC server serves.
|
||||
precheck PreCheckoutResolver
|
||||
forward PaymentForwarder
|
||||
|
||||
mu sync.Mutex
|
||||
links map[*link]struct{}
|
||||
@@ -168,105 +147,6 @@ func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEli
|
||||
return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil
|
||||
}
|
||||
|
||||
// SetPaymentBridge wires the Telegram Stars payment resolvers, backing ValidatePreCheckout and
|
||||
// ForwardPayment. It must be called before the gRPC server starts serving (the fields are read
|
||||
// without a lock, relying on that happens-before). A nil resolver leaves its RPC reporting
|
||||
// Unavailable.
|
||||
func (h *Hub) SetPaymentBridge(precheck PreCheckoutResolver, forward PaymentForwarder) {
|
||||
h.precheck = precheck
|
||||
h.forward = forward
|
||||
}
|
||||
|
||||
// ValidatePreCheckout serves the bot's pre_checkout validation over the same mTLS channel: it
|
||||
// delegates to the configured resolver (the backend intake) and returns whether the order may be
|
||||
// charged. A resolver failure maps to Internal, which the bot treats as a decline (fail-closed).
|
||||
func (h *Hub) ValidatePreCheckout(ctx context.Context, req *botlinkv1.PreCheckoutRequest) (*botlinkv1.PreCheckoutResponse, error) {
|
||||
if h.precheck == nil {
|
||||
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
|
||||
}
|
||||
ok, reason, err := h.precheck(ctx, req.GetOrderId(), req.GetAmount(), req.GetCurrency())
|
||||
if err != nil {
|
||||
h.log.Warn("validate pre_checkout failed", zap.String("order", req.GetOrderId()), zap.Error(err))
|
||||
return nil, status.Error(codes.Internal, "validate pre_checkout")
|
||||
}
|
||||
return &botlinkv1.PreCheckoutResponse{Ok: ok, Reason: reason}, nil
|
||||
}
|
||||
|
||||
// ForwardPayment serves the bot's completed-payment delivery: it credits the order through the
|
||||
// configured forwarder (the backend intake) and reports the durable outcome. A forwarder failure
|
||||
// maps to Internal, which the bot treats as transient and retries; a clean response (credited true
|
||||
// or false) lets the bot forget the outbox row.
|
||||
func (h *Hub) ForwardPayment(ctx context.Context, req *botlinkv1.ForwardPaymentRequest) (*botlinkv1.ForwardPaymentResponse, error) {
|
||||
if h.forward == nil {
|
||||
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
|
||||
}
|
||||
credited, err := h.forward(ctx, req.GetOrderId(), req.GetTelegramPaymentChargeId(), req.GetAmount(), req.GetTelegramUserId())
|
||||
if err != nil {
|
||||
h.log.Warn("forward telegram payment failed", zap.String("order", req.GetOrderId()), zap.Error(err))
|
||||
return nil, status.Error(codes.Internal, "forward payment")
|
||||
}
|
||||
return &botlinkv1.ForwardPaymentResponse{Credited: credited}, nil
|
||||
}
|
||||
|
||||
// MintInvoice commands the connected bot to mint a Telegram Stars invoice link for the order and
|
||||
// returns the link. It is a bounded, synchronous round-trip (invoiceMintTimeout): no bot connected
|
||||
// returns ErrNoBot, and a bot error or an empty link is an error the caller surfaces as a failed
|
||||
// order launch.
|
||||
func (h *Hub) MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, invoiceMintTimeout)
|
||||
defer cancel()
|
||||
ack, err := h.sendAwaitAck(cctx, CreateInvoiceCommand(title, description, orderID, amountStars))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ack.GetResult() == "" {
|
||||
return "", errors.New("botlink: bot returned an empty invoice link")
|
||||
}
|
||||
return ack.GetResult(), nil
|
||||
}
|
||||
|
||||
// sendAwaitAck enqueues a command and waits for the bot's full Ack (or ctx). It mirrors SendAwait
|
||||
// but returns the Ack — the invoice-mint path needs its result field — and reports ctx expiry as an
|
||||
// error rather than a not-delivered, since a timed-out mint has no link to return.
|
||||
func (h *Hub) sendAwaitAck(ctx context.Context, cmd *botlinkv1.Command) (*botlinkv1.Ack, error) {
|
||||
l, ok := h.pick()
|
||||
if !ok {
|
||||
h.count("dropped")
|
||||
return nil, ErrNoBot
|
||||
}
|
||||
id := h.nextID()
|
||||
cmd.CommandId = id
|
||||
ackc := make(chan *botlinkv1.Ack, 1)
|
||||
h.mu.Lock()
|
||||
h.pending[id] = ackc
|
||||
h.mu.Unlock()
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.pending, id)
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case l.out <- &botlinkv1.ToBot{Command: cmd}:
|
||||
case <-ctx.Done():
|
||||
h.count("dropped")
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case ack := <-ackc:
|
||||
if e := ack.GetError(); e != "" {
|
||||
h.count("error")
|
||||
return nil, errors.New(e)
|
||||
}
|
||||
h.count(deliveredLabel(ack.GetDelivered()))
|
||||
return ack, nil
|
||||
case <-ctx.Done():
|
||||
h.count("error")
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// register adds a connected bot.
|
||||
func (h *Hub) register(l *link) {
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
"scrabble/gateway/internal/session"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
"scrabble/gateway/internal/vkpay"
|
||||
"scrabble/gateway/internal/webui"
|
||||
edgev1 "scrabble/gateway/proto/edge/v1"
|
||||
"scrabble/gateway/proto/edge/v1/edgev1connect"
|
||||
@@ -71,19 +70,18 @@ const (
|
||||
|
||||
// Server implements edgev1connect.GatewayHandler.
|
||||
type Server struct {
|
||||
registry *transcode.Registry
|
||||
sessions *session.Cache
|
||||
backend *backendclient.Client
|
||||
limiter *ratelimit.Limiter
|
||||
tracker *ratelimit.Tracker
|
||||
banlist *ratelimit.Banlist
|
||||
honeytoken string
|
||||
vkAppSecret string
|
||||
hub *push.Hub
|
||||
heartbeat time.Duration
|
||||
log *zap.Logger
|
||||
adminProxy http.Handler
|
||||
metrics *serverMetrics
|
||||
registry *transcode.Registry
|
||||
sessions *session.Cache
|
||||
backend *backendclient.Client
|
||||
limiter *ratelimit.Limiter
|
||||
tracker *ratelimit.Tracker
|
||||
banlist *ratelimit.Banlist
|
||||
honeytoken string
|
||||
hub *push.Hub
|
||||
heartbeat time.Duration
|
||||
log *zap.Logger
|
||||
adminProxy http.Handler
|
||||
metrics *serverMetrics
|
||||
|
||||
maxBodyBytes int
|
||||
|
||||
@@ -112,15 +110,12 @@ type Deps struct {
|
||||
// Honeytoken, when non-empty, is the planted bearer value whose presentation
|
||||
// bans the caller and raises a high-severity alarm.
|
||||
Honeytoken string
|
||||
// VKAppSecret is the VK Mini App protected key; it verifies the VK payment callback signature
|
||||
// (the same key the launch-signature auth uses). Empty leaves the VK callback rejecting.
|
||||
VKAppSecret string
|
||||
Hub *push.Hub
|
||||
RateLimit config.RateLimitConfig
|
||||
Heartbeat time.Duration
|
||||
Logger *zap.Logger
|
||||
AdminProxy http.Handler
|
||||
Meter metric.Meter
|
||||
Hub *push.Hub
|
||||
RateLimit config.RateLimitConfig
|
||||
Heartbeat time.Duration
|
||||
Logger *zap.Logger
|
||||
AdminProxy http.Handler
|
||||
Meter metric.Meter
|
||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||
MaxBodyBytes int
|
||||
@@ -156,7 +151,6 @@ func NewServer(d Deps) *Server {
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
backend: d.Backend,
|
||||
vkAppSecret: d.VKAppSecret,
|
||||
limiter: limiter,
|
||||
tracker: tracker,
|
||||
banlist: banlist,
|
||||
@@ -202,12 +196,6 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// through this session-gated route (not public); see dictBytesHandler.
|
||||
mux.Handle("/dict/", s.dictBytesHandler())
|
||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
|
||||
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
|
||||
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
|
||||
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
|
||||
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
|
||||
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
|
||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
|
||||
@@ -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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
|
||||
@@ -37,19 +37,6 @@ func encodeAck(ok bool) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeWalletOrder builds a WalletOrderResponse payload: the created order id and the provider
|
||||
// launch URL the client opens.
|
||||
func encodeWalletOrder(o backendclient.WalletOrderResp) []byte {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
oid := b.CreateString(o.OrderID)
|
||||
url := b.CreateString(o.RedirectURL)
|
||||
fb.WalletOrderResponseStart(b)
|
||||
fb.WalletOrderResponseAddOrderId(b, oid)
|
||||
fb.WalletOrderResponseAddRedirectUrl(b, url)
|
||||
b.Finish(fb.WalletOrderResponseEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which
|
||||
// deletion step-up the account uses ("email" | "phrase").
|
||||
func encodeDeleteRequestResult(method string) []byte {
|
||||
|
||||
@@ -55,7 +55,6 @@ const (
|
||||
MsgWalletGet = "wallet.get"
|
||||
MsgWalletCatalog = "wallet.catalog"
|
||||
MsgWalletBuy = "wallet.buy"
|
||||
MsgWalletOrder = "wallet.order"
|
||||
)
|
||||
|
||||
// Request is one decoded Execute call.
|
||||
@@ -112,7 +111,6 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
|
||||
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
|
||||
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
|
||||
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
||||
@@ -170,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.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
@@ -344,41 +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
|
||||
}
|
||||
}
|
||||
|
||||
func blockStatusHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
bs, err := backend.BlockStatus(ctx, req.UserID)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
-20
@@ -73,12 +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/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk=
|
||||
github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw=
|
||||
github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4=
|
||||
@@ -97,7 +95,6 @@ github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6K
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
||||
github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
|
||||
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
|
||||
@@ -149,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/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
|
||||
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
|
||||
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
|
||||
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
@@ -177,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/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
@@ -185,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/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
@@ -206,17 +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=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
|
||||
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/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/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
||||
|
||||
@@ -873,18 +873,6 @@ table WalletBuyRequest {
|
||||
product_id:string;
|
||||
}
|
||||
|
||||
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
|
||||
table WalletOrderRequest {
|
||||
product_id:string;
|
||||
}
|
||||
|
||||
// WalletOrderResponse returns the created order id and the provider launch URL the client opens
|
||||
// (the Robokassa hosted-payment page); chips are credited later, by the verified server callback.
|
||||
table WalletOrderResponse {
|
||||
order_id:string;
|
||||
redirect_url:string;
|
||||
}
|
||||
|
||||
// CatalogAtom is one atom line of a storefront product: the base value type it grants
|
||||
// ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries.
|
||||
table CatalogAtom {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -225,7 +225,6 @@ type Command struct {
|
||||
// *Command_SendToUser
|
||||
// *Command_SendToChannel
|
||||
// *Command_ChatGate
|
||||
// *Command_CreateInvoice
|
||||
Payload isCommand_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -311,15 +310,6 @@ func (x *Command) GetChatGate() *ChatGateCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Command) GetCreateInvoice() *CreateInvoiceCommand {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*Command_CreateInvoice); ok {
|
||||
return x.CreateInvoice
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isCommand_Payload interface {
|
||||
isCommand_Payload()
|
||||
}
|
||||
@@ -340,10 +330,6 @@ type Command_ChatGate struct {
|
||||
ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"`
|
||||
}
|
||||
|
||||
type Command_CreateInvoice struct {
|
||||
CreateInvoice *CreateInvoiceCommand `protobuf:"bytes,6,opt,name=create_invoice,json=createInvoice,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Command_Notify) isCommand_Payload() {}
|
||||
|
||||
func (*Command_SendToUser) isCommand_Payload() {}
|
||||
@@ -352,20 +338,15 @@ func (*Command_SendToChannel) isCommand_Payload() {}
|
||||
|
||||
func (*Command_ChatGate) isCommand_Payload() {}
|
||||
|
||||
func (*Command_CreateInvoice) isCommand_Payload() {}
|
||||
|
||||
// Ack reports the outcome of the Command with command_id. delivered mirrors the
|
||||
// connector delivery semantics (false when the kind is not rendered out-of-app, the
|
||||
// user never started the bot, or no channel is configured); error carries an
|
||||
// unexpected transport/render failure, distinct from a clean not-delivered. result
|
||||
// carries a command's return value when it has one (the created invoice link for a
|
||||
// create_invoice command); it is empty otherwise.
|
||||
// unexpected transport/render failure, distinct from a clean not-delivered.
|
||||
type Ack struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"`
|
||||
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
|
||||
Result string `protobuf:"bytes,4,opt,name=result,proto3" json:"result,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -421,13 +402,6 @@ func (x *Ack) GetError() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Ack) GetResult() string {
|
||||
if x != nil {
|
||||
return x.Result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
|
||||
// chat. external_id is the user's Telegram identity (as in the backend identities
|
||||
// table); allow grants the right to write when true and revokes it when false. The
|
||||
@@ -588,314 +562,6 @@ func (x *ChatEligibilityResponse) GetEligible() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
|
||||
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
|
||||
// echoes back in the pre_checkout_query and the successful_payment; amount is the
|
||||
// price in whole stars; title and description are shown on the invoice. The bot
|
||||
// returns the link in its Ack result.
|
||||
type CreateInvoiceCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
|
||||
Payload string `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CreateInvoiceCommand) Reset() {
|
||||
*x = CreateInvoiceCommand{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CreateInvoiceCommand) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CreateInvoiceCommand) ProtoMessage() {}
|
||||
|
||||
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
|
||||
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *CreateInvoiceCommand) GetTitle() string {
|
||||
if x != nil {
|
||||
return x.Title
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CreateInvoiceCommand) GetDescription() string {
|
||||
if x != nil {
|
||||
return x.Description
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CreateInvoiceCommand) GetPayload() string {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CreateInvoiceCommand) GetAmount() int64 {
|
||||
if x != nil {
|
||||
return x.Amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
|
||||
// (whole stars) in currency may be approved before the charge.
|
||||
type PreCheckoutRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
|
||||
Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||
Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PreCheckoutRequest) Reset() {
|
||||
*x = PreCheckoutRequest{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PreCheckoutRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PreCheckoutRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
|
||||
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *PreCheckoutRequest) GetOrderId() string {
|
||||
if x != nil {
|
||||
return x.OrderId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PreCheckoutRequest) GetAmount() int64 {
|
||||
if x != nil {
|
||||
return x.Amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *PreCheckoutRequest) GetCurrency() string {
|
||||
if x != nil {
|
||||
return x.Currency
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
|
||||
// short user-facing decline message when ok is false.
|
||||
type PreCheckoutResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PreCheckoutResponse) Reset() {
|
||||
*x = PreCheckoutResponse{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PreCheckoutResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PreCheckoutResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
|
||||
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *PreCheckoutResponse) GetOk() bool {
|
||||
if x != nil {
|
||||
return x.Ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *PreCheckoutResponse) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
|
||||
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
|
||||
// and the payer's Telegram user id.
|
||||
type ForwardPaymentRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
|
||||
TelegramPaymentChargeId string `protobuf:"bytes,2,opt,name=telegram_payment_charge_id,json=telegramPaymentChargeId,proto3" json:"telegram_payment_charge_id,omitempty"`
|
||||
Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||
TelegramUserId int64 `protobuf:"varint,4,opt,name=telegram_user_id,json=telegramUserId,proto3" json:"telegram_user_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentRequest) Reset() {
|
||||
*x = ForwardPaymentRequest{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ForwardPaymentRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentRequest) GetOrderId() string {
|
||||
if x != nil {
|
||||
return x.OrderId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentRequest) GetTelegramPaymentChargeId() string {
|
||||
if x != nil {
|
||||
return x.TelegramPaymentChargeId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentRequest) GetAmount() int64 {
|
||||
if x != nil {
|
||||
return x.Amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentRequest) GetTelegramUserId() int64 {
|
||||
if x != nil {
|
||||
return x.TelegramUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
|
||||
// was credited (or already had been); false means the payment was recorded but could
|
||||
// not be matched to a creditable order (an operator follows up). Either way the bot
|
||||
// may forget the outbox row — only a transport error triggers a retry.
|
||||
type ForwardPaymentResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Credited bool `protobuf:"varint,1,opt,name=credited,proto3" json:"credited,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentResponse) Reset() {
|
||||
*x = ForwardPaymentResponse{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ForwardPaymentResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentResponse) GetCredited() bool {
|
||||
if x != nil {
|
||||
return x.Credited
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||
@@ -910,7 +576,7 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||
"\x05Hello\x12\x1f\n" +
|
||||
"\vinstance_id\x18\x01 \x01(\tR\n" +
|
||||
"instanceId\x12!\n" +
|
||||
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xb2\x03\n" +
|
||||
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xde\x02\n" +
|
||||
"\aCommand\x12\x1d\n" +
|
||||
"\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" +
|
||||
"sendToUser\x12X\n" +
|
||||
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" +
|
||||
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGate\x12R\n" +
|
||||
"\x0ecreate_invoice\x18\x06 \x01(\v2).scrabble.botlink.v1.CreateInvoiceCommandH\x00R\rcreateInvoiceB\t\n" +
|
||||
"\apayload\"p\n" +
|
||||
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGateB\t\n" +
|
||||
"\apayload\"X\n" +
|
||||
"\x03Ack\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
|
||||
"\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" +
|
||||
"\x05error\x18\x03 \x01(\tR\x05error\x12\x16\n" +
|
||||
"\x06result\x18\x04 \x01(\tR\x06result\"H\n" +
|
||||
"\x05error\x18\x03 \x01(\tR\x05error\"H\n" +
|
||||
"\x0fChatGateCommand\x12\x1f\n" +
|
||||
"\vexternal_id\x18\x01 \x01(\tR\n" +
|
||||
"externalId\x12\x14\n" +
|
||||
@@ -938,31 +602,10 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"registered\x18\x01 \x01(\bR\n" +
|
||||
"registered\x12\x1a\n" +
|
||||
"\beligible\x18\x02 \x01(\bR\beligible\"\x80\x01\n" +
|
||||
"\x14CreateInvoiceCommand\x12\x14\n" +
|
||||
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
|
||||
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x18\n" +
|
||||
"\apayload\x18\x03 \x01(\tR\apayload\x12\x16\n" +
|
||||
"\x06amount\x18\x04 \x01(\x03R\x06amount\"c\n" +
|
||||
"\x12PreCheckoutRequest\x12\x19\n" +
|
||||
"\border_id\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||
"\x06amount\x18\x02 \x01(\x03R\x06amount\x12\x1a\n" +
|
||||
"\bcurrency\x18\x03 \x01(\tR\bcurrency\"=\n" +
|
||||
"\x13PreCheckoutResponse\x12\x0e\n" +
|
||||
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x16\n" +
|
||||
"\x06reason\x18\x02 \x01(\tR\x06reason\"\xb1\x01\n" +
|
||||
"\x15ForwardPaymentRequest\x12\x19\n" +
|
||||
"\border_id\x18\x01 \x01(\tR\aorderId\x12;\n" +
|
||||
"\x1atelegram_payment_charge_id\x18\x02 \x01(\tR\x17telegramPaymentChargeId\x12\x16\n" +
|
||||
"\x06amount\x18\x03 \x01(\x03R\x06amount\x12(\n" +
|
||||
"\x10telegram_user_id\x18\x04 \x01(\x03R\x0etelegramUserId\"4\n" +
|
||||
"\x16ForwardPaymentResponse\x12\x1a\n" +
|
||||
"\bcredited\x18\x01 \x01(\bR\bcredited2\x99\x03\n" +
|
||||
"\beligible\x18\x02 \x01(\bR\beligible2\xc4\x01\n" +
|
||||
"\aBotLink\x12D\n" +
|
||||
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" +
|
||||
"\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponse\x12h\n" +
|
||||
"\x13ValidatePreCheckout\x12'.scrabble.botlink.v1.PreCheckoutRequest\x1a(.scrabble.botlink.v1.PreCheckoutResponse\x12i\n" +
|
||||
"\x0eForwardPayment\x12*.scrabble.botlink.v1.ForwardPaymentRequest\x1a+.scrabble.botlink.v1.ForwardPaymentResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
|
||||
"\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
|
||||
|
||||
var (
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
|
||||
(*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
|
||||
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
|
||||
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
|
||||
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
|
||||
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
|
||||
(*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse
|
||||
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
|
||||
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
|
||||
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
|
||||
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
|
||||
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
|
||||
(*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest
|
||||
(*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest
|
||||
(*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest
|
||||
}
|
||||
var file_botlink_v1_botlink_proto_depIdxs = []int32{
|
||||
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
|
||||
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
|
||||
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
|
||||
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
|
||||
14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
|
||||
15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
|
||||
8, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
|
||||
9, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
|
||||
10, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
|
||||
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, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
|
||||
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
|
||||
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
|
||||
12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
|
||||
12, // [12:16] is the sub-list for method output_type
|
||||
8, // [8:12] is the sub-list for method input_type
|
||||
8, // [8:8] is the sub-list for extension type_name
|
||||
8, // [8:8] is the sub-list for extension extendee
|
||||
0, // [0:8] is the sub-list for field type_name
|
||||
0, // 7: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||
6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||
1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||
9, // [9:11] is the sub-list for method output_type
|
||||
7, // [7:9] is the sub-list for method input_type
|
||||
7, // [7:7] is the sub-list for extension type_name
|
||||
7, // [7:7] is the sub-list for extension extendee
|
||||
0, // [0:7] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_botlink_v1_botlink_proto_init() }
|
||||
@@ -1033,7 +666,6 @@ func file_botlink_v1_botlink_proto_init() {
|
||||
(*Command_SendToUser)(nil),
|
||||
(*Command_SendToChannel)(nil),
|
||||
(*Command_ChatGate)(nil),
|
||||
(*Command_CreateInvoice)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
@@ -1041,7 +673,7 @@ func file_botlink_v1_botlink_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 13,
|
||||
NumMessages: 8,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -27,24 +27,6 @@ service BotLink {
|
||||
// same mTLS channel when a user joins the chat, to decide whether to grant the
|
||||
// write permission. Delivery of the answer is request/response (not best-effort).
|
||||
rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse);
|
||||
|
||||
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
|
||||
// approved before any star is charged: the order in the invoice payload exists, is
|
||||
// still creditable (pending or an honoured-expired order, never one already paid),
|
||||
// and its amount and currency match the invoice. The bot calls it on every
|
||||
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
|
||||
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
|
||||
// one place a repeat payment is stopped before money moves. Request/response.
|
||||
rpc ValidatePreCheckout(PreCheckoutRequest) returns (PreCheckoutResponse);
|
||||
|
||||
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
|
||||
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
|
||||
// response) after Telegram confirms the payment; the gateway forwards it to the
|
||||
// backend intake, which credits the order once, idempotent on
|
||||
// telegram_payment_charge_id. A response means the payment was durably handled
|
||||
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
|
||||
// transport error leaves the row for a later retry. Request/response.
|
||||
rpc ForwardPayment(ForwardPaymentRequest) returns (ForwardPaymentResponse);
|
||||
}
|
||||
|
||||
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
|
||||
@@ -79,21 +61,17 @@ message Command {
|
||||
scrabble.telegram.v1.SendToUserRequest send_to_user = 3;
|
||||
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
|
||||
ChatGateCommand chat_gate = 5;
|
||||
CreateInvoiceCommand create_invoice = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Ack reports the outcome of the Command with command_id. delivered mirrors the
|
||||
// connector delivery semantics (false when the kind is not rendered out-of-app, the
|
||||
// user never started the bot, or no channel is configured); error carries an
|
||||
// unexpected transport/render failure, distinct from a clean not-delivered. result
|
||||
// carries a command's return value when it has one (the created invoice link for a
|
||||
// create_invoice command); it is empty otherwise.
|
||||
// unexpected transport/render failure, distinct from a clean not-delivered.
|
||||
message Ack {
|
||||
string command_id = 1;
|
||||
bool delivered = 2;
|
||||
string error = 3;
|
||||
string result = 4;
|
||||
}
|
||||
|
||||
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
|
||||
@@ -121,48 +99,3 @@ message ChatEligibilityResponse {
|
||||
bool registered = 1;
|
||||
bool eligible = 2;
|
||||
}
|
||||
|
||||
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
|
||||
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
|
||||
// echoes back in the pre_checkout_query and the successful_payment; amount is the
|
||||
// price in whole stars; title and description are shown on the invoice. The bot
|
||||
// returns the link in its Ack result.
|
||||
message CreateInvoiceCommand {
|
||||
string title = 1;
|
||||
string description = 2;
|
||||
string payload = 3;
|
||||
int64 amount = 4;
|
||||
}
|
||||
|
||||
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
|
||||
// (whole stars) in currency may be approved before the charge.
|
||||
message PreCheckoutRequest {
|
||||
string order_id = 1;
|
||||
int64 amount = 2;
|
||||
string currency = 3;
|
||||
}
|
||||
|
||||
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
|
||||
// short user-facing decline message when ok is false.
|
||||
message PreCheckoutResponse {
|
||||
bool ok = 1;
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
|
||||
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
|
||||
// and the payer's Telegram user id.
|
||||
message ForwardPaymentRequest {
|
||||
string order_id = 1;
|
||||
string telegram_payment_charge_id = 2;
|
||||
int64 amount = 3;
|
||||
int64 telegram_user_id = 4;
|
||||
}
|
||||
|
||||
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
|
||||
// was credited (or already had been); false means the payment was recorded but could
|
||||
// not be matched to a creditable order (an operator follows up). Either way the bot
|
||||
// may forget the outbox row — only a transport error triggers a retry.
|
||||
message ForwardPaymentResponse {
|
||||
bool credited = 1;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ const _ = grpc.SupportPackageIsVersion9
|
||||
const (
|
||||
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
|
||||
BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility"
|
||||
BotLink_ValidatePreCheckout_FullMethodName = "/scrabble.botlink.v1.BotLink/ValidatePreCheckout"
|
||||
BotLink_ForwardPayment_FullMethodName = "/scrabble.botlink.v1.BotLink/ForwardPayment"
|
||||
)
|
||||
|
||||
// BotLinkClient is the client API for BotLink service.
|
||||
@@ -50,22 +48,6 @@ type BotLinkClient interface {
|
||||
// same mTLS channel when a user joins the chat, to decide whether to grant the
|
||||
// write permission. Delivery of the answer is request/response (not best-effort).
|
||||
ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error)
|
||||
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
|
||||
// approved before any star is charged: the order in the invoice payload exists, is
|
||||
// still creditable (pending or an honoured-expired order, never one already paid),
|
||||
// and its amount and currency match the invoice. The bot calls it on every
|
||||
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
|
||||
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
|
||||
// one place a repeat payment is stopped before money moves. Request/response.
|
||||
ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error)
|
||||
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
|
||||
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
|
||||
// response) after Telegram confirms the payment; the gateway forwards it to the
|
||||
// backend intake, which credits the order once, idempotent on
|
||||
// telegram_payment_charge_id. A response means the payment was durably handled
|
||||
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
|
||||
// transport error leaves the row for a later retry. Request/response.
|
||||
ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error)
|
||||
}
|
||||
|
||||
type botLinkClient struct {
|
||||
@@ -99,26 +81,6 @@ func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatElig
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *botLinkClient) ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PreCheckoutResponse)
|
||||
err := c.cc.Invoke(ctx, BotLink_ValidatePreCheckout_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *botLinkClient) ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ForwardPaymentResponse)
|
||||
err := c.cc.Invoke(ctx, BotLink_ForwardPayment_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BotLinkServer is the server API for BotLink service.
|
||||
// All implementations must embed UnimplementedBotLinkServer
|
||||
// for forward compatibility.
|
||||
@@ -137,22 +99,6 @@ type BotLinkServer interface {
|
||||
// same mTLS channel when a user joins the chat, to decide whether to grant the
|
||||
// write permission. Delivery of the answer is request/response (not best-effort).
|
||||
ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error)
|
||||
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
|
||||
// approved before any star is charged: the order in the invoice payload exists, is
|
||||
// still creditable (pending or an honoured-expired order, never one already paid),
|
||||
// and its amount and currency match the invoice. The bot calls it on every
|
||||
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
|
||||
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
|
||||
// one place a repeat payment is stopped before money moves. Request/response.
|
||||
ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error)
|
||||
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
|
||||
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
|
||||
// response) after Telegram confirms the payment; the gateway forwards it to the
|
||||
// backend intake, which credits the order once, idempotent on
|
||||
// telegram_payment_charge_id. A response means the payment was durably handled
|
||||
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
|
||||
// transport error leaves the row for a later retry. Request/response.
|
||||
ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error)
|
||||
mustEmbedUnimplementedBotLinkServer()
|
||||
}
|
||||
|
||||
@@ -169,12 +115,6 @@ func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot])
|
||||
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented")
|
||||
}
|
||||
func (UnimplementedBotLinkServer) ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ValidatePreCheckout not implemented")
|
||||
}
|
||||
func (UnimplementedBotLinkServer) ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ForwardPayment not implemented")
|
||||
}
|
||||
func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {}
|
||||
func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -221,42 +161,6 @@ func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BotLink_ValidatePreCheckout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PreCheckoutRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BotLinkServer).ValidatePreCheckout(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BotLink_ValidatePreCheckout_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BotLinkServer).ValidatePreCheckout(ctx, req.(*PreCheckoutRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _BotLink_ForwardPayment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ForwardPaymentRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(BotLinkServer).ForwardPayment(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: BotLink_ForwardPayment_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(BotLinkServer).ForwardPayment(ctx, req.(*ForwardPaymentRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -268,14 +172,6 @@ var BotLink_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ResolveChatEligibility",
|
||||
Handler: _BotLink_ResolveChatEligibility_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ValidatePreCheckout",
|
||||
Handler: _BotLink_ValidatePreCheckout_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ForwardPayment",
|
||||
Handler: _BotLink_ForwardPayment_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||
default 25) to respect the Bot API flood limits.
|
||||
- **Payments (Telegram Stars).** When `TELEGRAM_STARS_OUTBOX_DIR` is set (default `/data`), the
|
||||
bot handles the Stars rail. Only the bot reaches Telegram, so it mints the invoice on a
|
||||
`CreateInvoice` bot-link command (`createInvoiceLink`, XTR — the link goes back to the Mini App
|
||||
for `WebApp.openInvoice`); it gates each `pre_checkout_query` through the bot-link
|
||||
(`ValidatePreCheckout`, backed by the backend intake — declining an already-paid reusable
|
||||
invoice before the charge); and it records each `successful_payment` in a durable **SQLite
|
||||
outbox** (`internal/outbox`, `stars.db` on the writable volume) before forwarding it over the
|
||||
bot-link (`ForwardPayment`). The outbox is re-driven on startup and every 30 s, so a gateway or
|
||||
backend outage never loses a paid order; crediting is idempotent on `telegram_payment_charge_id`.
|
||||
The rail stays inert until a chip pack carries an XTR price (seeded in the admin).
|
||||
|
||||
The send commands address a recipient by the identity `external_id` (as in the backend
|
||||
`identities` table), so a future VK / MAX bot reuses them; only the validator's initData
|
||||
@@ -114,11 +104,9 @@ parsing is Telegram-specific.
|
||||
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
|
||||
relay.
|
||||
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
|
||||
on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's
|
||||
chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the
|
||||
Ack result), plus unary `ResolveChatEligibility` (the bot's join-time query),
|
||||
`ValidatePreCheckout` and `ForwardPayment` (the Stars rail) over the same mTLS channel.
|
||||
Generated Go is committed under `pkg`.
|
||||
on the gateway (`Hello` / `Command` / `Ack`), now also carrying a `ChatGateCommand` (set
|
||||
a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time
|
||||
query) over the same mTLS channel. Generated Go is committed under `pkg`.
|
||||
|
||||
## 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_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
|
||||
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
|
||||
| `TELEGRAM_STARS_OUTBOX_DIR` | `/data` | directory for the Telegram Stars payment outbox (`stars.db`, a writable volume); empty disables the Stars rail |
|
||||
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
||||
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
||||
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/botlink"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/promobot"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
@@ -91,24 +90,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
GameChannelID: cfg.GameChannelID,
|
||||
SupportChatID: cfg.SupportChatID,
|
||||
SupportStore: supportStore,
|
||||
AcceptPayments: cfg.StarsOutboxDir != "",
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The Telegram Stars payment outbox: a durable SQLite store on the bot host's writable volume.
|
||||
// Opened when the rail is enabled; a failure to open is fatal, since accepting Stars without a
|
||||
// durable outbox would risk losing a paid-for order.
|
||||
var paymentOutbox *outbox.Store
|
||||
if cfg.StarsOutboxDir != "" {
|
||||
paymentOutbox, err = outbox.Open(filepath.Join(cfg.StarsOutboxDir, "stars.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = paymentOutbox.Close() }()
|
||||
}
|
||||
|
||||
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -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 <->
|
||||
// client construction cycle.
|
||||
b.SetEligibilityResolver(client.ResolveChatEligibility)
|
||||
// The Telegram Stars payment path rides the same bot-link: pre_checkout validation and
|
||||
// completed-payment forwarding, backed by the durable outbox — wired here for the same
|
||||
// late-binding reason.
|
||||
if paymentOutbox != nil {
|
||||
b.SetPaymentHandlers(client.ValidatePreCheckout, client.ForwardPayment, paymentOutbox)
|
||||
}
|
||||
|
||||
// The optional standalone promo bot: a second bot (its own token) that only answers
|
||||
// /start with a button opening the main bot's Mini App. It is self-contained — no
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
// Re-drive the Stars payment outbox: at startup (recovering payments left by a restart or a past
|
||||
// outage) and periodically thereafter.
|
||||
if paymentOutbox != nil {
|
||||
wg.Go(func() { b.RunPaymentDrainer(ctx) })
|
||||
}
|
||||
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with
|
||||
// the main bot's lease).
|
||||
if promo != nil {
|
||||
|
||||
@@ -12,16 +12,3 @@ require (
|
||||
google.golang.org/protobuf v1.36.11
|
||||
scrabble/pkg v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.49.1 // indirect
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
@@ -50,10 +49,6 @@ type Config struct {
|
||||
// SupportStore persists the support relay's state (topic mapping, block list,
|
||||
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
||||
SupportStore *support.Store
|
||||
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
|
||||
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
|
||||
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
|
||||
AcceptPayments bool
|
||||
}
|
||||
|
||||
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||
@@ -96,12 +91,6 @@ type Bot struct {
|
||||
supportLocks *keyedMutex
|
||||
// admins caches the support chat's administrator ids (who may reply and act).
|
||||
admins *adminCache
|
||||
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
|
||||
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
|
||||
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
|
||||
precheck PreCheckoutValidator
|
||||
forward PaymentForwarder
|
||||
outbox *outbox.Store
|
||||
}
|
||||
|
||||
// New builds the bot wrapper, registering the /start handler and a default handler
|
||||
@@ -134,10 +123,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
// callback handler by their "sup:" data prefix.
|
||||
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
||||
}
|
||||
// Allowed updates default to "all except chat_member" (which already includes
|
||||
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
|
||||
// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
|
||||
// pre_checkout_query, which the explicit set would otherwise drop.
|
||||
// Allowed updates default to "all except chat_member". Specify an explicit set only
|
||||
// when we need chat_member (moderated chat) — and then re-add callback_query (which
|
||||
// the explicit set would otherwise drop) when the support relay needs it.
|
||||
if cfg.ChatID != 0 {
|
||||
allowed := tgbot.AllowedUpdates{
|
||||
models.AllowedUpdateMessage,
|
||||
@@ -147,9 +135,6 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if t.supportEnabled() {
|
||||
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
||||
}
|
||||
if cfg.AcceptPayments {
|
||||
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
|
||||
}
|
||||
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
||||
}
|
||||
if cfg.TestEnv {
|
||||
@@ -361,17 +346,6 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleChatMember(ctx, update.ChatMember)
|
||||
return
|
||||
}
|
||||
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
|
||||
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
|
||||
// message is never mistaken for a support DM or given a launch reply.
|
||||
if update.PreCheckoutQuery != nil {
|
||||
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
|
||||
return
|
||||
}
|
||||
if update.Message != nil && update.Message.SuccessfulPayment != nil {
|
||||
t.handleSuccessfulPayment(ctx, update.Message)
|
||||
return
|
||||
}
|
||||
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
||||
// is either an operator's reply in the support chat or a user's direct message to
|
||||
// relay. Everything else falls through to the Mini App launch reply.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -83,34 +83,6 @@ func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string)
|
||||
return resp.GetEligible(), nil
|
||||
}
|
||||
|
||||
// ValidatePreCheckout asks the gateway whether a Telegram Stars pre_checkout_query for orderID
|
||||
// paying amount in currency may be approved, before the charge. The bot calls it on every
|
||||
// pre_checkout_query over the same mTLS connection and approves only on ok; the reason is a short
|
||||
// decline message (already localised by the backend) to show the payer.
|
||||
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) {
|
||||
resp, err := c.client.ValidatePreCheckout(ctx, &botlinkv1.PreCheckoutRequest{OrderId: orderID, Amount: amount, Currency: currency})
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return resp.GetOk(), resp.GetReason(), nil
|
||||
}
|
||||
|
||||
// ForwardPayment delivers a completed Stars payment to the gateway for crediting. The bot calls it
|
||||
// from the outbox drain; it reports whether the order was credited (or already had been). A non-nil
|
||||
// error is a transient failure the bot retries.
|
||||
func (c *Client) ForwardPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) {
|
||||
resp, err := c.client.ForwardPayment(ctx, &botlinkv1.ForwardPaymentRequest{
|
||||
OrderId: orderID,
|
||||
TelegramPaymentChargeId: chargeID,
|
||||
Amount: amount,
|
||||
TelegramUserId: telegramUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetCredited(), nil
|
||||
}
|
||||
|
||||
// Run keeps the bot-link command stream open, re-opening it after each break, until
|
||||
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
@@ -149,8 +121,8 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
|
||||
if cmd == nil {
|
||||
continue
|
||||
}
|
||||
delivered, result, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
|
||||
delivered, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
|
||||
if herr != nil {
|
||||
ack.Error = herr.Error()
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ type Sender interface {
|
||||
// chat, but only when they are currently in it; it reports whether a restriction
|
||||
// was applied.
|
||||
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
|
||||
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged
|
||||
// with payload (the order id, echoed back in pre_checkout and successful_payment), and
|
||||
// returns the link.
|
||||
CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error)
|
||||
}
|
||||
|
||||
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
|
||||
@@ -52,42 +48,22 @@ func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
|
||||
return &Executor{sender: sender, channelID: channelID, log: log}
|
||||
}
|
||||
|
||||
// Handle dispatches one command to the matching Bot API call. It returns whether the command was
|
||||
// delivered, an optional result string (the created invoice link for a create_invoice command; empty
|
||||
// otherwise), and an error for an unexpected or malformed failure.
|
||||
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, string, error) {
|
||||
// Handle dispatches one command to the matching Bot API send.
|
||||
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
|
||||
switch p := cmd.GetPayload().(type) {
|
||||
case *botlinkv1.Command_Notify:
|
||||
d, err := e.notify(ctx, p.Notify)
|
||||
return d, "", err
|
||||
return e.notify(ctx, p.Notify)
|
||||
case *botlinkv1.Command_SendToUser:
|
||||
d, err := e.sendToUser(ctx, p.SendToUser)
|
||||
return d, "", err
|
||||
return e.sendToUser(ctx, p.SendToUser)
|
||||
case *botlinkv1.Command_SendToChannel:
|
||||
d, err := e.sendToChannel(ctx, p.SendToChannel)
|
||||
return d, "", err
|
||||
return e.sendToChannel(ctx, p.SendToChannel)
|
||||
case *botlinkv1.Command_ChatGate:
|
||||
d, err := e.chatGate(ctx, p.ChatGate)
|
||||
return d, "", err
|
||||
case *botlinkv1.Command_CreateInvoice:
|
||||
return e.createInvoice(ctx, p.CreateInvoice)
|
||||
return e.chatGate(ctx, p.ChatGate)
|
||||
default:
|
||||
return false, "", fmt.Errorf("botlink: empty command")
|
||||
return false, fmt.Errorf("botlink: empty command")
|
||||
}
|
||||
}
|
||||
|
||||
// createInvoice mints a Telegram Stars invoice link for the order and returns it in the Ack result.
|
||||
// A Bot API failure is a hard error carried back in the Ack, so the gateway's synchronous mint fails
|
||||
// (rather than returning an empty link).
|
||||
func (e *Executor) createInvoice(ctx context.Context, req *botlinkv1.CreateInvoiceCommand) (bool, string, error) {
|
||||
link, err := e.sender.CreateInvoiceLink(ctx, req.GetTitle(), req.GetDescription(), req.GetPayload(), req.GetAmount())
|
||||
if err != nil {
|
||||
e.log.Warn("create invoice link failed", zap.String("order", req.GetPayload()), zap.Error(err))
|
||||
return false, "", err
|
||||
}
|
||||
return true, link, nil
|
||||
}
|
||||
|
||||
// chatGate applies a chat-gate command: it parses the target Telegram user id and
|
||||
// sets their write access in the moderated chat (a no-op when they are not in it). A
|
||||
// Bot API failure is logged and reported as not-delivered, not a hard error.
|
||||
|
||||
@@ -13,13 +13,11 @@ import (
|
||||
|
||||
// fakeSender records the delivery calls the executor makes.
|
||||
type fakeSender struct {
|
||||
notify []notifyCall
|
||||
text []textCall
|
||||
gate []gateCall
|
||||
invoice []invoiceCall
|
||||
invoiceLink string // CreateInvoiceLink's returned link
|
||||
applied bool // ApplyChatGate's reported result
|
||||
err error
|
||||
notify []notifyCall
|
||||
text []textCall
|
||||
gate []gateCall
|
||||
applied bool // ApplyChatGate's reported result
|
||||
err error
|
||||
}
|
||||
|
||||
type notifyCall struct {
|
||||
@@ -34,10 +32,6 @@ type gateCall struct {
|
||||
userID int64
|
||||
allow bool
|
||||
}
|
||||
type invoiceCall struct {
|
||||
title, description, payload string
|
||||
amount int64
|
||||
}
|
||||
|
||||
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
|
||||
@@ -54,11 +48,6 @@ func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool)
|
||||
return f.applied, f.err
|
||||
}
|
||||
|
||||
func (f *fakeSender) CreateInvoiceLink(_ context.Context, title, description, payload string, amountStars int64) (string, error) {
|
||||
f.invoice = append(f.invoice, invoiceCall{title, description, payload, amountStars})
|
||||
return f.invoiceLink, f.err
|
||||
}
|
||||
|
||||
func yourTurnPayload(gameID string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
gid := b.CreateString(gameID)
|
||||
@@ -78,7 +67,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
||||
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
|
||||
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -96,7 +85,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
||||
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
|
||||
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -110,7 +99,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
|
||||
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
|
||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
||||
if _, _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
|
||||
if _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
|
||||
t.Error("expected an error for a non-numeric external_id")
|
||||
}
|
||||
}
|
||||
@@ -119,7 +108,7 @@ func TestExecutorSendToUser(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
|
||||
delivered, _, err := exec.Handle(context.Background(), cmd)
|
||||
delivered, err := exec.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -137,7 +126,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
|
||||
func TestExecutorChatGateApplied(t *testing.T) {
|
||||
sender := &fakeSender{applied: true}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("777", true))
|
||||
delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -149,7 +138,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
|
||||
func TestExecutorChatGateNotInChat(t *testing.T) {
|
||||
sender := &fakeSender{applied: false} // user not in the chat
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("888", false))
|
||||
delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -163,53 +152,24 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
|
||||
|
||||
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
||||
if _, _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
|
||||
if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
|
||||
t.Error("expected an error for a non-numeric external_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorCreateInvoice(t *testing.T) {
|
||||
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
||||
}}}
|
||||
delivered, result, err := exec.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
if !delivered || result != "https://t.me/$abc" {
|
||||
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
||||
}
|
||||
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
||||
t.Errorf("invoice calls = %+v", sender.invoice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorCreateInvoiceError(t *testing.T) {
|
||||
sender := &fakeSender{err: context.DeadlineExceeded}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: "x", Description: "x", Payload: "order-2", Amount: 10,
|
||||
}}}
|
||||
if _, _, err := exec.Handle(context.Background(), cmd); err == nil {
|
||||
t.Error("expected an error when minting the invoice fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorSendToChannel(t *testing.T) {
|
||||
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
|
||||
|
||||
t.Run("unconfigured", func(t *testing.T) {
|
||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
||||
if _, _, err := exec.Handle(context.Background(), channelCmd); err == nil {
|
||||
if _, err := exec.Handle(context.Background(), channelCmd); err == nil {
|
||||
t.Error("expected an error when no channel is configured")
|
||||
}
|
||||
})
|
||||
t.Run("configured", func(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 555, nil)
|
||||
delivered, _, err := exec.Handle(context.Background(), channelCmd)
|
||||
delivered, err := exec.Handle(context.Background(), channelCmd)
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
|
||||
@@ -52,11 +52,6 @@ type BotConfig struct {
|
||||
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
|
||||
// container user (UID 65532) and backed by a persistent volume.
|
||||
SupportStateDir string
|
||||
// StarsOutboxDir is the directory holding the Telegram Stars payment outbox SQLite file
|
||||
// (TELEGRAM_STARS_OUTBOX_DIR, optional; empty disables the Stars rail). It must be writable by
|
||||
// the container user (UID 65532) and backed by a persistent volume — a lost outbox loses any
|
||||
// payment not yet forwarded to the gateway.
|
||||
StarsOutboxDir string
|
||||
// PromoBotToken is the API token of the optional standalone promo bot run in this
|
||||
// container — a second bot whose only job is to answer /start with a button that
|
||||
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
|
||||
@@ -162,7 +157,6 @@ func LoadBot() (BotConfig, error) {
|
||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
|
||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||
BotLink: BotLinkClientConfig{
|
||||
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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/');
|
||||
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
@@ -37,27 +37,8 @@ test('wallet: balances, benefits and the storefront render for a durable account
|
||||
await expect(page.getByTestId('product')).toHaveCount(3);
|
||||
const pack = page.locator('[data-kind="pack"]');
|
||||
await expect(pack).toContainText('₽');
|
||||
// The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
|
||||
await expect(pack.getByTestId('buy-pack')).toBeEnabled();
|
||||
await expect(page.getByTestId('offer')).toContainText('Public offer');
|
||||
});
|
||||
|
||||
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openWallet(page);
|
||||
|
||||
// The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it.
|
||||
await page.evaluate(() => {
|
||||
(window as { __opened?: string }).__opened = '';
|
||||
window.open = ((u: string) => {
|
||||
(window as { __opened?: string }).__opened = u;
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
|
||||
.toContain('robokassa');
|
||||
// The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'.
|
||||
await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('wallet: the tab sits between Friends and About', async ({ page }) => {
|
||||
|
||||
@@ -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.
|
||||
@@ -28,7 +28,6 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"core-js-bundle": "^3.49.0",
|
||||
"marked": "^18.0.5",
|
||||
"svelte": "^5.15.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
Generated
-10
@@ -39,9 +39,6 @@ importers:
|
||||
core-js-bundle:
|
||||
specifier: ^3.49.0
|
||||
version: 3.49.0
|
||||
marked:
|
||||
specifier: ^18.0.5
|
||||
version: 18.0.5
|
||||
svelte:
|
||||
specifier: ^5.15.0
|
||||
version: 5.56.0
|
||||
@@ -1631,11 +1628,6 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
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:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3885,8 +3877,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
marked@18.0.5: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
minimatch@10.2.5:
|
||||
|
||||
@@ -28,11 +28,9 @@ const DIST = 'dist';
|
||||
// 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,
|
||||
// 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
|
||||
// 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
|
||||
// in the shared chunk). 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.
|
||||
const BUDGET = { app: 125, shared: 30, 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
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
+1
-14
@@ -123,10 +123,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="ft">
|
||||
<a class="offer" href="/offer/">{t('landing.offer')}</a>
|
||||
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
|
||||
</footer>
|
||||
<footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -278,18 +275,8 @@
|
||||
}
|
||||
.ft {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.ft .offer {
|
||||
color: inherit;
|
||||
}
|
||||
.ft .offer:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -86,8 +86,6 @@ export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
|
||||
export { VKLoginRequest } from './scrabblefb/vklogin-request.js';
|
||||
export { Wallet } from './scrabblefb/wallet.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 { WalletSegment } from './scrabblefb/wallet-segment.js';
|
||||
export { WordCheckResult } from './scrabblefb/word-check-result.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);
|
||||
}
|
||||
}
|
||||
@@ -137,9 +137,6 @@ export const app = $state<{
|
||||
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
|
||||
* with friend requests) and the Settings → Info badge. */
|
||||
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
|
||||
* 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. */
|
||||
@@ -178,7 +175,6 @@ export const app = $state<{
|
||||
chatUnread: {},
|
||||
messageUnread: {},
|
||||
feedbackReplyUnread: false,
|
||||
walletRefresh: 0,
|
||||
staleInvite: false,
|
||||
welcomeRedeem: false,
|
||||
resync: 0,
|
||||
@@ -443,12 +439,6 @@ function openStream(): void {
|
||||
if (e.sub === 'profile') {
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@ import type {
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
Wallet,
|
||||
WalletOrder,
|
||||
Catalog,
|
||||
} from './model';
|
||||
|
||||
@@ -148,9 +147,6 @@ export interface GatewayClient {
|
||||
/** 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. */
|
||||
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>;
|
||||
|
||||
// --- friends ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
decodeWallet,
|
||||
decodeCatalog,
|
||||
encodeWalletBuy,
|
||||
encodeWalletOrder,
|
||||
decodeWalletOrder,
|
||||
decodeSession,
|
||||
decodeFeedbackState,
|
||||
decodeFeedbackUnread,
|
||||
@@ -75,25 +73,6 @@ describe('codec', () => {
|
||||
expect(w.hints).toBe(5);
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
const b = new Builder(256);
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import type {
|
||||
MoveResult,
|
||||
Profile,
|
||||
Wallet,
|
||||
WalletOrder,
|
||||
WalletSegment,
|
||||
Catalog,
|
||||
CatalogProduct,
|
||||
@@ -374,15 +373,6 @@ export function encodeWalletBuy(productId: string): Uint8Array {
|
||||
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
|
||||
// context-applicable benefits.
|
||||
export function decodeWallet(buf: Uint8Array): Wallet {
|
||||
@@ -401,12 +391,6 @@ export function decodeWallet(buf: Uint8Array): Wallet {
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
// value or a chip pack priced in the context's payment method, with its atom composition.
|
||||
export function decodeCatalog(buf: Uint8Array): Catalog {
|
||||
|
||||
@@ -242,11 +242,7 @@ export const en = {
|
||||
'wallet.store': 'Store',
|
||||
'wallet.empty': 'The store is empty for now',
|
||||
'wallet.buy': 'Buy',
|
||||
'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.soon': 'Soon',
|
||||
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'votes',
|
||||
@@ -279,7 +275,6 @@ export const en = {
|
||||
'landing.captionTelegram': 'Telegram',
|
||||
'landing.captionVK': 'VK',
|
||||
'landing.captionWeb': 'Web',
|
||||
'landing.offer': 'Public offer',
|
||||
|
||||
'install.title': 'Install the app',
|
||||
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
|
||||
|
||||
@@ -242,11 +242,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'wallet.store': 'Магазин',
|
||||
'wallet.empty': 'Магазин пока пуст',
|
||||
'wallet.buy': 'Купить',
|
||||
'wallet.offer': 'Публичная оферта',
|
||||
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
|
||||
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
|
||||
'wallet.iosBlockedLink': 'другой версией',
|
||||
'wallet.iosBlockedPost': ' игры.',
|
||||
'wallet.soon': 'Скоро',
|
||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'голосов',
|
||||
@@ -279,7 +275,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'landing.captionTelegram': 'Telegram',
|
||||
'landing.captionVK': 'VK',
|
||||
'landing.captionWeb': 'Веб-версия',
|
||||
'landing.offer': 'Публичная оферта',
|
||||
|
||||
'install.title': 'Установить приложение',
|
||||
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
|
||||
|
||||
@@ -41,7 +41,6 @@ import type {
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
Wallet,
|
||||
WalletOrder,
|
||||
Catalog,
|
||||
} from '../model';
|
||||
import { valueForLetter } from '../alphabet';
|
||||
@@ -226,14 +225,6 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
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 };
|
||||
}
|
||||
private cloneWallet(): Wallet {
|
||||
return {
|
||||
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
||||
|
||||
@@ -172,14 +172,6 @@ export interface Wallet {
|
||||
|
||||
/** 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. */
|
||||
// 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 {
|
||||
atomType: string;
|
||||
quantity: number;
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -39,7 +39,6 @@ interface TelegramWebApp {
|
||||
close?: () => void;
|
||||
openTelegramLink?: (url: string) => void;
|
||||
openLink?: (url: string) => void;
|
||||
openInvoice?: (url: string, callback?: (status: string) => void) => void;
|
||||
onEvent?: (event: string, handler: () => void) => void;
|
||||
setHeaderColor?: (color: string) => void;
|
||||
setBackgroundColor?: (color: string) => void;
|
||||
@@ -171,20 +170,6 @@ export function telegramOpenLink(url: string): boolean {
|
||||
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,
|
||||
* so Telegram opens it directly in its in-app browser instead of the WebView's generic "open
|
||||
|
||||
@@ -237,9 +237,6 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async walletBuy(productId: string) {
|
||||
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 friendsList() {
|
||||
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
||||
|
||||
@@ -182,21 +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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from '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 { normalizeSubtype } from '../lib/platform';
|
||||
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
||||
import { isGooglePlayBuild } from '../lib/distribution';
|
||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||
import { telegramOpenInvoice } from '../lib/telegram';
|
||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||
|
||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
||||
@@ -21,22 +17,12 @@
|
||||
let wallet = $state<Wallet | null>(null);
|
||||
let catalog = $state<Catalog | null>(null);
|
||||
let loading = $state(true);
|
||||
// The product whose purchase is in flight, or null. It both guards against a concurrent purchase
|
||||
// (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);
|
||||
let busy = $state(false);
|
||||
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
|
||||
let warnProduct = $state<CatalogProduct | null>(null);
|
||||
|
||||
const context: SpendContext = executionContext();
|
||||
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 packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
||||
@@ -52,26 +38,7 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
void load();
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
onMount(load);
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
return t(`wallet.source.${source}` as MessageKey);
|
||||
@@ -102,42 +69,13 @@
|
||||
async function doBuy(p: CatalogProduct) {
|
||||
warnProduct = null;
|
||||
if (busy) return;
|
||||
busyId = p.productId;
|
||||
busy = true;
|
||||
try {
|
||||
wallet = await gateway.walletBuy(p.productId);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -167,17 +105,11 @@
|
||||
|
||||
<section>
|
||||
<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}
|
||||
{#each values as p (p.productId)}
|
||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
<span class="price">🪙 {p.chips}</span>
|
||||
<button class="buy" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)}
|
||||
>{t('wallet.buy')}</button
|
||||
>
|
||||
<button class="buy" data-testid="buy" disabled={busy} onclick={() => onBuy(p)}>{t('wallet.buy')}</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -188,24 +120,9 @@
|
||||
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)}</span>
|
||||
<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
|
||||
>
|
||||
<button class="buy" disabled>{t('wallet.soon')}</button>
|
||||
</div>
|
||||
{/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 !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
||||
@@ -277,24 +194,8 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
cursor: default;
|
||||
}
|
||||
.empty,
|
||||
.stub {
|
||||
@@ -306,14 +207,6 @@
|
||||
border: 1px dashed var(--border);
|
||||
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 {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "src/lib/offer.ts"]
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { resolve } from 'node:path';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
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
|
||||
@@ -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
|
||||
// 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`
|
||||
@@ -77,7 +60,6 @@ export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
svelte(),
|
||||
emitPolyfills(),
|
||||
emitOffer(),
|
||||
injectBootVersion(),
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user