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.
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:
- a configured YooKassa shop for the platform channel → YooKassa;
- otherwise a configured Robokassa shop → this rail;
- 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/resultis the legacy path and is verified as the web shop. Method: POST. Each shop's callback is verified only by its own Password2 (Shops.Verifierdoes 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
InvIdis numeric, so the order rides the custom-parameter channel asShp_orderandInvIdis always sent as0. 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.