release: YooKassa direct rail (v1.26.0) #294
Reference in New Issue
Block a user
Delete Branch "development"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Promotes the YooKassa direct rail to production. Seven commits, all payments; no schema migration, so image rollback stays DB-safe and no maintenance window is needed.
Suggested tag: v1.26.0 (previous release v1.25.1).
STOP — read before dispatching the prod deploy
The Robokassa environment is gone from
prod-deploy.yaml. After this deploy the direct (RUB) rail resolves to YooKassa, and Robokassa has no credentials to fall back to. So unless the YooKassa production secrets exist, card payments on the web stop working — the wallet answersrail_unavailableand no purchase can start. Nothing else is affected (VK Votes, Telegram Stars, spending existing chips, the whole game) and nothing crashes: the backend boots cleanly with the rail unconfigured.Set these in Gitea before dispatching:
PROD_BACKEND_YOOKASSA_WEB_SHOP_IDshopId(Настройки — Магазин)PROD_BACKEND_YOOKASSA_WEB_SECRET_KEYPROD_BACKEND_YOOKASSA_WEB_TESTPROD_BACKEND_YOOKASSA_VAT_CODEAnd in that shop's cabinet, Интеграция — HTTP-уведомления →
https://erudit-game.ru/pay/yookassa/notify, eventspayment.succeeded,payment.canceled,refund.succeeded.The old
PROD_BACKEND_ROBOKASSA_*secrets are no longer read and can be deleted.What ships
Idempotence-Keyandmetadata.order_id, so a retried create cannot mint a second payment and a notification always resolves to its order.GET /v3/payments/{id}and only that answer is acted on, with two guards: the payment's metadata must name the order, and itstestflag must match the shop's, so a test-shop payment can never credit real chips.succeededrefund; a failure records nothing. A refund made in the merchant cabinet is picked up byrefund.succeededand reversed once (a partial one records nothing and is logged for a human — the engine is full-refund-only).BACKEND_YOOKASSA_VAT_CODEfor the day the regime changes.backend/internal/robokassa/README.mdrecords the retired variables and cabinet setup./_gm/ledgeradmin section: all accounts, filtered by date range (default last 30 days), wallet, rail, kind and account, with totals for the whole filtered range and the CSV export moved here (now filter-aware). The user card became a summary that links into it; the refund action moved onto the ledger's funded rows.Verification
Full local suite plus CI green on every commit and after the merge (
#1007). Beyond tests, the rail was exercised with real money on the contour against a YooKassa test shop: a purchase credited through the notification path in 24 seconds, a second one credited through the reconcile safety net, and a refund returned the money and wrote one ledger row under the provider's own refund id. Balances reconciled exactly.That live testing is also what found four defects the 21 integration tests did not — the sender-IP check killed every notification on a contour behind a tunnel, the re-check threshold was keyed off the order TTL, and the console reported a successful refund as a repeat click. All fixed in this branch.
After the deploy
One small real payment end-to-end, then a refund from
/_gm. The backend log lineyookassa reconcile: credited an order no notification confirmedappearing at all means the notification path is not reaching us — the money is still credited, but the cabinet's notification URL wants checking.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.A real test payment on the contour exposed both problems at once. YooKassa delivered the notification five times; all five were rejected because the backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and cannot observe real client addresses, the same reason the IP bans in this repository are prod-only. The chips were not lost (the reconcile sweep would have credited them), but the primary path was dead and the customer was left watching an unchanged balance. The address check is removed rather than made conditional. It never was the security boundary — the confirming GET /v3/payments/{id} is — and the one thing it bought is already bought earlier and far more tightly: the order is resolved from the notification's metadata *before* any provider call, so a notification naming no known order costs a single indexed read and stops there. Guessing a live order id means guessing a uuid. Against that, an address check adds nothing and breaks every deployment that cannot see real client addresses, while turning any future change to YooKassa's published ranges into a silent degradation. The second problem was mine. The reconcile threshold was keyed off the order lifetime, so a lost notification cost the customer the full 30-minute TTL before the chips landed. Those are different questions: the lifetime governs how long a customer may take to pay, the re-check governs how soon we notice a lost callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every reaper tick. The bound D49 was chosen for survives: the calls one order can cause are still its lifetime divided by the sweep interval, a handful, not an open-ended poll. Worst case for a failed notification drops from ~30 minutes to ~5; an order the customer is still paying for is left alone. Tests: the foreign-sender test is replaced by the two properties that now carry the load — a notification naming an unknown order makes no provider call at all, and a genuine notification is honoured whatever address it appears to come from. Plus one pinning that a seconds-old order is not polled. The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the script header: every user-visible string lands in that chunk and it had been sitting 40 bytes under the cap. Decisions D48 and D49 revised.