feat(payments): settle the direct rail through YooKassa
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
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.
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user