feat(payments): settle the direct rail through YooKassa #293

Merged
developer merged 6 commits from feature/yookassa-direct-rail into development 2026-07-28 10:49:47 +00:00
37 changed files with 2638 additions and 171 deletions
Showing only changes of commit 92ba527575 - Show all commits
+23
View File
@@ -241,6 +241,29 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0E9). go-jet money rule above applies.
- **YooKassa (the direct rail since v1.x) does NOT sign its webhooks.** Authenticity is the
confirming `GET /v3/payments/{id}` — never the notification body — plus the published sender-IP
allowlist. Two extra guards ride on the confirmed object: its `metadata.order_id` must name the
order, and its `test` flag must match the shop's `IsTest` (a test-shop payment must never credit
real chips). Answer 200 for anything durably decided, 5xx only to be redelivered (YooKassa retries
24h).
- **YooKassa DOES have a test mode** — a separate *test shop* (own shopId/secret, test cards, up to
20 of them), and webhooks/receipts/refunds all work there. The whole rail is verifiable on the
contour without real money; don't plan around "no test payments".
- **YooKassa has no cabinet-side generic чек.** Unlike Robokassa's cloud kassa, «Чеки от ЮKassa»
registers a receipt **only if the request carries `receipt`** — so the itemized receipt is
mandatory code on both payment and refund. `payment_subject`/`payment_mode`/`vat_code` are fields
*inside* `receipt.items[]` (54-ФЗ tags 1212/1214/1199), documented under the receipts section, not
the payment API — easy to miss. A malformed receipt is an API error at payment creation, so it
breaks purchases loudly rather than silently skipping the fiscal document.
- **The gateway cannot import `backend/internal/*`** (separate module + Go's internal rule), so a
security list shared by both hops either lives in `pkg/` or is enforced backend-side. The YooKassa
sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
`X-Forwarded-For` — do not duplicate the CIDR list into the gateway.
- **Robokassa is retired but wired.** The direct rail resolves YooKassa → else Robokassa, and no
deployment sets the Robokassa credentials. Reviving it is a credentials change, not a code change;
`backend/internal/robokassa/README.md` holds the retired vars, cabinet URLs and the known gap
(the per-channel `…_WEB_*`/`…_ANDROID_*` vars never reached any workflow).
- **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills
VK purchases. VK HTML-escapes it in the `order_status_change` callback echo (`"`→`&quot;`) but
signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item
+14 -12
View File
@@ -371,13 +371,14 @@ 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"
# Direct-rail (YooKassa) intake on the contour: the credentials of a YooKassa TEST shop,
# which takes test cards and moves no real money. YOOKASSA_WEB_TEST is forced to 1 so the
# contour refuses to credit anything but a test payment, whatever credentials are set.
# Empty credentials leave the direct rail off.
YOOKASSA_WEB_SHOP_ID: ${{ secrets.TEST_BACKEND_YOOKASSA_WEB_SHOP_ID }}
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.TEST_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
YOOKASSA_WEB_TEST: "1"
YOOKASSA_VAT_CODE: ${{ vars.TEST_BACKEND_YOOKASSA_VAT_CODE }}
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
@@ -576,15 +577,16 @@ jobs:
- 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)"
# /pay/yookassa/notify must reach the gateway, not fall to the landing catch-all. The probe
# is a bare GET from an address that is not one of YooKassa's senders, so it is rejected
# downstream and 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/yookassa/notify 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?)"
echo "FAIL: /pay/yookassa/notify did not reach the gateway (landing catch-all?)"
docker logs --tail 50 scrabble-gateway || true
exit 1
fi
+12 -8
View File
@@ -99,14 +99,18 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
# YooKassa direct rail (backend BACKEND_YOOKASSA_*), one merchant shop per channel: the shop
# id + secret key, and a test-shop flag — a var, so pointing prod at a test shop is a flag
# flip rather than a secret rotation ("1" refuses to credit anything but a test payment;
# empty/"0" is live). Empty credentials leave that channel — or the whole rail disabled.
# The VAT rate code on every receipt line is a var too, so a rate change needs no code change.
YOOKASSA_WEB_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SHOP_ID }}
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
YOOKASSA_WEB_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_WEB_TEST }}
YOOKASSA_ANDROID_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SHOP_ID }}
YOOKASSA_ANDROID_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SECRET_KEY }}
YOOKASSA_ANDROID_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_ANDROID_TEST }}
YOOKASSA_VAT_CODE: ${{ vars.PROD_BACKEND_YOOKASSA_VAT_CODE }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
+8 -5
View File
@@ -61,12 +61,15 @@ jobs:
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# YooKassa direct rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# it must carry the same credentials or the direct rail goes dark after a rollback.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
YOOKASSA_WEB_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SHOP_ID }}
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
YOOKASSA_WEB_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_WEB_TEST }}
YOOKASSA_ANDROID_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SHOP_ID }}
YOOKASSA_ANDROID_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SECRET_KEY }}
YOOKASSA_ANDROID_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_ANDROID_TEST }}
YOOKASSA_VAT_CODE: ${{ vars.PROD_BACKEND_YOOKASSA_VAT_CODE }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
+52 -2
View File
@@ -3,7 +3,7 @@
Technical, step-by-step implementation of the monetization domain. Business mechanics and
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); the frozen owner agreements they both derive
from (the `D1``D41` decisions log) live in
from (the `D1``D51` decisions log) live in
[`docs/PAYMENTS_DECISIONS_ru.md`](docs/PAYMENTS_DECISIONS_ru.md) — the authority when a rule
is disputed. This file is the *how*. Each stage is written to be self-sufficient: returning
to it gives full context — goal, exact touch-points, tests, done-criteria, and current
@@ -11,7 +11,7 @@ status — without re-deriving decisions.
## How to use this file
- **Stage granularity (E0E9) is fixed.** Do not split or merge stages. Plan each stage
- **Stage granularity (E0E12) is fixed.** Do not split or merge stages. Plan each stage
densely enough to execute whole.
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
titles/descriptions must read as finalized feature copy — never "E5", "stage 3", etc.
@@ -41,6 +41,7 @@ status — without re-deriving decisions.
| E9 | Tournament fee | future | TODO |
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
| E12 | YooKassa direct rail (Robokassa retired) | 2+ | DONE |
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
@@ -1012,6 +1013,55 @@ so nothing is disabled until an operator acts.
---
## E12 — YooKassa direct rail (Robokassa retired)
**Status:** DONE · **Release 2+ (money-live)** · depends on: E5 (intake/`Fund`, the order flow), E7
(the admin refund + report), E10 (per-channel shops), E11 (the kill switch) · mechanics: PAYMENTS §2,
§9, §11, §12; decisions D47D51 (revising D41).
**Delivered.** The `direct` (RUB) rail settles through **YooKassa** instead of Robokassa. The wallet
model is untouched — one `direct` segment, the same spend wall, the same per-channel shops (D42) and
`shop` on the order (D44). Robokassa is **dormant, not deleted**: the direct rail falls back to it
when no YooKassa shop is configured, so reviving it is a credentials change (D47).
- **Provider glue** — `backend/internal/yookassa`: an API client (`CreatePayment` / `GetPayment` /
`CreateRefund`, HTTP Basic, `Idempotence-Key`), a per-channel `Shops` registry with `ByShopID` for
attributing a notification, the notification envelope + the sender-IP allowlist, and the fiscal
receipt builder. No DB, no payments-domain coupling — the same shape as the `robokassa` package.
- **Order** — `handleWalletOrder` prefers a YooKassa shop and otherwise falls through to Robokassa.
It creates the payment (order id as both `Idempotence-Key` and `metadata.order_id`), records the
provider payment id on the order (`AttachProviderPayment`; no migration — the column existed) and
returns `confirmation_url`. D36's email anchor now also feeds the receipt (`ConfirmedEmail`).
- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: sender
allowlist, then the confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the
payment's metadata must name the order, and its `test` flag must match the shop's. `payment.canceled`
records a `failed` event. 200 = durably decided, 5xx = redeliver.
- **Reconcile** — `runOrderReaper` asks the provider about each pending order that reached its expiry
age carrying a payment id, and credits the ones really paid (D49). One request per order.
- **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on success, under the
provider's own refund id (D50); a failure records nothing.
- **Fiscalization** — «Чеки от ЮKassa» with the `receipt` sent from code (D51), reversing E10's B4:
YooKassa has no cabinet-side generic receipt. `BACKEND_YOOKASSA_VAT_CODE` is a deploy variable;
the settlement subject/method are constants.
- **Config/deploy** — `BACKEND_YOOKASSA_{WEB,ANDROID}_{SHOP_ID,SECRET_KEY,TEST}` +
`BACKEND_YOOKASSA_VAT_CODE`; every `ROBOKASSA_*` mapping removed from compose, `.env.example`,
`write-prod-env.sh` and the three workflows, and recorded in `backend/internal/robokassa/README.md`.
The CI edge probe now targets `/pay/yookassa/notify`.
- **Tests** — unit (`yookassa`: request building, receipt, idempotence key, error retryability, shop
registry, IP allowlist); integration (`payments_yookassa_test.go`: credit-once + redelivery, a
forged notification credits nothing, a foreign sender is refused, a live payment on a test shop is
refused, a decline records `failed`, the reconcile sweep credits a lost notification and leaves an
unpaid order alone, the refund moves money then records — and records nothing when it fails, the
order path mints a payment with a receipt, and D36 still gates the rail).
**Contour-safe:** no migration, no wire change; the client is rail-agnostic (only the mock URL and an
e2e assertion name a provider).
**Verified on the contour** against a YooKassa **test shop** (test mode exists — a separate test shop
with its own credentials and test cards), then in prod with one real payment.
---
## Verification & CI (all stages)
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
+16 -4
View File
@@ -335,7 +335,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
Robokassa: cfg.Robokassa,
YooKassa: cfg.YooKassa,
YooKassaVatCode: cfg.YooKassaVatCode,
PublicBaseURL: cfg.PublicBaseURL,
Robokassa: cfg.Robokassa,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -345,8 +349,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
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)
// still credits), asking the provider about each one first. Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, srv, 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)
@@ -363,7 +367,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// 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) {
//
// Before writing an order off it asks the provider what really happened to it — the one status check
// each order gets, and the safety net for a notification that was lost for good (YooKassa redelivers
// for 24 hours, so only a permanently lost one reaches here). A rail with no such check contributes
// nothing to this pass.
func runOrderReaper(ctx context.Context, p *payments.Service, srv *server.Server, log *zap.Logger) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
@@ -371,6 +380,9 @@ func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
case <-ctx.Done():
return
case <-t.C:
if n := srv.ReconcileYooKassaOrders(ctx); n > 0 {
log.Info("order reaper: credited orders confirmed by the provider", zap.Int("count", n))
}
if n, err := p.ExpireOrders(ctx); err != nil {
log.Warn("order reaper: sweep failed", zap.Error(err))
} else if n > 0 {
+11 -3
View File
@@ -397,16 +397,24 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
// 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) {
_, ok, err := s.ConfirmedEmail(ctx, accountID)
return ok, err
}
// ConfirmedEmail returns the account's confirmed email address, the oldest one first when several
// exist. Besides being the direct-rail recovery anchor (D36) it is where the rail's fiscal receipt
// is delivered, so the purchase path reads the address itself rather than only its presence.
func (s *Store) ConfirmedEmail(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
return "", false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
return id.ExternalID, true, nil
}
}
return false, nil
return "", false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated
+59 -5
View File
@@ -17,6 +17,7 @@ import (
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
"scrabble/backend/internal/yookassa"
)
// Config holds the backend's runtime configuration.
@@ -65,8 +66,17 @@ 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 — one merchant shop per channel
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
// YooKassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
// the direct rail back to Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
// It is configurable because the rate is the one receipt attribute that genuinely changes.
YooKassaVatCode int
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
// change — see backend/internal/robokassa/README.md.
Robokassa robokassa.Shops
}
@@ -158,9 +168,26 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
// dropped, so the rail stays dormant until a channel is fully configured.
ykShops := yookassa.Shops{}
for channel, prefix := range map[string]string{
yookassa.ChannelWeb: "BACKEND_YOOKASSA_WEB",
yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
} {
if shop := yookassaShop(prefix); shop.Configured() {
ykShops[channel] = shop
}
}
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeNone)
if err != nil {
return Config{}, err
}
// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
// the credentials revives it without a code change. The legacy single-shop vars seed the web
// channel; the per-channel vars add the rest.
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
@@ -190,6 +217,8 @@ func Load() (Config, error) {
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
YooKassa: ykShops,
YooKassaVatCode: vatCode,
Robokassa: shops,
}
if err := c.validate(); err != nil {
@@ -248,6 +277,19 @@ func (c Config) validate() error {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d)", c.YooKassaVatCode, yookassa.VatCodeMax)
}
if c.YooKassa.Configured() {
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
// URL to bring them back, which only the public base URL can supply.
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
return nil
}
@@ -288,6 +330,18 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
return d, nil
}
// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
// either credential yields a Config the caller drops.
func yookassaShop(prefix string) yookassa.Config {
return yookassa.Config{
ShopID: os.Getenv(prefix + "_SHOP_ID"),
SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
@@ -0,0 +1,504 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/server"
"scrabble/backend/internal/yookassa"
)
// The test shop the fake API answers for. IsTest is true throughout, matching a YooKassa test shop.
const (
ykShopID = "100500"
ykSecretKey = "test_secret"
// ykSenderIP is inside YooKassa's published notification ranges; the intake rejects anything else.
ykSenderIP = "185.71.76.1"
)
// fakeYooKassa is a stand-in for the YooKassa API. Tests set the payment it reports and whether a
// refund succeeds, then assert on what the intake did with that answer.
type fakeYooKassa struct {
mu sync.Mutex
// payments answers GET /payments/{id}; a missing id is a 404, as it is for a payment we never made.
payments map[string]yookassa.Payment
// refundFails makes POST /refunds answer 500, standing in for a provider that did not move money.
refundFails bool
// refundCalls counts the refunds actually requested.
refundCalls int
// createdIdempotenceKey records the key sent on the last create-payment call.
createdIdempotenceKey string
// lastReceipt records the receipt object of the last create-payment call.
lastReceipt map[string]any
}
// newFakeYooKassa starts the fake API and returns it with a shop config pointed at it.
func newFakeYooKassa(t *testing.T) (*fakeYooKassa, yookassa.Config) {
t.Helper()
f := &fakeYooKassa{payments: map[string]yookassa.Payment{}}
srv := httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(srv.Close)
return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL}
}
func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/payments":
var body struct {
Amount yookassa.Amount `json:"amount"`
Metadata map[string]string `json:"metadata"`
Receipt map[string]any `json:"receipt"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
f.createdIdempotenceKey = r.Header.Get("Idempotence-Key")
f.lastReceipt = body.Receipt
id := "pay-" + body.Metadata[yookassa.MetadataOrderID]
p := yookassa.Payment{
ID: id, Status: yookassa.StatusPending, Test: true, Amount: body.Amount,
Confirmation: yookassa.Confirmation{Type: yookassa.ConfirmationRedirect, ConfirmationURL: "https://yoomoney.test/pay/" + id},
Metadata: body.Metadata,
Recipient: yookassa.Recipient{AccountID: ykShopID},
}
f.payments[id] = p
_ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
return
}
_ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodPost && r.URL.Path == "/refunds":
if f.refundFails {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"type":"error","description":"acquirer unavailable"}`))
return
}
f.refundCalls++
var body yookassa.RefundRequest
_ = json.NewDecoder(r.Body).Decode(&body)
_ = json.NewEncoder(w).Encode(yookassa.Refund{
ID: "refund-1", Status: yookassa.StatusSucceeded, PaymentID: body.PaymentID, Amount: body.Amount,
})
default:
w.WriteHeader(http.StatusNotFound)
}
}
// setPayment overwrites what the API reports for a payment — how a test says "the money did (or did
// not) really move", independently of what a notification claims.
func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) {
f.mu.Lock()
defer f.mu.Unlock()
p := f.payments[id]
p.ID = id
mutate(&p)
f.payments[id] = p
}
// yookassaServer builds a backend server whose direct rail is the fake YooKassa shop.
//
// The suite shares one database, and the kill-switch test leaves the direct rail disabled, so the
// rail status is reset first: these tests are about the provider, not about ops availability, and
// fail-open means an absent row is an enabled rail.
func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil {
t.Fatalf("reset direct rail status: %v", err)
}
paySvc := newPaymentsService()
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: account.NewStore(testDB),
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
Payments: paySvc,
YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop},
YooKassaVatCode: yookassa.VatCodeNone,
PublicBaseURL: "https://scrabble.test",
})
return srv, paySvc
}
// postYooKassaNotify posts a notification body to the intake as the given sender, and reports the status code.
func postYooKassaNotify(t *testing.T, srv *server.Server, senderIP, event, paymentID, orderID string) int {
t.Helper()
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded",
"recipient":{"account_id":%q},"metadata":{"order_id":%q}}}`, event, paymentID, ykShopID, orderID)
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", senderIP)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec.Code
}
// seedYooKassaOrder opens a paid-for-real order: a pending order with the provider payment id
// attached, exactly as the order path leaves it once YooKassa has minted the payment.
func seedYooKassaOrder(t *testing.T, f *fakeYooKassa, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
t.Helper()
ctx := context.Background()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paymentID := "pay-" + res.OrderID.String()
f.setPayment(paymentID, func(p *yookassa.Payment) {
p.Status = yookassa.StatusSucceeded
p.Paid = true
p.Test = true
p.Amount = yookassa.Amount{Value: "149.00", Currency: "RUB"}
p.Metadata = map[string]string{yookassa.MetadataOrderID: res.OrderID.String()}
p.Recipient = yookassa.Recipient{AccountID: ykShopID}
})
if err := pay.AttachProviderPayment(ctx, res.OrderID, "yookassa", paymentID); err != nil {
t.Fatalf("attach provider payment: %v", err)
}
return res.OrderID, paymentID
}
// TestYooKassaNotifyCreditsOnce drives the crediting path: a succeeded notification credits the
// order exactly once, and a redelivery credits nothing more.
func TestYooKassaNotifyCreditsOnce(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100", got)
}
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
}
// YooKassa redelivers until it gets a 200; a redelivery must credit nothing and still answer 200.
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("redelivered notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after redelivery = %d, want 100 (credited once)", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
}
}
// TestYooKassaNotifyIsNotEvidence is the security property of the whole rail: notifications are
// unsigned, so a fabricated "succeeded" credits nothing when the confirming read says otherwise.
func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
// The provider says the payment never completed, whatever the notification claims.
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusPending; p.Paid = false })
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a forged notification credited chips", got)
}
// A payment we never made: the confirming read 404s, so there is nothing to credit.
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-invented", orderID.String()); code != http.StatusOK {
t.Fatalf("notify for an unknown payment = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — an invented payment credited chips", got)
}
}
// TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's
// published ranges is refused before any provider call, so a forger cannot make us fan out requests.
func TestYooKassaNotifyRejectsForeignSenders(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden {
t.Fatalf("notify from a foreign address = %d, want 403", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a foreign sender credited chips", got)
}
}
// TestYooKassaNotifyRejectsLiveOnTestShop is the guard that keeps play money out of a live ledger and
// real money out of a test one: a payment whose test flag disagrees with the shop's is not credited.
func TestYooKassaNotifyRejectsLiveOnTestShop(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop) // the shop is a test shop
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Test = false }) // a live payment
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a live payment credited against test credentials", got)
}
}
// TestYooKassaCanceledRecordsFailure checks an active decline records a failed payment event and
// credits nothing, so the customer learns the attempt did not go through.
func TestYooKassaCanceledRecordsFailure(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
f.setPayment(paymentID, func(p *yookassa.Payment) {
p.Status = yookassa.StatusCanceled
p.Paid = false
p.CancellationDetails = &yookassa.Cancellation{Party: "payment_network", Reason: "insufficient_funds"}
})
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentCanceled, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a canceled payment credited chips", got)
}
if orderStatus(t, orderID) != "pending" {
t.Errorf("order status = %s, want pending (a decline does not settle the order)", orderStatus(t, orderID))
}
evs, err := pay.UndispatchedEvents(context.Background(), 50)
if err != nil {
t.Fatalf("read events: %v", err)
}
found := false
for _, e := range evs {
if e.AccountID == acc && e.Type == "failed" {
found = true
}
}
if !found {
t.Error("no failed payment event recorded for a declined payment")
}
}
// TestYooKassaReconcileCreditsLostNotification is the safety net: when no notification ever arrives,
// the expiry-time check asks the provider and credits an order that was in fact paid.
func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, _ := seedYooKassaOrder(t, f, pay, acc)
// Age the order past its lifetime without any notification having been delivered.
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 {
t.Fatalf("reconciled %d orders, want 1", n)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — the safety net did not credit a paid order", got)
}
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
}
// A second sweep finds nothing left to do — the order is no longer pending.
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
t.Errorf("second sweep credited %d orders, want 0", n)
}
}
// TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
// customer abandoned.
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled; p.Paid = false })
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
t.Errorf("reconciled %d orders, want 0 (the payment was never completed)", n)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0", got)
}
}
// TestYooKassaConsoleRefundMovesMoneyThenRecords drives the operator refund end to end: the provider
// refund is requested first and the ledger records the provider's own refund id.
func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
h := srv.Handler()
base := "http://admin.test/_gm/users/" + acc.String()
code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
if code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
t.Fatalf("refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
}
if f.refundCalls != 1 {
t.Errorf("provider refunds requested = %d, want 1", f.refundCalls)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0", got)
}
// The ledger records the provider's own refund id, which is what keeps it reconcilable against
// YooKassa's records — and is distinct from the payment id, so both rows coexist.
var provider, refundID string
if err := testDB.QueryRowContext(context.Background(),
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
acc).Scan(&provider, &refundID); err != nil {
t.Fatalf("read refund ledger row: %v", err)
}
if provider != "yookassa" || refundID != "refund-1" {
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-1)", provider, refundID)
}
}
// TestYooKassaRefundRecordsNothingWhenTheProviderFails is the invariant that keeps the ledger honest:
// if the money did not move, nothing is written.
func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
f.refundFails = true
h := srv.Handler()
base := "http://admin.test/_gm/users/" + acc.String()
_, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
if !strings.Contains(body, "nothing was recorded") {
t.Errorf("refund failure message = %q, want it to say nothing was recorded", body)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — chips were revoked without the money moving", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("a refund ledger row was written although the provider did not refund")
}
}
// TestYooKassaOrderMintsAPaymentWithAReceipt drives the purchase path over HTTP: the order endpoint
// calls the provider, threads the order id through, sends the fiscal receipt to the buyer's confirmed
// address, and hands the client the hosted payment page.
func TestYooKassaOrderMintsAPaymentWithAReceipt(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, _ := yookassaServer(t, shop)
acc := provisionAccount(t)
const email = "buyer@example.test"
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
uuid.New(), acc, email); err != nil {
t.Fatalf("seed email identity: %v", err)
}
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", acc.String())
req.Header.Set("X-Platform", "direct/web")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
}
var out struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode order response: %v", err)
}
if out.Rail != "yookassa" || !strings.HasPrefix(out.RedirectURL, "https://yoomoney.test/pay/") {
t.Errorf("order response = %+v, want the yookassa rail and its hosted payment page", out)
}
// The order id is both the idempotency key (so a retried create cannot mint a second payment)
// and the metadata that later resolves a notification to this order.
if f.createdIdempotenceKey != out.OrderID {
t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID)
}
customer, _ := f.lastReceipt["customer"].(map[string]any)
if customer["email"] != email {
t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email)
}
items, _ := f.lastReceipt["items"].([]any)
if len(items) != 1 {
t.Fatalf("receipt items = %d, want 1", len(items))
}
item, _ := items[0].(map[string]any)
if item["vat_code"] != float64(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService {
t.Errorf("receipt fiscal attributes = %v", item)
}
// The payment id is recorded on the order, which is what the safety net and a refund need.
orderID, err := uuid.Parse(out.OrderID)
if err != nil {
t.Fatalf("parse order id: %v", err)
}
var paymentID string
if err := testDB.QueryRowContext(context.Background(),
`SELECT provider_payment_id FROM payments.orders WHERE order_id=$1`, orderID).Scan(&paymentID); err != nil {
t.Fatalf("read order payment id: %v", err)
}
if paymentID != "pay-"+out.OrderID {
t.Errorf("order provider_payment_id = %q, want the minted payment id", paymentID)
}
}
// TestYooKassaOrderRequiresAnEmailAnchor keeps D36 in force on the new rail: without a confirmed
// address there is no recovery anchor and nowhere to deliver the fiscal receipt, so no payment is
// minted at all.
func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) {
_, shop := newFakeYooKassa(t)
srv, _ := yookassaServer(t, shop)
acc := provisionAccount(t) // a telegram identity only — no confirmed email
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", acc.String())
req.Header.Set("X-Platform", "direct/web")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "email_required") {
t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String())
}
}
+18 -5
View File
@@ -11,11 +11,24 @@ import (
// one manual full refund per order.
const providerAdmin = "admin"
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
// RefundOrderFull refunds a paid order in full at the operator's request, recording the reversal
// only. It is for the rails that have no refund API of our own to call: the operator performs the
// actual money refund there by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet),
// and this records it under the operator's own idempotency key.
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
return s.RefundOrderFullAs(ctx, orderID, providerAdmin, orderID.String())
}
// RefundOrderFullAs refunds a paid order in full and records it under the given provider and refund
// id: it revokes the funded chips best-effort (floored at 0, never negative — D27), appends a refund
// ledger row and is idempotent on (provider, providerRefundID), so a second call reports
// AlreadyRefunded. Callers whose rail moved the money through an API pass that rail's own refund id,
// which keeps the ledger reconcilable against the provider's records; the refund id is distinct from
// the fund's payment id, so the two rows coexist under the same partial-unique index.
//
// The provider-side money movement must already have succeeded when this is called — the ledger must
// never claim a refund that did not happen.
func (s *Service) RefundOrderFullAs(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string) (RefundOutcome, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
@@ -24,7 +37,7 @@ func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (Refun
if err != nil {
return RefundOutcome{}, err
}
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
}
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
@@ -64,6 +64,83 @@ func directShop(cxt Context) string {
return ""
}
// AttachProviderPayment records the provider's own payment identifier on a pending order, right
// after the provider mints it and before the customer has paid. Two later paths depend on it: the
// reconcile sweep, which asks the provider what became of an order no callback ever confirmed, and a
// refund, which must address the original payment. It does not credit anything and does not change
// the order status.
func (s *Service) AttachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string) error {
return s.store.attachProviderPayment(ctx, orderID, provider, providerPaymentID, s.clock())
}
// OrderRef identifies a stored order to a provider: which rail settles it, that rail's own payment
// id, the merchant shop channel it was issued through, and the amount and account it belongs to. It
// is what the reconcile sweep and the refund path need without giving them the whole order.
type OrderRef struct {
OrderID uuid.UUID
AccountID uuid.UUID
Provider string
PaymentID string
Shop string
Amount Money
Status string
}
// orderRef projects a stored order onto an OrderRef.
func orderRef(o orderRow) (OrderRef, error) {
amount, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
if err != nil {
return OrderRef{}, err
}
return OrderRef{
OrderID: o.orderID,
AccountID: o.accountID,
Provider: o.provider,
PaymentID: o.paymentID,
Shop: o.shop,
Amount: amount,
Status: o.status,
}, nil
}
// OrderProviderRef reads how an order reaches its provider — the rail, that rail's payment id and
// the shop it was issued through. The refund path uses it to call the right merchant account.
func (s *Service) OrderProviderRef(ctx context.Context, orderID uuid.UUID) (OrderRef, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return OrderRef{}, err
}
return orderRef(o)
}
// reconcileBatch bounds one reconcile sweep, so a backlog cannot turn a periodic tick into a long
// run of provider calls.
const reconcileBatch = 50
// PendingForReconcile returns the pending orders that have reached their expiry age while carrying a
// provider payment id — the ones where the money may well have moved but no callback ever told us.
// The caller asks the provider for each one's real outcome before ExpireOrders writes them off.
// Orders that never reached a payment are not returned: there is nothing to ask about.
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
ttl, err := s.store.orderTTL(ctx)
if err != nil {
return nil, err
}
rows, err := s.store.pendingForReconcile(ctx, ttl, s.clock(), reconcileBatch)
if err != nil {
return nil, err
}
out := make([]OrderRef, 0, len(rows))
for _, r := range rows {
ref, err := orderRef(r)
if err != nil {
return nil, err
}
out = append(out, ref)
}
return out, 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).
+63 -1
View File
@@ -182,6 +182,9 @@ type orderRow struct {
currency string
origin string
status string
provider string
paymentID string
shop string
}
// orderByID reads an order, or ErrOrderNotFound.
@@ -198,6 +201,12 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
}
return newOrderRow(o), nil
}
// newOrderRow projects a stored order onto the intake's view of it, flattening the nullable provider
// columns to their empty strings.
func newOrderRow(o model.Orders) orderRow {
return orderRow{
orderID: o.OrderID,
accountID: o.AccountID,
@@ -206,7 +215,60 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
currency: o.Currency,
origin: o.Origin,
status: o.Status,
}, nil
provider: derefString(o.Provider),
paymentID: derefString(o.ProviderPaymentID),
shop: o.Shop,
}
}
// derefString reads a nullable text column as a plain string, treating NULL as empty.
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
// attachProviderPayment records the provider's own payment id on a pending order, as soon as the
// provider mints it. It is what later lets an unattended order be re-checked against the provider
// and a refund address the right payment; fund overwrites it with the same value when the callback
// lands. It never changes the order status.
func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, now time.Time) error {
_, err := table.Orders.
UPDATE(table.Orders.Provider, table.Orders.ProviderPaymentID, table.Orders.UpdatedAt).
SET(postgres.String(provider), postgres.String(providerPaymentID), postgres.TimestampzT(now)).
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
ExecContext(ctx, s.db)
if err != nil {
return fmt.Errorf("payments: attach provider payment to order %s: %w", orderID, err)
}
return nil
}
// pendingForReconcile reads the pending orders that have reached their expiry age and carry a
// provider payment id — the ones whose real outcome is still unknown to us because no callback ever
// arrived. The caller asks the provider what happened before the order is written off as expired.
// Orders with no provider payment id are skipped: the customer never got as far as a payment.
func (s *Store) pendingForReconcile(ctx context.Context, ttlSeconds int, now time.Time, limit int) ([]orderRow, error) {
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
var rows []model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff))).
AND(table.Orders.ProviderPaymentID.IS_NOT_NULL()).
AND(table.Orders.ProviderPaymentID.NOT_EQ(postgres.String("")))).
ORDER_BY(table.Orders.CreatedAt.ASC()).
LIMIT(int64(limit)).
QueryContext(ctx, s.db, &rows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return nil, fmt.Errorf("payments: load orders for reconcile: %w", err)
}
out := make([]orderRow, 0, len(rows))
for _, r := range rows {
out = append(out, newOrderRow(r))
}
return out, nil
}
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
+85
View File
@@ -0,0 +1,85 @@
# Robokassa — the retired direct rail
Robokassa used to settle the **`direct`** (RUB) rail. It was replaced by
[YooKassa](../yookassa) and is now **dormant**: the code, its tests and its wiring are all still
here, but **no deployment sets its credentials**, so `Shops.Configured()` is false, the Result
callback route is never registered, and the direct rail resolves to YooKassa.
The code is kept because the merchant relationship could come back. This file records the operational
knowledge that used to live in `deploy/` and `.gitea/workflows/` and was removed from there so the
live configuration stays free of dead variables.
## How the rail is chosen
`handleWalletOrder` (`backend/internal/server/handlers_intake.go`) resolves the direct rail in this
order:
1. a configured YooKassa shop for the platform channel → YooKassa;
2. otherwise a configured Robokassa shop → **this rail**;
3. otherwise `501 rail_unavailable`.
So reviving Robokassa is a **credentials change, not a code change**: restore the environment block
below and the rail comes back on the next deploy. If both providers are configured, YooKassa wins.
## Environment variables (retired)
The backend still reads all of these. They were removed from `deploy/docker-compose.yml`,
`deploy/.env.example`, `deploy/write-prod-env.sh`, `deploy/README.md` and the three workflows.
| Backend variable (inside the container) | Meaning |
| --- | --- |
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | Shop login. Empty ⇒ the shop is dropped. Legacy single-shop form: it seeds the **web** channel. |
| `BACKEND_ROBOKASSA_PASSWORD1` | Pass phrase #1 — signs the outgoing payment request. |
| `BACKEND_ROBOKASSA_PASSWORD2` | Pass phrase #2 — signs and so verifies the incoming Result callback. |
| `BACKEND_ROBOKASSA_TEST` | `1` adds `IsTest=1`, so the shop simulates payments against its **test** pass phrases and no money moves. Empty/`0` is live. |
| `BACKEND_ROBOKASSA_WEB_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **web** shop; overrides the legacy set above. |
| `BACKEND_ROBOKASSA_ANDROID_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **android** (RuStore) shop (D42). |
Password3 (Robokassa's JWT-invoice API) was never used.
The three-step naming this repo uses was: a Gitea secret/variable `PROD_BACKEND_ROBOKASSA_PASSWORD1`
→ mapped by the workflow to `ROBOKASSA_PASSWORD1` → mapped by compose to
`BACKEND_ROBOKASSA_PASSWORD1` inside the container.
| Contour | Gitea entries that fed the rail |
| --- | --- |
| test | secrets `TEST_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; `ROBOKASSA_TEST` was hard-coded to `"1"` in `ci.yaml` so the contour could never take real money whatever the shop's own mode. |
| prod | secrets `PROD_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; **variable** `PROD_BACKEND_ROBOKASSA_TEST`, deliberately a variable so go-live was a flag flip rather than a secret rotation. Mirrored in `prod-rollback.yaml` so a rollback did not dark the rail. |
**Known gap, if the rail is ever revived:** the per-channel `…_WEB_*` / `…_ANDROID_*` variables
existed in compose and in `config.go` but were **never** carried by any workflow or by
`write-prod-env.sh` — only the four legacy variables reached prod. A revival that wants the
multi-shop split must add them to the deploy plumbing too.
## Cabinet configuration
Set in the Robokassa ЛКК, per shop:
- **Result URL** — `https://<host>/pay/robokassa/result/<channel>` (`…/web`, `…/android`). The bare
`…/pay/robokassa/result` is the legacy path and is verified as the **web** shop. Method: POST.
Each shop's callback is verified only by its own Password2 (`Shops.Verifier` does not fall back).
- **Success URL** — `https://<host>/pay/robokassa/success`.
- **Fail URL** — `https://<host>/pay/robokassa/fail`.
- **Signature algorithm** — SHA-256 (the code hard-codes it; a shop set to MD5 will not verify).
- **54-ФЗ** — fiscalization was cabinet-side through Robokassa's cloud cash register (kassa + ОФД +
СНО configured by the owner), so no receipt data was ever sent from code. YooKassa does **not**
work this way: it registers a receipt only if the request carries one.
The gateway routes (`gateway/internal/connectsrv/server.go`) are still registered, so the cabinet
URLs above stay reachable; only the backend has no shop to verify against.
## Protocol notes
- The order id is a UUID and Robokassa's `InvId` is numeric, so the order rides the custom-parameter
channel as **`Shp_order`** and `InvId` is always sent as `0`. Idempotency at the credit site is
therefore keyed on the order id, not on a provider payment id.
- Outgoing signature base: `MerchantLogin:OutSum:InvId:Password1[:Shp_key=value…sorted]`.
- Incoming (Result) signature base: `OutSum:InvId:Password2[:Shp_key=value…sorted]`, compared
case-insensitively.
- The Result callback must be answered with the body `OK<InvId>`, which the gateway echoes verbatim.
## What is *not* reversible by configuration
Ledger rows written while this rail was live carry `provider = 'robokassa'`. That literal is
load-bearing for the `(provider, provider_payment_id)` idempotency index and must never be renamed or
reused for another provider.
+6
View File
@@ -3,6 +3,12 @@
// 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.
//
// This rail is RETIRED: YooKassa settles the direct rail now, and no deployment sets these
// credentials, so the shop set is empty and the rail is dormant. It stays wired as the fallback the
// direct rail resolves to when YooKassa has no shop, which makes reviving it a credentials change
// rather than a code change. README.md in this directory records the retired environment variables,
// the cabinet configuration and how to bring the rail back.
//
// 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.
+7 -3
View File
@@ -79,16 +79,20 @@ func (s *Server) registerRoutes() {
s.internal.GET("/offer/pricing", s.handleOfferPricing)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
// The money order endpoint dispatches by rail (direct → YooKassa, 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.
// gateway-only (the single writer): the VK payment callback (both phases handled here), the
// YooKassa notification, and the retired Robokassa Result callback if a merchant is ever
// configured again.
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.yookassa.Configured() {
s.internal.POST("/payments/yookassa/notify", s.handleYooKassaNotify)
}
if s.robokassa.Configured() {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/csv"
"fmt"
"strconv"
@@ -9,11 +10,17 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
// consoleRefund refunds a paid order in full at the operator's request: it records the reversal — a
// refund ledger row and a floor-0 chip revoke (never negative, D27) — and, on the direct rail, moves
// the money back through the provider's refund API first, so one click does the whole job and the
// ledger cannot claim a refund the provider never made. The rails with no refund API of ours (VK
// support, Telegram refundStarPayment, the Robokassa cabinet) still need the operator to move the
// money by hand; this records that. Idempotent either way.
func (s *Server) consoleRefund(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
@@ -29,7 +36,13 @@ func (s *Server) consoleRefund(c *gin.Context) {
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
return
}
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
ctx := c.Request.Context()
ref, err := s.payments.OrderProviderRef(ctx, orderID)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
}
out, err := s.refundOrder(ctx, ref)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
@@ -46,6 +59,25 @@ func (s *Server) consoleRefund(c *gin.Context) {
s.renderConsoleMessage(c, "Refunded", msg, back)
}
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
// must never claim a refund that did not happen. The reversal is then recorded under the provider's
// own refund id, which keeps the ledger reconcilable against the provider's records.
//
// A rail with no refund API of ours records under the operator's key instead, and the operator moves
// the money by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet).
func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (payments.RefundOutcome, error) {
if ref.Provider != providerYooKassa {
return s.payments.RefundOrderFull(ctx, ref.OrderID)
}
refundID, err := s.refundYooKassa(ctx, ref)
if err != nil {
s.log.Error("yookassa refund failed", zap.String("order", ref.OrderID.String()), zap.Error(err))
return payments.RefundOutcome{}, fmt.Errorf("the provider did not refund the payment, nothing was recorded: %w", err)
}
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
}
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
func (s *Server) consoleLedgerExport(c *gin.Context) {
+28 -9
View File
@@ -15,20 +15,28 @@ import (
"scrabble/backend/internal/robokassa"
)
// Ledger/order provider tags per rail.
// Ledger/order provider tags per rail. providerRobokassa is retired but never renamed or reused:
// historical ledger rows carry it, and it is load-bearing for the (provider, provider_payment_id)
// idempotency index.
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerYooKassa = "yookassa" // the direct (RUB) rail
providerRobokassa = "robokassa" // the retired direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// yookassaReturnPath is where YooKassa returns the customer's browser after the hosted payment page.
// The credit never rides this redirect — it rides the server notification — so the page only closes
// the payment window and drops the customer back into the live app.
const yookassaReturnPath = "/pay/yookassa/return"
// 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
// the provider's hosted-payment page 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.
@@ -41,7 +49,7 @@ type walletOrderResponse struct {
}
// 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
// details for the client: the provider's 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
@@ -84,13 +92,20 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
switch cxt.Kind {
case payments.SourceDirect:
shop, ok := s.robokassa.Shop(cxt.Subtype)
if !ok {
// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
// only reached when YooKassa has no shop for this channel — which is what makes reviving the
// old rail a credentials change rather than a code change. The rail is resolved before any
// account-level gate, so an unconfigured rail still reads as unavailable rather than as
// something the customer could fix.
ykShop, onYooKassa := s.yookassa.Shop(cxt.Subtype)
rkShop, onRobokassa := s.robokassa.Shop(cxt.Subtype)
if !onYooKassa && !onRobokassa {
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)
// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
// receipt is delivered.
email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
@@ -99,6 +114,10 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
return
}
if onYooKassa {
s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
return
}
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
if err != nil {
s.abortErr(c, err)
@@ -106,7 +125,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
RedirectURL: rkShop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
@@ -0,0 +1,355 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/yookassa"
)
// maxNotifyBytes caps the notification body the backend will read. The gateway already forwards only
// what it read from the provider; this is the backend's own ceiling on an untrusted body.
const maxNotifyBytes = 1 << 20
// orderYooKassa opens a pending order on the direct rail and mints the YooKassa payment the customer
// is redirected to. shop is the merchant shop the caller resolved for this platform channel, and
// email is the account's confirmed address (D36), which doubles as the delivery address of the fiscal
// receipt this rail must send with every payment. No chips are credited here —
// only later, by the verified notification (or the reconcile sweep).
func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Context, present []payments.Source, productID uuid.UUID, shop yookassa.Config, email string) {
ctx := c.Request.Context()
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerYooKassa)
if err != nil {
s.abortErr(c, err)
return
}
amount := yookassa.Amount{Value: res.Amount.Major(), Currency: string(res.Amount.Currency())}
payment, err := shop.CreatePayment(ctx, yookassa.PaymentRequest{
Amount: amount,
Capture: true,
Confirmation: yookassa.Confirmation{
Type: yookassa.ConfirmationRedirect,
ReturnURL: strings.TrimSuffix(s.publicURL, "/") + yookassaReturnPath,
},
Description: yookassa.TruncateDescription(res.Title),
Metadata: map[string]string{yookassa.MetadataOrderID: res.OrderID.String()},
// «Чеки от ЮKassa»: the receipt is registered only if we send it, so a malformed one is an
// API error here rather than a silently missing fiscal document.
Receipt: yookassa.SingleItemReceipt(email, res.Title, amount, s.vatCode),
}, res.OrderID.String())
if err != nil {
// The order stays pending and unpaid; it expires on its own. The customer sees the generic
// error, and the log carries the provider's own description (which names the offending
// parameter when a receipt is at fault).
s.log.Error("yookassa create payment failed", zap.String("order", res.OrderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
return
}
if payment.Confirmation.ConfirmationURL == "" {
s.log.Error("yookassa payment has no confirmation url",
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID))
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
return
}
// Record the provider's payment id so the reconcile sweep and any refund can address it. A
// failure here is logged and tolerated: the credit path matches on the order id carried in the
// payment metadata, so the purchase still completes — only the safety net and refunds lose their
// handle on this one order.
if err := s.payments.AttachProviderPayment(ctx, res.OrderID, providerYooKassa, payment.ID); err != nil {
s.log.Error("yookassa attach payment id failed",
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID), zap.Error(err))
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: payment.Confirmation.ConfirmationURL,
Rail: providerYooKassa,
})
}
// handleYooKassaNotify is the YooKassa webhook, reached only through the gateway (which checks the
// sender address and forwards the raw JSON body on the internal, gateway-only route).
//
// YooKassa does not sign notifications, so the body is never evidence: it only names a payment to
// re-read. Everything acted on comes from the confirming GetPayment. A succeeded payment credits its
// order exactly once (idempotent, and honoured even if the order expired); a canceled one records a
// failed event so the customer is told the attempt did not go through.
//
// The reply tells the provider whether to redeliver: 200 for anything durably decided — including a
// duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient
// failure we want redelivered (YooKassa retries for 24 hours). An unrecognised sender is a 403,
// which is also redelivered: if YooKassa ever changed its published ranges, the retries plus the
// reconcile sweep would keep the money owed while the allowlist is corrected.
func (s *Server) handleYooKassaNotify(c *gin.Context) {
// The sender check runs before anything else. It is defence in depth — the confirming GetPayment
// is what actually establishes authenticity — but it is what stops a forger from turning each
// fabricated notification into an outbound API call of ours. The address is the true sender,
// forwarded by the gateway, which is the only hop that sees it.
if ip := clientIP(c); !yookassa.AllowedSender(ip) {
s.log.Warn("yookassa notify: sender is not a YooKassa address", zap.String("ip", ip))
c.String(http.StatusForbidden, "forbidden")
return
}
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
if err != nil {
c.String(http.StatusInternalServerError, "error")
return
}
note, err := yookassa.ParseNotification(raw)
if err != nil {
s.log.Warn("yookassa notify: malformed body", zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
switch note.Event {
case yookassa.EventPaymentSucceeded, yookassa.EventPaymentCanceled:
default:
// Refund events are informational: refunds are issued through the API, which records them
// synchronously. Anything else is a subscription we do not act on.
c.String(http.StatusOK, "ignored")
return
}
claimed, err := note.Payment()
if err != nil {
s.log.Warn("yookassa notify: unusable payment object", zap.String("event", note.Event), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
orderID, err := uuid.Parse(claimed.OrderID())
if err != nil {
s.log.Warn("yookassa notify: no order in payment metadata", zap.String("payment", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
ctx := c.Request.Context()
ref, err := s.payments.OrderProviderRef(ctx, orderID)
if errors.Is(err, payments.ErrOrderNotFound) {
s.log.Warn("yookassa notify: unknown order", zap.String("order", orderID.String()), zap.String("payment", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
if err != nil {
s.log.Error("yookassa notify: order lookup failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
payment, shop, err := s.confirmYooKassaPayment(ctx, claimed.ID, claimed.Recipient.AccountID, ref)
if err != nil {
var apiErr *yookassa.APIError
if errors.As(err, &apiErr) && !apiErr.Retryable() {
// The provider says this payment is not ours or not readable — a forged or stale
// notification. Nothing to redeliver.
s.log.Warn("yookassa notify: payment not confirmed",
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
s.log.Error("yookassa notify: confirm failed",
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !s.yooKassaPaymentMatchesOrder(payment, shop, orderID) {
c.String(http.StatusOK, "ignored")
return
}
switch payment.Status {
case yookassa.StatusSucceeded:
if err := s.creditYooKassaPayment(ctx, orderID, payment); err != nil {
if isPermanentFundError(err) {
s.log.Warn("yookassa notify rejected", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusOK, "rejected")
return
}
s.log.Error("yookassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
case yookassa.StatusCanceled:
// An active decline (§9): the customer is told the attempt failed. An order abandoned without
// a decline never reaches here — it just expires, invisibly.
s.recordYooKassaFailure(ctx, ref.AccountID, orderID, payment)
default:
// Still pending: the notification raced ahead of the payment, or was forged. Either way the
// reconcile sweep will settle the order, so there is nothing to redeliver.
s.log.Info("yookassa notify: payment not final",
zap.String("order", orderID.String()), zap.String("status", payment.Status))
}
c.String(http.StatusOK, "ok")
}
// confirmYooKassaPayment re-reads a payment from the API — the authenticity check for the whole rail,
// since notifications are unsigned. The shop is chosen by the shop id the payment claims to have been
// paid to, falling back to the merchant channel recorded on the order; a claim naming a shop we do
// not run is not honoured with our own credentials.
func (s *Server) confirmYooKassaPayment(ctx context.Context, paymentID, claimedShopID string, ref payments.OrderRef) (yookassa.Payment, yookassa.Config, error) {
_, shop, ok := s.yookassa.ByShopID(claimedShopID)
if !ok {
shop, ok = s.yookassa.Shop(ref.Shop)
}
if !ok {
return yookassa.Payment{}, yookassa.Config{}, yookassa.ErrUnconfigured
}
payment, err := shop.GetPayment(ctx, paymentID)
return payment, shop, err
}
// yooKassaPaymentMatchesOrder checks the confirmed payment really belongs to the order being
// credited and to the kind of shop we think it is. It rejects a payment whose metadata names a
// different order, and — the guard that keeps play money out of the live ledger — a live payment
// arriving on test credentials or a test payment on live ones.
func (s *Server) yooKassaPaymentMatchesOrder(payment yookassa.Payment, shop yookassa.Config, orderID uuid.UUID) bool {
if payment.OrderID() != orderID.String() {
s.log.Warn("yookassa: confirmed payment belongs to another order",
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
zap.String("payment_order", payment.OrderID()))
return false
}
if payment.Test != shop.IsTest {
s.log.Error("yookassa: payment test flag does not match the shop; refusing to credit",
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
zap.Bool("payment_test", payment.Test), zap.Bool("shop_test", shop.IsTest))
return false
}
return true
}
// creditYooKassaPayment credits a confirmed succeeded payment to its order exactly once and records
// the succeeded event a duplicate must not re-emit.
func (s *Server) creditYooKassaPayment(ctx context.Context, orderID uuid.UUID, payment yookassa.Payment) error {
paid, err := payments.ParseMoney(payment.Amount.Value, payments.Currency(payment.Amount.Currency))
if err != nil {
return err
}
outcome, err := s.payments.Fund(ctx, orderID, providerYooKassa, payment.ID, paid)
if err != nil {
return err
}
if outcome.AlreadyCredited {
return nil
}
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 {
// The credit is already committed; only the in-app notification is lost, and the wallet still
// refreshes on the client's return.
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
return nil
}
// recordYooKassaFailure records the failed payment event for an actively declined payment, so the
// customer learns the attempt did not go through instead of watching a balance that never moves.
func (s *Server) recordYooKassaFailure(ctx context.Context, accountID uuid.UUID, orderID uuid.UUID, payment yookassa.Payment) {
reason := ""
if payment.CancellationDetails != nil {
reason = payment.CancellationDetails.Reason
}
s.log.Info("yookassa payment canceled",
zap.String("order", orderID.String()), zap.String("payment", payment.ID), zap.String("reason", reason))
payload, _ := json.Marshal(map[string]any{"reason": reason})
if err := s.payments.RecordPaymentEvent(ctx, accountID, &orderID, "failed", payload); err != nil {
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// isPermanentFundError reports whether a credit failed for a reason no retry can fix — an order that
// does not exist, an amount that does not match, or a product that is no longer a chip pack.
func isPermanentFundError(err error) bool {
return errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound)
}
// refundYooKassa returns the money for a paid order through the YooKassa refund API and reports the
// provider's own refund id, which the ledger then records. The refund carries a receipt because the
// rail registers a refund receipt the same way it registers a payment one, and the order id as the
// idempotency key, so a repeated attempt returns the original refund instead of paying twice.
//
// It is called before anything is recorded: if the money does not move, nothing is written, and the
// ledger never claims a refund that did not happen.
func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (string, error) {
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
return "", yookassa.ErrUnconfigured
}
if ref.PaymentID == "" {
return "", errors.New("the order carries no provider payment id to refund")
}
title, _, err := s.payments.OrderItem(ctx, ref.OrderID)
if err != nil {
return "", err
}
email, _, err := s.accounts.ConfirmedEmail(ctx, ref.AccountID)
if err != nil {
return "", err
}
amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())}
refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{
PaymentID: ref.PaymentID,
Amount: amount,
Receipt: yookassa.SingleItemReceipt(email, title, amount, s.vatCode),
}, ref.OrderID.String())
if err != nil {
return "", err
}
if refund.ID == "" {
return "", errors.New("the provider returned a refund with no id")
}
return refund.ID, nil
}
// ReconcileYooKassaOrders asks YooKassa what became of every pending order that reached its expiry
// age while carrying a payment id, and credits the ones that were in fact paid. It is the safety net
// for a notification that was lost for good: YooKassa redelivers for 24 hours, so a short outage
// heals itself, but a notification that never arrived at all would otherwise leave the money taken
// and the chips unowed. It runs once per order, just before the order is written off as expired.
//
// It returns how many orders it credited. Failures are logged and skipped: the next sweep retries.
func (s *Server) ReconcileYooKassaOrders(ctx context.Context) int {
if !s.yookassa.Configured() || s.payments == nil {
return 0
}
refs, err := s.payments.PendingForReconcile(ctx)
if err != nil {
s.log.Warn("yookassa reconcile: read pending orders failed", zap.Error(err))
return 0
}
credited := 0
for _, ref := range refs {
if ref.Provider != providerYooKassa || ref.PaymentID == "" {
continue
}
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
continue
}
payment, err := shop.GetPayment(ctx, ref.PaymentID)
if err != nil {
s.log.Warn("yookassa reconcile: payment lookup failed",
zap.String("order", ref.OrderID.String()), zap.String("payment", ref.PaymentID), zap.Error(err))
continue
}
if payment.Status != yookassa.StatusSucceeded || !s.yooKassaPaymentMatchesOrder(payment, shop, ref.OrderID) {
continue
}
if err := s.creditYooKassaPayment(ctx, ref.OrderID, payment); err != nil {
s.log.Error("yookassa reconcile: credit failed",
zap.String("order", ref.OrderID.String()), zap.Error(err))
continue
}
s.log.Info("yookassa reconcile: credited an order no notification confirmed",
zap.String("order", ref.OrderID.String()), zap.String("payment", payment.ID))
credited++
}
return credited
}
+19 -2
View File
@@ -36,6 +36,7 @@ import (
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
"scrabble/backend/internal/yookassa"
)
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
@@ -115,8 +116,18 @@ 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 — one merchant shop per channel; an empty
// set leaves the order and Result-callback endpoints unregistered.
// YooKassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// set leaves the order and notification endpoints unregistered and falls the direct rail back to
// Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
YooKassaVatCode int
// PublicBaseURL is the canonical https origin the payment return URL is built from. It is
// required whenever YooKassa is configured.
PublicBaseURL string
// Robokassa configures the retired direct-rail provider — one merchant shop per channel. No
// deployment sets it, so the set is empty and the direct rail resolves to YooKassa; it stays
// wired so restoring the rail is a credentials change (backend/internal/robokassa/README.md).
Robokassa robokassa.Shops
}
@@ -146,6 +157,9 @@ type Server struct {
ads *ads.Service
payments *payments.Service
gamelimits *gamelimits.Service
yookassa yookassa.Shops
vatCode int
publicURL string
robokassa robokassa.Shops
notifier notify.Publisher
console *adminconsole.Renderer
@@ -201,6 +215,9 @@ func New(addr string, deps Deps) *Server {
ads: deps.Ads,
payments: deps.Payments,
gamelimits: deps.GameLimits,
yookassa: deps.YooKassa,
vatCode: deps.YooKassaVatCode,
publicURL: deps.PublicBaseURL,
robokassa: deps.Robokassa,
notifier: notifier,
renderer: deps.Renderer,
+98
View File
@@ -0,0 +1,98 @@
package yookassa
import (
"encoding/json"
"fmt"
"net/netip"
)
// Notification events this integration subscribes to. An event name is "<object>.<status>": the
// object whose status changed and the status it entered.
const (
// EventPaymentSucceeded means the money was taken and the order may be credited.
EventPaymentSucceeded = "payment.succeeded"
// EventPaymentCanceled means the payment was actively declined or abandoned; nothing is credited
// and the payer is told the attempt failed.
EventPaymentCanceled = "payment.canceled"
// EventRefundSucceeded reports a completed refund. Refunds here are always initiated by an
// operator through the API, which records them synchronously, so this event is informational.
EventRefundSucceeded = "refund.succeeded"
)
// notificationType is the fixed value of a notification envelope's type field.
const notificationType = "notification"
// Notification is an incoming webhook envelope. Object is left raw because its shape depends on the
// event — a payment for payment.*, a refund for refund.* — and because nothing in it may be acted on
// before GetPayment confirms it: YooKassa does not sign notifications.
type Notification struct {
Type string `json:"type"`
Event string `json:"event"`
Object json.RawMessage `json:"object"`
}
// ParseNotification decodes a webhook body and checks the envelope is a notification with an event.
// It deliberately validates nothing else: the body is untrusted input whose only job is to name an
// object to re-read from the API.
func ParseNotification(body []byte) (Notification, error) {
var n Notification
if err := json.Unmarshal(body, &n); err != nil {
return Notification{}, fmt.Errorf("yookassa: decode notification: %w", err)
}
if n.Type != notificationType || n.Event == "" {
return Notification{}, fmt.Errorf("yookassa: not a notification envelope (type %q, event %q)", n.Type, n.Event)
}
return n, nil
}
// Payment decodes the notification's object as a payment. Use it only for payment.* events; it
// yields the payment id to re-read, never the payment state to act on.
func (n Notification) Payment() (Payment, error) {
var p Payment
if err := json.Unmarshal(n.Object, &p); err != nil {
return Payment{}, fmt.Errorf("yookassa: decode notification payment: %w", err)
}
if p.ID == "" {
return Payment{}, fmt.Errorf("yookassa: notification payment has no id")
}
return p, nil
}
// senderPrefixes are the address ranges YooKassa delivers notifications from
// (https://yookassa.ru/developers/using-api/webhooks). Single addresses are expressed as /32 and
// /128 prefixes. The list is defence in depth only — the confirming GetPayment is what actually
// establishes authenticity — so it is kept deliberately literal and easy to audit against the docs.
var senderPrefixes = []netip.Prefix{
netip.MustParsePrefix("185.71.76.0/27"),
netip.MustParsePrefix("185.71.77.0/27"),
netip.MustParsePrefix("77.75.153.0/25"),
netip.MustParsePrefix("77.75.156.11/32"),
netip.MustParsePrefix("77.75.156.35/32"),
netip.MustParsePrefix("77.75.154.128/25"),
netip.MustParsePrefix("2a02:5180::/32"),
}
// AllowedIP reports whether addr is one of YooKassa's notification senders. An IPv4-mapped IPv6
// address is unmapped first, so a dual-stack listener's view of an IPv4 sender still matches.
func AllowedIP(addr netip.Addr) bool {
addr = addr.Unmap()
if !addr.IsValid() {
return false
}
for _, p := range senderPrefixes {
if p.Contains(addr) {
return true
}
}
return false
}
// AllowedSender reports whether the textual address ip is one of YooKassa's notification senders. An
// unparseable address is not allowed.
func AllowedSender(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return AllowedIP(addr)
}
+90
View File
@@ -0,0 +1,90 @@
package yookassa
import (
"testing"
)
func TestParseNotification(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded",
"object":{"id":"pay-1","status":"succeeded","paid":true,"metadata":{"order_id":"0197-order"}}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if n.Event != EventPaymentSucceeded {
t.Errorf("event = %q, want %q", n.Event, EventPaymentSucceeded)
}
p, err := n.Payment()
if err != nil {
t.Fatalf("decode notification payment: %v", err)
}
if p.ID != "pay-1" {
t.Errorf("payment id = %q, want pay-1", p.ID)
}
if p.OrderID() != "0197-order" {
t.Errorf("order id = %q, want the order carried in metadata", p.OrderID())
}
}
func TestParseNotificationRejectsNonEnvelopes(t *testing.T) {
for name, body := range map[string]string{
"not json": `nonsense`,
"wrong type": `{"type":"payment","event":"payment.succeeded","object":{"id":"pay-1"}}`,
"no event": `{"type":"notification","object":{"id":"pay-1"}}`,
"empty object": `{}`,
} {
if _, err := ParseNotification([]byte(body)); err == nil {
t.Errorf("%s: accepted as a notification envelope", name)
}
}
}
func TestNotificationPaymentNeedsAnID(t *testing.T) {
// Without an id there is nothing to re-read from the API, and the body itself is never evidence.
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded","object":{"status":"succeeded"}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if _, err := n.Payment(); err == nil {
t.Error("a payment object with no id was accepted")
}
}
func TestAllowedSender(t *testing.T) {
allowed := []string{
"185.71.76.0", "185.71.76.31", // /27 bounds
"185.71.77.15",
"77.75.153.0", "77.75.153.127", // /25 bounds
"77.75.156.11", "77.75.156.35", // the two single addresses
"77.75.154.128", "77.75.154.255",
"2a02:5180::1", "2a02:5180:ffff::abcd",
}
for _, ip := range allowed {
if !AllowedSender(ip) {
t.Errorf("%s rejected, want allowed", ip)
}
}
denied := []string{
"185.71.76.32", "185.71.75.255", // just outside the /27
"77.75.153.128", // just outside the /25
"77.75.156.12", // adjacent to a single allowed address
"77.75.154.127",
"2a02:5181::1",
"8.8.8.8", "127.0.0.1", "",
"not an address",
}
for _, ip := range denied {
if AllowedSender(ip) {
t.Errorf("%s allowed, want rejected", ip)
}
}
}
func TestAllowedSenderUnmapsIPv4(t *testing.T) {
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d; it must still match the v4 range.
if !AllowedSender("::ffff:77.75.156.11") {
t.Error("an IPv4-mapped allowed sender was rejected")
}
if AllowedSender("::ffff:8.8.8.8") {
t.Error("an IPv4-mapped foreign sender was allowed")
}
}
+82
View File
@@ -0,0 +1,82 @@
package yookassa
// Fiscal receipt attributes for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers the receipt itself —
// no cash register to rent, no OFD contract — but only if the payment and refund requests carry a
// receipt object, so unlike the previous rail's cabinet-side receipts these fields are load-bearing
// code. Reference:
// https://yookassa.ru/developers/payment-acceptance/receipts/54fz/yoomoney/parameters-values
const (
// PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken
// for. Chips are sold as a service ("Услуга").
PaymentSubjectService = "service"
// PaymentModeFullPayment is the settlement method (54-ФЗ tag 1214) — the payer pays and receives
// the goods at once ("Полный расчет"). «Чеки от ЮKassa» accept only this and full_prepayment;
// partial prepayment, advance and credit are not supported.
PaymentModeFullPayment = "full_payment"
// VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on
// УСН or ПСН charges. It is the default; the deployed value is configurable because the rate is
// the one receipt attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026,
// adding codes 11 and 12).
VatCodeNone = 1
// VatCodeMax is the highest VAT rate code the API accepts; codes run 1..12.
VatCodeMax = 12
)
// Customer is the payer's contact data for delivering the receipt. «Чеки от ЮKassa» deliver only by
// email (no SMS), so Email is required — the direct rail's confirmed email anchor supplies it.
type Customer struct {
Email string `json:"email,omitempty"`
}
// ReceiptItem is one line of a fiscal receipt: what was sold, how much of it, for how much, and the
// three fiscal attributes that classify it.
type ReceiptItem struct {
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Amount Amount `json:"amount"`
VatCode int `json:"vat_code"`
PaymentSubject string `json:"payment_subject"`
PaymentMode string `json:"payment_mode"`
}
// Receipt is the fiscal receipt data sent alongside a payment or a refund. tax_system_code is
// deliberately absent: YooKassa ignores it for «Чеки от ЮKassa».
type Receipt struct {
Customer Customer `json:"customer"`
Items []ReceiptItem `json:"items"`
}
// maxDescriptionRunes is the API's limit for both a payment description and a receipt line
// description (54-ФЗ tag 1030). A longer title is truncated rather than rejected, so an over-long
// product name cannot block a purchase.
const maxDescriptionRunes = 128
// SingleItemReceipt builds a one-line receipt for selling title at amount to email. Chip packs are
// always a single line bought once, so quantity is 1 and the line amount is the whole payment.
func SingleItemReceipt(email, title string, amount Amount, vatCode int) *Receipt {
return &Receipt{
Customer: Customer{Email: email},
Items: []ReceiptItem{{
Description: TruncateDescription(title),
Quantity: 1,
Amount: amount,
VatCode: vatCode,
PaymentSubject: PaymentSubjectService,
PaymentMode: PaymentModeFullPayment,
}},
}
}
// TruncateDescription shortens s to the API's description limit, counting runes rather than bytes so
// a Cyrillic title is not cut mid-character.
func TruncateDescription(s string) string {
r := []rune(s)
if len(r) <= maxDescriptionRunes {
return s
}
return string(r[:maxDescriptionRunes])
}
// ValidVatCode reports whether code is within the range the API accepts (1..12).
func ValidVatCode(code int) bool { return code >= 1 && code <= VatCodeMax }
+58
View File
@@ -0,0 +1,58 @@
package yookassa
// Shops is a set of YooKassa merchant shops keyed by channel — the subtype of the trusted X-Platform
// signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a payment to the
// per-channel shop for separate merchant accounts, accounting and receipts, while every shop still
// credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set leaves the direct
// order and notification endpoints unregistered.
type Shops map[string]Config
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
// default: an unknown or empty channel routes here on the order side.
const (
ChannelWeb = "web"
ChannelAndroid = "android"
)
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
// costs at most accounting accuracy, not money. The second result is false when neither the channel
// nor the web shop is configured.
func (s Shops) Shop(channel string) (Config, bool) {
if channel != "" {
if c, ok := s[channel]; ok && c.Configured() {
return c, true
}
}
if c, ok := s[ChannelWeb]; ok && c.Configured() {
return c, true
}
return Config{}, false
}
// ByShopID returns the shop with the given YooKassa shop id, which is how an incoming notification is
// attributed to one of several configured shops (the payment object reports it as
// recipient.account_id). Unlike Shop it does not fall back: an unrecognised shop id means the
// notification was not meant for us, and the caller must not guess at credentials.
func (s Shops) ByShopID(shopID string) (channel string, cfg Config, ok bool) {
if shopID == "" {
return "", Config{}, false
}
for ch, c := range s {
if c.Configured() && c.ShopID == shopID {
return ch, c, true
}
}
return "", Config{}, false
}
// Configured reports whether at least one shop has credentials (the YooKassa direct rail is live).
func (s Shops) Configured() bool {
for _, c := range s {
if c.Configured() {
return true
}
}
return false
}
+112
View File
@@ -0,0 +1,112 @@
package yookassa
import (
"strings"
"testing"
)
func testShops() Shops {
return Shops{
ChannelWeb: {ShopID: "100500", SecretKey: "web-secret"},
ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"},
}
}
func TestShopsShopRouting(t *testing.T) {
s := testShops()
for channel, wantShopID := range map[string]string{
ChannelWeb: "100500",
ChannelAndroid: "100700",
"ios": "100500", // an unconfigured channel falls back to web
"": "100500", // so does an unknown platform subtype
} {
got, ok := s.Shop(channel)
if !ok || got.ShopID != wantShopID {
t.Errorf("Shop(%q) = %q ok=%v, want %q", channel, got.ShopID, ok, wantShopID)
}
}
}
func TestShopsShopWithoutWebFallback(t *testing.T) {
// Only android configured: an unknown channel has nowhere safe to fall back to.
s := Shops{ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"}}
if got, ok := s.Shop(ChannelAndroid); !ok || got.ShopID != "100700" {
t.Errorf("Shop(android) = %q ok=%v, want the android shop", got.ShopID, ok)
}
if _, ok := s.Shop("ios"); ok {
t.Error("Shop(ios) resolved with no web shop configured")
}
if _, ok := (Shops{}).Shop(ChannelWeb); ok {
t.Error("an empty set resolved a shop")
}
}
func TestShopsByShopIDDoesNotGuess(t *testing.T) {
s := testShops()
channel, cfg, ok := s.ByShopID("100700")
if !ok || channel != ChannelAndroid || cfg.SecretKey != "android-secret" {
t.Errorf("ByShopID(100700) = %q/%q ok=%v, want the android shop", channel, cfg.SecretKey, ok)
}
// An unrecognised shop id means the notification was not meant for us; guessing at credentials
// would verify a foreign payment against our own shop.
for _, id := range []string{"999999", ""} {
if _, _, ok := s.ByShopID(id); ok {
t.Errorf("ByShopID(%q) resolved a shop", id)
}
}
}
func TestShopsIgnoreHalfConfiguredEntries(t *testing.T) {
s := Shops{
ChannelWeb: {ShopID: "100500"}, // no secret key
ChannelAndroid: {SecretKey: "android-secret"},
}
if s.Configured() {
t.Error("Configured() = true with no complete shop")
}
if _, ok := s.Shop(ChannelWeb); ok {
t.Error("Shop(web) resolved a shop with no secret key")
}
if _, _, ok := s.ByShopID("100500"); ok {
t.Error("ByShopID matched a shop with no secret key")
}
}
func TestShopsConfigured(t *testing.T) {
if !testShops().Configured() {
t.Error("Configured() = false with two complete shops")
}
if (Shops{}).Configured() {
t.Error("Configured() = true for an empty set")
}
}
func TestSingleItemReceiptTruncatesLongTitles(t *testing.T) {
long := strings.Repeat("ф", 200) // Cyrillic: truncation must count runes, not bytes
r := SingleItemReceipt("buyer@example.test", long, Amount{Value: "1.00", Currency: "RUB"}, VatCodeNone)
got := []rune(r.Items[0].Description)
if len(got) != maxDescriptionRunes {
t.Errorf("description = %d runes, want %d", len(got), maxDescriptionRunes)
}
for _, c := range got {
if c != 'ф' {
t.Fatalf("truncation cut a multi-byte rune: %q", string(got))
}
}
if r.Items[0].Quantity != 1 {
t.Errorf("quantity = %v, want 1 (a chip pack is bought once)", r.Items[0].Quantity)
}
}
func TestValidVatCode(t *testing.T) {
for _, code := range []int{1, 4, 11, 12} {
if !ValidVatCode(code) {
t.Errorf("vat code %d rejected, want accepted", code)
}
}
for _, code := range []int{0, -1, 13} {
if ValidVatCode(code) {
t.Errorf("vat code %d accepted, want rejected", code)
}
}
}
+271
View File
@@ -0,0 +1,271 @@
// Package yookassa talks to the YooKassa REST API for the direct-rail (RUB) payments: it creates a
// hosted payment the client is redirected to, re-reads a payment to confirm what really happened,
// and issues a refund. 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.
//
// Two properties of the API shape everything here. First, YooKassa notifications carry NO signature:
// authenticity is established by re-reading the object with GetPayment (the notification body is a
// hint, never evidence) and, defensively, by the sender-IP allowlist in AllowedIP. Second, every
// state-changing call is made idempotent by an Idempotence-Key header, so a retried create or refund
// returns the original object instead of charging twice; callers pass the order id as that key.
//
// The order is threaded to the provider through metadata["order_id"] and echoed back on the payment,
// so a notification resolves to an order without trusting anything else in the body.
package yookassa
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// DefaultBaseURL is the production API root. Config.BaseURL overrides it (tests point it at an
// httptest server).
const DefaultBaseURL = "https://api.yookassa.ru/v3"
// MetadataOrderID is the metadata key carrying our order id through the provider and back on the
// payment object — the only link a notification gives us to an order.
const MetadataOrderID = "order_id"
// Payment statuses (https://yookassa.ru/developers/payment-acceptance/getting-started/payment-process).
// A single-stage payment (Capture true) moves pending -> succeeded or pending -> canceled;
// StatusWaitingForCapture only occurs for two-stage payments, which this integration does not use.
const (
StatusPending = "pending"
StatusWaitingForCapture = "waiting_for_capture"
StatusSucceeded = "succeeded"
StatusCanceled = "canceled"
)
// ConfirmationRedirect is the confirmation scenario this integration uses: YooKassa hosts the
// payment form and returns the customer to the return URL afterwards.
const ConfirmationRedirect = "redirect"
// requestTimeout bounds a single API call. The caller's context still governs cancellation; this is
// the ceiling for a provider that has stopped answering.
const requestTimeout = 20 * time.Second
// maxResponseBytes caps how much of a response body is read, so a misbehaving or spoofed endpoint
// cannot exhaust memory.
const maxResponseBytes = 1 << 20
// apiClient is shared by every shop so connections pool across them. It carries only a ceiling
// timeout; per-request cancellation rides the context.
var apiClient = &http.Client{Timeout: requestTimeout}
// Config is a YooKassa merchant shop's credentials. ShopID and SecretKey authenticate every call as
// HTTP Basic credentials. IsTest records that these are a test shop's credentials, which lets the
// caller refuse to credit a live payment against a test shop and vice versa. BaseURL overrides
// DefaultBaseURL when set.
type Config struct {
ShopID string
SecretKey string
IsTest bool
BaseURL string
}
// Amount is a monetary amount on the wire: a decimal string with the fraction separator "." and an
// ISO-4217 currency code. payments.Money.Major() already renders Value in this form.
type Amount struct {
Value string `json:"value"`
Currency string `json:"currency"`
}
// Confirmation carries the confirmation scenario. ReturnURL is sent on a create (where the customer
// lands after paying, absolute); ConfirmationURL comes back on the created payment and is the page
// the customer must be sent to.
type Confirmation struct {
Type string `json:"type"`
ReturnURL string `json:"return_url,omitempty"`
ConfirmationURL string `json:"confirmation_url,omitempty"`
}
// Recipient identifies the shop that received the payment. AccountID is the shop id, which is how an
// incoming notification is attributed to one of several configured shops.
type Recipient struct {
AccountID string `json:"account_id"`
GatewayID string `json:"gateway_id"`
}
// Cancellation explains a canceled payment: which participant decided (yoo_money, payment_network,
// merchant) and why. It is reported to the operator, not to the payer.
type Cancellation struct {
Party string `json:"party"`
Reason string `json:"reason"`
}
// PaymentRequest is the body of a create-payment call in the redirect confirmation scenario. Capture
// true settles in one stage: a paid payment goes straight to succeeded with no capture call.
type PaymentRequest struct {
Amount Amount `json:"amount"`
Capture bool `json:"capture"`
Confirmation Confirmation `json:"confirmation"`
Description string `json:"description,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Receipt *Receipt `json:"receipt,omitempty"`
}
// Payment is a YooKassa payment object. Paid reports that the money was actually taken; Test is true
// for a payment made against a test shop. Metadata echoes what the create call sent, so
// Metadata[MetadataOrderID] identifies the order.
type Payment struct {
ID string `json:"id"`
Status string `json:"status"`
Paid bool `json:"paid"`
Test bool `json:"test"`
Amount Amount `json:"amount"`
Description string `json:"description"`
Confirmation Confirmation `json:"confirmation"`
Metadata map[string]string `json:"metadata"`
Recipient Recipient `json:"recipient"`
CancellationDetails *Cancellation `json:"cancellation_details,omitempty"`
}
// OrderID returns the order the payment funds, taken from the metadata the create call threaded
// through. It is empty for a payment we did not originate.
func (p Payment) OrderID() string { return p.Metadata[MetadataOrderID] }
// RefundRequest is the body of a create-refund call. Amount may be the whole payment or part of it;
// Receipt carries the refund receipt required when the shop registers receipts through YooKassa.
type RefundRequest struct {
PaymentID string `json:"payment_id"`
Amount Amount `json:"amount"`
Description string `json:"description,omitempty"`
Receipt *Receipt `json:"receipt,omitempty"`
}
// Refund is a YooKassa refund object. Its ID is distinct from the refunded payment's id and is what
// the ledger records as the refund's idempotency key.
type Refund struct {
ID string `json:"id"`
Status string `json:"status"`
PaymentID string `json:"payment_id"`
Amount Amount `json:"amount"`
}
// ErrUnconfigured is returned when a call is attempted on a shop with no credentials.
var ErrUnconfigured = errors.New("yookassa: shop is not configured")
// APIError is a non-2xx answer from the API. Description and Parameter come from YooKassa's error
// object and name the offending field, which is what makes a malformed receipt diagnosable.
type APIError struct {
StatusCode int `json:"-"`
Type string `json:"type"`
ID string `json:"id"`
Code string `json:"code"`
Description string `json:"description"`
Parameter string `json:"parameter"`
RetryAfter int `json:"retry_after"`
}
// Error renders the status together with YooKassa's own description, including the offending
// parameter when the API named one.
func (e *APIError) Error() string {
msg := e.Description
if msg == "" {
msg = e.Code
}
if e.Parameter != "" {
return fmt.Sprintf("yookassa: http %d: %s (parameter %s)", e.StatusCode, msg, e.Parameter)
}
return fmt.Sprintf("yookassa: http %d: %s", e.StatusCode, msg)
}
// Retryable reports whether repeating the call could succeed. A 5xx or a 429 is transient; a 4xx is
// a permanent rejection of this request. Callers use it to decide whether to ask the provider to
// redeliver a notification or to accept it as durably handled.
func (e *APIError) Retryable() bool {
return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests
}
// Configured reports whether the shop has credentials to call the API with.
func (c Config) Configured() bool { return c.ShopID != "" && c.SecretKey != "" }
// CreatePayment opens a payment for the order and returns the created object, whose
// Confirmation.ConfirmationURL is the page the customer must be sent to. idempotenceKey makes the
// call safe to retry — YooKassa replays the original result for 24 hours — so callers pass the order
// id and never mint a second payment for the same order.
func (c Config) CreatePayment(ctx context.Context, req PaymentRequest, idempotenceKey string) (Payment, error) {
var out Payment
err := c.do(ctx, http.MethodPost, "/payments", req, idempotenceKey, &out)
return out, err
}
// GetPayment re-reads a payment by id. This is the authenticity check for the whole rail: YooKassa
// notifications are unsigned, so only the object returned here may be acted on.
func (c Config) GetPayment(ctx context.Context, paymentID string) (Payment, error) {
var out Payment
err := c.do(ctx, http.MethodGet, "/payments/"+url.PathEscape(paymentID), nil, "", &out)
return out, err
}
// CreateRefund returns money for a succeeded payment and reports the created refund. idempotenceKey
// guards against a double refund on a retry; callers pass the order id.
func (c Config) CreateRefund(ctx context.Context, req RefundRequest, idempotenceKey string) (Refund, error) {
var out Refund
err := c.do(ctx, http.MethodPost, "/refunds", req, idempotenceKey, &out)
return out, err
}
// do performs one authenticated API call and decodes the result into out. A non-2xx answer is
// returned as an *APIError carrying YooKassa's own error object when the body holds one.
func (c Config) do(ctx context.Context, method, path string, body any, idempotenceKey string, out any) error {
if !c.Configured() {
return ErrUnconfigured
}
var payload io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("yookassa: encode %s %s: %w", method, path, err)
}
payload = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.endpoint()+path, payload)
if err != nil {
return fmt.Errorf("yookassa: build %s %s: %w", method, path, err)
}
req.SetBasicAuth(c.ShopID, c.SecretKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotenceKey != "" {
req.Header.Set("Idempotence-Key", idempotenceKey)
}
resp, err := apiClient.Do(req)
if err != nil {
return fmt.Errorf("yookassa: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return fmt.Errorf("yookassa: read %s %s: %w", method, path, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
apiErr := &APIError{StatusCode: resp.StatusCode}
// The body is YooKassa's error object on a handled fault and something else entirely on an
// edge failure; either way the status alone is enough to classify the error.
_ = json.Unmarshal(raw, apiErr)
return apiErr
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("yookassa: decode %s %s: %w", method, path, err)
}
return nil
}
// endpoint returns the API root for this shop, without a trailing slash.
func (c Config) endpoint() string {
if c.BaseURL == "" {
return DefaultBaseURL
}
return strings.TrimSuffix(c.BaseURL, "/")
}
+225
View File
@@ -0,0 +1,225 @@
package yookassa
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// capture records what the fake API received, so a test can assert on the request the client built.
type capture struct {
method string
path string
authUser string
authPass string
idempotenceKey string
body map[string]any
}
// fakeAPI serves one canned response and records the request. It returns a Config already pointed at
// it, which is how every API-shaped test drives the client without touching the network.
func fakeAPI(t *testing.T, status int, response string) (Config, *capture) {
t.Helper()
got := &capture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.method = r.Method
got.path = r.URL.Path
got.authUser, got.authPass, _ = r.BasicAuth()
got.idempotenceKey = r.Header.Get("Idempotence-Key")
if raw, _ := io.ReadAll(r.Body); len(raw) > 0 {
_ = json.Unmarshal(raw, &got.body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got
}
func TestCreatePaymentBuildsTheRequest(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false,
"amount":{"value":"149.00","currency":"RUB"},
"confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"},
"metadata":{"order_id":"0197-order"}}`)
amount := Amount{Value: "149.00", Currency: "RUB"}
p, err := cfg.CreatePayment(context.Background(), PaymentRequest{
Amount: amount,
Capture: true,
Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"},
Description: "10 chips",
Metadata: map[string]string{MetadataOrderID: "0197-order"},
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
}, "0197-order")
if err != nil {
t.Fatalf("create payment: %v", err)
}
if got.method != http.MethodPost || got.path != "/payments" {
t.Errorf("request = %s %s, want POST /payments", got.method, got.path)
}
if got.authUser != "100500" || got.authPass != "secret" {
t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass)
}
// The idempotence key is what makes a retried create return the original payment instead of
// minting a second one for the same order.
if got.idempotenceKey != "0197-order" {
t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey)
}
if got.body["capture"] != true {
t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"])
}
conf, _ := got.body["confirmation"].(map[string]any)
if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" {
t.Errorf("confirmation = %v, want a redirect with the return url", conf)
}
meta, _ := got.body["metadata"].(map[string]any)
if meta[MetadataOrderID] != "0197-order" {
t.Errorf("metadata = %v, want the order id threaded through", meta)
}
receipt, _ := got.body["receipt"].(map[string]any)
customer, _ := receipt["customer"].(map[string]any)
if customer["email"] != "buyer@example.test" {
t.Errorf("receipt customer = %v, want the buyer email", customer)
}
items, _ := receipt["items"].([]any)
if len(items) != 1 {
t.Fatalf("receipt items = %d, want 1", len(items))
}
item, _ := items[0].(map[string]any)
if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment {
t.Errorf("receipt item fiscal attributes = %v", item)
}
if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" {
t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL)
}
if p.OrderID() != "0197-order" {
t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID())
}
}
func TestGetPaymentReadsTheObject(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"pay-1","status":"succeeded","paid":true,"test":true,
"amount":{"value":"149.00","currency":"RUB"},
"recipient":{"account_id":"100500","gateway_id":"100700"},
"metadata":{"order_id":"0197-order"}}`)
p, err := cfg.GetPayment(context.Background(), "pay-1")
if err != nil {
t.Fatalf("get payment: %v", err)
}
if got.method != http.MethodGet || got.path != "/payments/pay-1" {
t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path)
}
// A read is idempotent by nature, so no key is sent.
if got.idempotenceKey != "" {
t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey)
}
if p.Status != StatusSucceeded || !p.Paid {
t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid)
}
if !p.Test {
t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one")
}
if p.Recipient.AccountID != "100500" {
t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID)
}
}
func TestCreateRefundBuildsTheRequest(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}`)
amount := Amount{Value: "149.00", Currency: "RUB"}
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
PaymentID: "pay-1",
Amount: amount,
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
}, "0197-order")
if err != nil {
t.Fatalf("create refund: %v", err)
}
if got.method != http.MethodPost || got.path != "/refunds" {
t.Errorf("request = %s %s, want POST /refunds", got.method, got.path)
}
if got.idempotenceKey != "0197-order" {
t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey)
}
if got.body["payment_id"] != "pay-1" {
t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"])
}
if _, ok := got.body["receipt"]; !ok {
t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»")
}
// The refund id is distinct from the payment id: the ledger keys the refund row on it, so the
// fund and refund rows can coexist under the same partial-unique index.
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r)
}
}
func TestAPIErrorClassifiesRetryability(t *testing.T) {
cfg, _ := fakeAPI(t, http.StatusBadRequest,
`{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`)
_, err := cfg.GetPayment(context.Background(), "pay-1")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want an *APIError", err)
}
if apiErr.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want 400", apiErr.StatusCode)
}
// The offending parameter is what makes a malformed receipt diagnosable rather than a mystery.
if !strings.Contains(apiErr.Error(), "receipt.items") {
t.Errorf("error text %q does not name the offending parameter", apiErr.Error())
}
if apiErr.Retryable() {
t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered")
}
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`)
_, err := cfg.GetPayment(context.Background(), "pay-1")
var e *APIError
if !errors.As(err, &e) || !e.Retryable() {
t.Errorf("status %d: error = %v, want a retryable *APIError", status, err)
}
}
}
func TestUnconfiguredShopMakesNoCall(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("an unconfigured shop reached the API")
}))
t.Cleanup(srv.Close)
for name, cfg := range map[string]Config{
"no credentials": {BaseURL: srv.URL},
"no secret key": {ShopID: "100500", BaseURL: srv.URL},
"no shop id": {SecretKey: "secret", BaseURL: srv.URL},
} {
if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) {
t.Errorf("%s: error = %v, want ErrUnconfigured", name, err)
}
}
}
func TestEndpointDefaultsToTheProductionAPI(t *testing.T) {
if got := (Config{}).endpoint(); got != DefaultBaseURL {
t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL)
}
if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" {
t.Errorf("endpoint = %q, want the trailing slash trimmed", got)
}
}
+24 -22
View File
@@ -132,28 +132,30 @@ GATEWAY_VK_APP_SECRET=
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
GATEWAY_VK_ID_CLIENT_SECRET=
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
ROBOKASSA_MERCHANT_LOGIN=
ROBOKASSA_PASSWORD1=
ROBOKASSA_PASSWORD2=
ROBOKASSA_TEST=
# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here.
# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each
# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The
# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android.
ROBOKASSA_WEB_MERCHANT_LOGIN=
ROBOKASSA_WEB_PASSWORD1=
ROBOKASSA_WEB_PASSWORD2=
ROBOKASSA_WEB_TEST=
ROBOKASSA_ANDROID_MERCHANT_LOGIN=
ROBOKASSA_ANDROID_PASSWORD1=
ROBOKASSA_ANDROID_PASSWORD2=
ROBOKASSA_ANDROID_TEST=
# --- Payments: YooKassa (direct RUB rail) ------------------------------------
# One merchant shop per channel (D42): the shop id (Настройки — Магазин, field shopId) and the
# secret key (Интеграция — Ключи API). Each shop credits the one direct wallet; a shop missing
# either credential drops that channel (order routing falls back to web), and no shop at all
# leaves the direct rail off. YOOKASSA_*_TEST=1 marks a TEST shop, which makes the intake refuse
# to credit a live payment against it (and a test payment against a live shop).
# Mapped in compose to BACKEND_YOOKASSA_*; Gitea TEST_/PROD_ secrets.
# Set each shop's notification URL in its cabinet (Интеграция — HTTP-уведомления) to
# ${PUBLIC_BASE_URL}/pay/yookassa/notify, events payment.succeeded + payment.canceled.
YOOKASSA_WEB_SHOP_ID=
YOOKASSA_WEB_SECRET_KEY=
YOOKASSA_WEB_TEST=
# The android (RuStore) shop — add when that channel gets its own merchant account.
YOOKASSA_ANDROID_SHOP_ID=
YOOKASSA_ANDROID_SECRET_KEY=
YOOKASSA_ANDROID_TEST=
# VAT rate code on every fiscal receipt line (54-ФЗ tag 1199): 1 = «Без НДС» (УСН/ПСН), 4 = 20%,
# 11 = 22%. A Gitea VARIABLE, not a secret, so a rate change is a variable edit + redeploy.
YOOKASSA_VAT_CODE=1
#
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
# falls the direct rail back to it if they are ever set again — see
# backend/internal/robokassa/README.md for the full variable set and how to revive the rail.
# --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
+4 -4
View File
@@ -76,10 +76,10 @@ compose binds from this directory.
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SECRET_KEY` | secret | That shop's API secret key (cabinet: **Интеграция — Ключи API**). It is the password half of the HTTP Basic credentials on every API call. A shop id without a secret key is ignored. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_TEST` | **variable** | `1` marks the credentials as a **test shop**: the intake then refuses to credit anything but a test payment (and a live shop refuses a test one). A variable, not a secret, so switching a contour between a test and a live shop is a flag flip + redeploy. |
| `BACKEND_YOOKASSA_VAT_CODE` | **variable** | VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199): `1` = «Без НДС» (УСН/ПСН), `4` = 20%, `11` = 22%. Defaults to `1`. A variable so a rate change needs no code change. **Confirm it with the accountant before the first real payment** — a wrong rate produces wrong receipts, not a failed payment, so it fails silently. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
+15 -19
View File
@@ -164,25 +164,21 @@ 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:-}
# Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds
# the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android
# shop. Each shop credits the one direct wallet; an empty login drops that channel (order
# routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}.
BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-}
BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-}
BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-}
BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-}
BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-}
BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-}
# Direct-rail (YooKassa) payment intake, one merchant shop per channel (D42): the shop id +
# secret key and a flag marking a test shop (deploy env: TEST_/PROD_BACKEND_YOOKASSA_*).
# Each shop credits the one direct wallet; a shop missing either credential drops that
# channel (order routing falls back to the web shop), and no shop at all leaves the direct
# order and notification endpoints unregistered. Notification URL (set per shop in the
# YooKassa cabinet): ${PUBLIC_BASE_URL}/pay/yookassa/notify.
BACKEND_YOOKASSA_WEB_SHOP_ID: ${YOOKASSA_WEB_SHOP_ID:-}
BACKEND_YOOKASSA_WEB_SECRET_KEY: ${YOOKASSA_WEB_SECRET_KEY:-}
BACKEND_YOOKASSA_WEB_TEST: ${YOOKASSA_WEB_TEST:-}
BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-}
BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-}
BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-}
# VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199). 1 = «Без НДС» (УСН/ПСН).
# A deploy variable, not a secret, so a rate change is a variable edit + redeploy.
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-1}
# 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
+7 -4
View File
@@ -54,10 +54,13 @@ export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN'
export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1'
export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2'
export ROBOKASSA_TEST='$ROBOKASSA_TEST'
export YOOKASSA_WEB_SHOP_ID='$YOOKASSA_WEB_SHOP_ID'
export YOOKASSA_WEB_SECRET_KEY='$YOOKASSA_WEB_SECRET_KEY'
export YOOKASSA_WEB_TEST='$YOOKASSA_WEB_TEST'
export YOOKASSA_ANDROID_SHOP_ID='$YOOKASSA_ANDROID_SHOP_ID'
export YOOKASSA_ANDROID_SECRET_KEY='$YOOKASSA_ANDROID_SECRET_KEY'
export YOOKASSA_ANDROID_TEST='$YOOKASSA_ANDROID_TEST'
export YOOKASSA_VAT_CODE='${YOOKASSA_VAT_CODE:-1}'
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
+72 -23
View File
@@ -23,7 +23,7 @@ The game earns through two orthogonal channels:
The currency is **two-tier**:
```
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
money (VK Votes / TG Stars / RUB via YooKassa) ─┐
├─► Фишки (chips) ──► values
rewarded ad view ─┘ (no-ads, hints,
tournament fee)
@@ -46,7 +46,7 @@ The chip balance is **segmented by `source`** — the platform where the chips w
|------------|----------------------------------|
| `vk` | VK Votes purchase, VK rewarded ad |
| `telegram` | TG Stars purchase |
| `direct` | Robokassa purchase (web / native)|
| `direct` | YooKassa purchase (web / native) |
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
@@ -54,17 +54,27 @@ segment reads as zero. Segmentation is **not** per-identity — see §6.
Why segmented, not one pooled balance: store rules forbid activating value that was paid
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
the VK context; Stars only inside Telegram; YooKassa-funded (`direct`) chips only outside
the stores. See §4.
**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per
**Multi-shop direct rail (D42).** The `direct` rail routes to one YooKassa **merchant shop per
channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype;
every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account
separation for accounting / receipts only: the order records its `shop` (shown in the admin report),
and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/<channel>`.
separation for accounting / receipts only: the order records its `shop` (shown in the admin report).
All shops share one notification endpoint, `/pay/yookassa/notify`; an incoming notification is
attributed to a shop by the shop id the payment reports (`recipient.account_id`), falling back to the
channel recorded on the order, and that shop's credentials perform the confirming read (§9).
Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email
anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source
regardless of the number of shops (D41).
anchor (D43), which is also where the fiscal receipt is delivered. Fiscalization (54-ФЗ under one ИП)
is a single source regardless of the number of shops (D41).
**Robokassa is retired, not deleted (D47).** It settled the `direct` rail before YooKassa. Its code,
tests and wiring stay in the tree and the rail falls back to it when no YooKassa shop is configured,
so reviving it is a credentials change rather than a code change; no deployment sets those
credentials today. `backend/internal/robokassa/README.md` records the retired variables, the cabinet
configuration and the revival steps. Ledger rows written while it was live keep
`provider = 'robokassa'` — that literal is load-bearing for the idempotency index and is never
renamed or reused.
**Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel
(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the
@@ -224,20 +234,47 @@ never the source of truth.
## 9. Payment intake
**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC)
provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I
paid" is ignored.
**Server callback only.** Chips are credited solely on a **verified** provider callback —
YooKassa notification / TG `successful_payment` / VK callback. A client "I paid" is ignored. What
"verified" means differs by rail: VK and Telegram sign their callbacks, and **YooKassa does not**
(D48) — see the confirming read below.
**Single writer.** One payments domain is the only writer of the ledger. Public webhooks
(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
(YooKassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
`successful_payment` reaches the bot, which forwards into payments. One place credits and
dedupes.
**Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack /
expected amount / origin. The `order_id` is threaded to the provider (Robokassa `InvId` / TG
`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback
matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies
the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`.
expected amount / origin. The `order_id` is threaded to the provider (YooKassa `metadata.order_id` /
TG `invoice_payload` / VK `item`). The callback matches by `order_id` (never by amount, so
equal-amount collisions cannot happen), verifies the amount, credits, marks `paid`.
**Idempotency:** dedup by `(provider, provider_payment_id)`.
**Direct rail (YooKassa).** Opening a purchase is an outbound API call: the server creates the
payment (`POST /v3/payments`, `capture: true`, redirect confirmation, the order id as both the
`Idempotence-Key` and `metadata.order_id`, plus the fiscal receipt of §12) and sends the customer to
the returned `confirmation_url`. The provider's payment id is recorded on the order straight away,
which is what later lets the order be re-checked and refunded. The browser return page
(`/pay/yookassa/return`) is cosmetic: the credit never rides a redirect.
**The notification is a hint, never evidence (D48).** YooKassa does not sign notifications, so
nothing in the body may be acted on. It only names a payment, which the server then re-reads with
`GET /v3/payments/{id}`; only that answer is trusted. Two guards ride on it: the payment's metadata
must name the order being credited, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips (nor a live payment be credited against test credentials). As defence in
depth — and to stop a forger turning each fabricated notification into an outbound call of ours — the
sender address is first checked against YooKassa's published ranges. The reply tells the provider
whether to redeliver: 200 for anything durably decided, including a duplicate and a permanent
rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
**Expiry-time reconciliation (D49).** No continuous polling. But a notification that is lost for good
would leave the money taken and the chips unowed, silently, so the existing pending-order reaper asks
the provider what became of each order that reached its expiry age carrying a payment id, and credits
the ones that were in fact paid. One request per order over its whole life.
**A declined payment is surfaced.** A YooKassa `payment.canceled` records a `failed` payment event,
so the customer is told the attempt did not go through instead of watching a balance that never
moves. An order simply abandoned records nothing — it just expires, invisibly.
**Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
@@ -266,10 +303,14 @@ an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (em
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 —
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund. On the direct
rail the console does the whole job in one click (D50): it calls YooKassa's refund API first
(`POST /v3/refunds`, the order id as the `Idempotence-Key`, carrying the refund receipt of §12) and
records the reversal only once the money has actually moved — a failed call records **nothing**, so
the ledger can never claim a refund that did not happen. The recorded refund id is the provider's
own, which keeps the ledger reconcilable against YooKassa's records. VK refunds are still handled by
support and Telegram Stars refunds issued with `refundStarPayment`, both recorded by hand afterwards.
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
@@ -356,7 +397,15 @@ spends, grants, refunds, full history — as an extension of the existing user c
Receipts are automatic **through the provider**, and differ by rail:
- **Robokassa** (direct) — self-employed НПД receipt on payment.
- **YooKassa** (direct) — a 54-ФЗ fiscal receipt through **«Чеки от ЮKassa»**: YooKassa owns the
cash register, the fiscal drive and the OFD contract, and files with the tax authority. Unlike the
retired rail's cabinet-side receipts, it registers one **only if the request carries it** (D51), so
every payment and every refund sends a `receipt`: one line (the pack title, quantity 1, the amount)
with the VAT rate code (54-ФЗ tag 1199, a deploy variable — `1` = «Без НДС» for УСН/ПСН), the
settlement subject `service` (tag 1212) and the settlement method `full_payment` (tag 1214).
Delivery is by email only, to the D36 confirmed anchor. `tax_system_code` is not sent — YooKassa
ignores it for this solution. A malformed receipt is an API error at payment creation, so it blocks
the purchase loudly rather than silently skipping the fiscal document.
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
НПД income; accepted, no receipt issued).
@@ -366,7 +415,7 @@ confirms the exact НПД scheme with a tax advisor.)
## 13. Distribution (native Android)
- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context.
- **RuStore** — an external payment gate is allowed (0%); native = clean `direct` context.
- **Google Play** — direct purchases are **hidden**; the Wallet shows a stub ("install the
RuStore build to make purchases"). Rewarded ads and spending already-earned chips still
work. Confirm Google's current in-app-currency rules before the GP release.
@@ -407,4 +456,4 @@ prod (no purchase flow existed), so legacy values are zeroed.
- **value / benefit** — what chips buy (no-ads, hints, tournament fee).
- **gate** — the one-directional store-compliance rule (§4).
- **ledger** — the append-only record of all money/value operations.
- **rail** — a payment provider (Robokassa / VK Votes / TG Stars).
- **rail** — a payment provider (YooKassa / VK Votes / TG Stars).
+49 -1
View File
@@ -281,6 +281,46 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
проверяются отдельно в CreateOrder.
- **D47. Direct-рельс переезжает с Robokassa на ЮKassa; Robokassa консервируется, а не удаляется.**
Меняется merchant-отношение, не модель кошельков: сегмент `direct`, стенка трат, origin бенефитов,
мультимагазинность по каналу (D42) и `shop` в заказе (D44) — без изменений. Код Robokassa, её тесты
и проводка остаются в дереве, а direct-рельс **откатывается на неё**, если ни один магазин ЮKassa не
настроен, — значит возврат к Robokassa это смена кредов, а не правка кода. Переменные Robokassa
убраны из CI/compose/деплоя и записаны в `backend/internal/robokassa/README.md` вместе с
настройками кабинета и порядком возврата. Строки журнала с `provider = 'robokassa'` не трогаем:
литерал несущий для индекса идемпотентности. Telegram **не трогаем** — там реальными деньгами за
цифровые товары платить нельзя, рельс остаётся на Stars; префиксы переменных заводим на магазин
сразу, чтобы будущий магазин (например, android/RuStore) добавлялся аддитивно.
- **D48. Уведомления ЮKassa не подписаны → подтверждающее чтение обязательно.** В отличие от
Robokassa (подпись Password2) и VK (подпись), ЮKassa **не подписывает** вебхуки. Поэтому тело
уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом
`GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные
платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина
(платёж тестового магазина не начислит настоящие Фишки, и наоборот); адрес отправителя сверяется с
опубликованными диапазонами ЮKassa — эшелонированная защита, которая не даёт превратить каждое
фальшивое уведомление в наш исходящий запрос. Ответ провайдеру: 200 на всё окончательно решённое
(включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа).
- **D49. Сверка — одна проверка на истечении заказа, без постоянного опроса.** «Или» в документации
ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
жнец просроченных заказов перед списанием в `expired` спрашивает провайдера о судьбе каждого заказа,
дожившего до срока с идентификатором платежа, и начисляет реально оплаченные. Один запрос на заказ
за всю его жизнь; отдельный воркер не заводим.
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
может заявить о возврате, которого не было. Записывается собственный refund-id провайдера (сверка с
данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
- **D51. Фискализация — «Чеки от ЮKassa», чек отправляем из кода.** Ревизия D41: у ЮKassa нет
кабинетного «обобщённого чека», как у Robokassa, — чек регистрируется **только если запрос его
несёт**, поэтому itemized-код, от которого отказались в D41, возвращается в объём. Каждый платёж и
возврат шлют `receipt`: одна позиция (название пакета, количество 1, сумма), код ставки НДС
(тег 1199), признак предмета расчёта `service` (тег 1212), признак способа расчёта `full_payment`
(тег 1214); доставка только на email — на подтверждённый якорь D36. Код ставки НДС — **переменная
деплоя** (дефолт `1` = «Без НДС» для УСН/ПСН): это единственный реквизит, который реально меняется
(с 1 января 2026 ставки выросли, появились коды 11/12), и менять его без релиза нужно; признаки
предмета и способа расчёта — константы в коде. `tax_system_code` не шлём: для «Чеков от ЮKassa»
провайдер его игнорирует.
## Заметки к оформлению документов
@@ -296,7 +336,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
feedback-память `prefer-interview-mode` после plan mode.
## Все развилки закрыты (D1-D46)
## Все развилки закрыты (D1-D51)
Интервью завершено. Дальше — оформление документов и реализация по релизам.
@@ -307,6 +347,14 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
**Дополнение 2026-07-28 (интервью с владельцем).** Direct-рельс переезжает на **ЮKassa**; добавлены
D47-D51 — консервация Robokassa с откатом по кредам, подтверждающее чтение вместо подписи уведомления,
сверка на истечении заказа, возвраты через API и фискализация через «Чеки от ЮKassa» с отправкой
`receipt` из кода (ревизия D41, этап E12 в `PLAN.md`). Telegram из объёма исключён: реальными
деньгами за цифровые товары в Mini App платить нельзя, рельс остаётся на Stars. Отдельно уточнено:
у ЮKassa **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами),
поэтому весь путь проверяется на тестовом контуре без реальных денег.
## План внедрения (черновик PLAN.md — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
+72 -23
View File
@@ -23,7 +23,7 @@
Валюта **двухуровневая**:
```
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
деньги (Голоса VK / Stars TG / рубли через ЮKassa) ─┐
├─► Фишки ──► ценности
просмотр ролика за награду ─┘ (без рекламы,
подсказки,
@@ -46,7 +46,7 @@
|------------|------------------------------------|
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
| `telegram` | Покупка за Stars TG |
| `direct` | Покупка через Robokassa (web / native) |
| `direct` | Покупка через ЮKassa (web / native) |
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
@@ -54,17 +54,27 @@
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (`direct`) — только вне
сторов. См. §4.
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
Robokassa **на канал**`web` и `android` (RuStore), позже `ios` — по трастовому подтипу
ЮKassa **на канал**`web` и `android` (RuStore), позже `ios` — по трастовому подтипу
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
одним ИП) — один источник независимо от числа магазинов (D41).
админ-отчёте). Точка приёма уведомлений у всех магазинов одна — `/pay/yookassa/notify`; входящее
уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж
(`recipient.account_id`), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9)
выполняется кредами именно этого магазина. В standalone-приложениях (Android/iOS) вход только по
email, поэтому у direct-покупки всегда есть email-якорь D36 (D43) — он же адрес доставки чека.
Фискализация (54-ФЗ под одним ИП) — один источник независимо от числа магазинов (D41).
**Robokassa законсервирована, но не удалена (D47).** До ЮKassa она обслуживала рельс `direct`. Код,
тесты и проводка остаются в дереве, и рельс откатывается на неё, если ни один магазин ЮKassa не
настроен, — поэтому возврат к ней это смена кредов, а не правка кода; сегодня эти креды не задаёт ни
один контур. `backend/internal/robokassa/README.md` хранит список выведенных переменных, настройки
кабинета и порядок возврата рельса. Строки журнала, записанные при её жизни, сохраняют
`provider = 'robokassa'` — этот литерал несущий для индекса идемпотентности, его не переименовывают и
не переиспользуют.
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
@@ -217,21 +227,48 @@ durable).
## 9. Приём платежей
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK
callback. Клиентское «я оплатил» игнорируется.
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** серверному
колбэку — уведомление ЮKassa / TG `successful_payment` / VK callback. Клиентское «я оплатил»
игнорируется. Что значит «проверенный», зависит от рельса: VK и Telegram подписывают свои колбэки,
а **ЮKassa — нет** (D48), см. подтверждающее чтение ниже.
**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
Публичные вебхуки (ЮKassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
Одно место начисляет и защищает от повторов.
**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG
`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по
`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер,
provider_payment_id)`.
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (ЮKassa `metadata.order_id` / TG
`invoice_payload` / VK `item`). Колбэк матчится по `order_id` (никогда по сумме, поэтому коллизии
одинаковых сумм невозможны), сверяет сумму, начисляет, помечает `paid`. **Защита от повторов:**
дедуп по `(провайдер, provider_payment_id)`.
**Direct-рельс (ЮKassa).** Открытие покупки — исходящий вызов API: сервер создаёт платёж
(`POST /v3/payments`, `capture: true`, подтверждение через redirect, идентификатор заказа и как
`Idempotence-Key`, и как `metadata.order_id`, плюс фискальный чек из §12) и отправляет покупателя на
полученный `confirmation_url`. Идентификатор платежа сразу записывается в заказ — именно он позволяет
потом перепроверить заказ и оформить возврат. Страница возврата браузера (`/pay/yookassa/return`)
косметическая: начисление никогда не едет на редиректе.
**Уведомление — подсказка, а не доказательство (D48).** ЮKassa не подписывает уведомления, поэтому
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки:
метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина,
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). В качестве
эшелонированной защиты — и чтобы подделыватель не превращал каждое фальшивое уведомление в наш
исходящий запрос — адрес отправителя сперва сверяется с опубликованными диапазонами ЮKassa. Ответ
сообщает провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и
неустранимый отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
**Сверка на истечении заказа (D49).** Постоянного опроса нет. Но безвозвратно потерянное уведомление
оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому уже существующий жнец
просроченных заказов спрашивает у провайдера судьбу каждого заказа, который дожил до своего срока с
идентификатором платежа, и начисляет те, что на самом деле оплачены. Один запрос на заказ за всю его
жизнь.
**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
брошенный заказ не пишет ничего — он молча истекает.
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
@@ -261,9 +298,13 @@ at-least-once + идемпотентный приём (дедуп по `telegram
бота).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат. На direct-рельсе консоль делает
всю работу одним нажатием (D50): сперва вызывает refund-API ЮKassa (`POST /v3/refunds`,
`Idempotence-Key` — идентификатор заказа, с чеком возврата из §12) и записывает реверс только после
того, как деньги действительно ушли, — неудачный вызов не пишет **ничего**, поэтому журнал не может
заявить о возврате, которого не было. Записывается собственный refund-id провайдера: по нему журнал
сверяется с данными ЮKassa. Возвраты VK по-прежнему через поддержку, TG Stars — вызовом
`refundStarPayment`, оба фиксируются руками после факта. Все сходятся на одном движке — метод `Refund`
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
@@ -349,7 +390,15 @@ in-process кэш сегментов и бенефитов по ключу-ак
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
- **Robokassa** (direct) — чек НПД самозанятого при оплате.
- **ЮKassa** (direct) — фискальный чек по 54-ФЗ через **«Чеки от ЮKassa»**: касса, фискальный
накопитель, договор с ОФД и отправка в налоговую — на стороне ЮKassa. В отличие от кабинетных чеков
прежнего рельса, чек регистрируется **только если запрос его несёт** (D51), поэтому каждый платёж и
каждый возврат отправляют `receipt`: одна позиция (название пакета, количество 1, сумма) с кодом
ставки НДС (тег 54-ФЗ 1199, переменная деплоя — `1` = «Без НДС» для УСН/ПСН), признаком предмета
расчёта `service` (тег 1212) и признаком способа расчёта `full_payment` (тег 1214). Доставка только
на email — на подтверждённый якорь D36. `tax_system_code` не передаём: для этого решения ЮKassa его
игнорирует. Некорректный чек — ошибка API при создании платежа, то есть покупка ломается громко, а
не тихо остаётся без фискального документа.
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
доход НПД; принимаем, чек не формируем).
@@ -359,7 +408,7 @@ in-process кэш сегментов и бенефитов по ключу-ак
## 13. Дистрибуция (native Android)
- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`.
- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`.
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
@@ -403,4 +452,4 @@ legacy-значения обнуляются.
взнос).
- **гейт** — одностороннее правило комплаенса сторов (§4).
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG).
- **платёжный канал / рельса** — платёжный провайдер (ЮKassa / Голоса VK / Stars TG).
+10
View File
@@ -514,6 +514,16 @@ func (c *Client) RobokassaResult(ctx context.Context, channel string, params map
return out.Response, err
}
// YooKassaNotify forwards a YooKassa webhook body verbatim to the backend intake (the single writer,
// which re-reads the payment from the provider before acting on it — YooKassa does not sign
// notifications). The backend answers 200 for anything durably decided and 5xx only for a transient
// failure, so a nil error means the provider may stop redelivering.
// clientIP is the true sender address, which only the gateway sees; the backend checks it against
// YooKassa's published sender ranges.
func (c *Client) YooKassaNotify(ctx context.Context, clientIP string, body json.RawMessage) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/payments/yookassa/notify", "", clientIP, body, nil)
}
// 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
+54 -10
View File
@@ -273,15 +273,20 @@ 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. The
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change.
// Direct-rail return + callback routes: the server notification (the single crediting signal,
// proxied to the backend intake) and the browser return page. Caddy's /pay/* matcher already
// forwards every path here, so a new route needs no Caddyfile change.
mux.Handle("/pay/yookassa/notify", s.yookassaNotifyHandler())
mux.Handle("/pay/yookassa/return", s.paymentReturnHandler("Оплата принята."))
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
// The retired Robokassa rail. Its routes stay registered — the backend simply has no shop to
// verify against — so reviving the rail is a credentials change, not a code change, and the
// edge probe keeps proving /pay/* reaches the gateway. The per-shop callback rides a channel
// suffix (/pay/robokassa/result/<channel>); the bare path is the legacy web shop.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
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("Оплата не завершена."))
mux.Handle("/pay/robokassa/success", s.paymentReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.paymentReturnHandler("Оплата не завершена."))
// 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
@@ -691,6 +696,45 @@ func (s *Server) robokassaResultHandler() http.Handler {
})
}
// maxNotifyBytes caps the provider notification body the gateway will read and forward.
const maxNotifyBytes = 1 << 20
// yookassaNotifyHandler proxies a YooKassa webhook to the backend intake (the single writer). It
// rate-limits per IP and forwards the raw JSON body together with the true sender address, which
// only this hop sees; the backend checks that address against YooKassa's published sender ranges and
// then re-reads the payment from the provider, because YooKassa does not sign notifications.
//
// The backend answers 200 for anything durably decided and 5xx for a transient failure, and that is
// relayed as-is: YooKassa retries a notification for 24 hours until it gets a 200.
func (s *Server) yookassaNotifyHandler() 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, "yookassa-notify")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxNotifyBytes))
if err != nil || !json.Valid(body) {
// Rejected here rather than proxied: a body that is not JSON cannot become valid on a
// redelivery, and forwarding it would only make the backend answer 5xx forever.
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := s.backend.YooKassaNotify(r.Context(), ip, body); err != nil {
s.log.Warn("yookassa notify 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("ok"))
})
}
// 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
@@ -731,12 +775,12 @@ func (s *Server) vkCallbackHandler() http.Handler {
})
}
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
// paymentReturnHandler serves a minimal self-closing page for a direct-rail provider's 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
// cannot self-close. The credit rides the server notification, 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 {
func (s *Server) paymentReturnHandler(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">
+1 -1
View File
@@ -67,7 +67,7 @@ test('wallet: buying a chip pack opens the provider payment page', async ({ page
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
await expect
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
.toContain('robokassa');
.toContain('yookassa');
});
test('wallet: the tab sits between Friends and About', async ({ page }) => {
+1 -1
View File
@@ -239,7 +239,7 @@ export class MockGateway implements GatewayClient {
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 };
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/yookassa?order=' + productId };
}
async walletReward(_nonce: string): Promise<Wallet> {