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

86 lines
5.1 KiB
Markdown

# 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.