Files
scrabble-game/backend/internal/robokassa/README.md
T
Ilia Denisov 92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
feat(payments): settle the direct rail through YooKassa
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).

The two providers are not shaped alike, and that drives the change:

- Opening a purchase is now an outbound API call (`POST /v3/payments`,
  single-stage capture, redirect confirmation). The order id is both the
  `Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
  second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
  names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
  answer is acted on. Two guards ride on it — the payment's metadata must name
  the order, and its `test` flag must match the shop's, so a test-shop payment
  can never credit real chips. The sender address is checked against YooKassa's
  published ranges first, which stops a forger turning each fabricated
  notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
  silently. The existing pending-order reaper now asks the provider about each
  order that reached its expiry age carrying a payment id, and credits the ones
  really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
  surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
  anything; a failed call records nothing, so the ledger cannot claim a refund
  that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
  only if the request carries it, so every payment and refund now sends an
  itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
  variable; the settlement subject and method are constants.

Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.

No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.

Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
2026-07-28 08:51:31 +02:00

5.1 KiB

Robokassa — the retired direct rail

Robokassa used to settle the direct (RUB) rail. It was replaced by 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 URLhttps://<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 URLhttps://<host>/pay/robokassa/success.
  • Fail URLhttps://<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.