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
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.
86 lines
5.1 KiB
Markdown
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.
|