feat(payments): settle the direct rail through YooKassa #293
Reference in New Issue
Block a user
Delete Branch "feature/yookassa-direct-rail"
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?
Replaces Robokassa with YooKassa as the RUB
direct-rail provider. The wallet model is untouched — onedirectsegment, the same spend wall, the same per-channel merchant shops (D42) andshopon the order (D44). Decisions D47-D52 (revising D41/D51) and stage E12 are baked in.Three commits, each answering a question that came up while building it.
1. The rail
POST /v3/payments->confirmation_urlPOST /v3/refundsbackend/internal/yookassa- API client (CreatePayment/GetPayment/CreateRefund/GetRefund, HTTP Basic,Idempotence-Key), per-channelShopswithByShopID, notification envelope + sender-IP allowlist, receipt builder. Same shape as therobokassapackage: no DB, no payments-domain coupling.Idempotence-Keyandmetadata.order_id, so a retried create cannot mint a second payment and a notification always resolves to its order. The provider payment id is recorded on the order (no migration - the column existed).GET /v3/payments/{id}; only that answer is acted on. 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. The sender address is checked against YooKassa's published ranges first - that is what stops a forger turning each fabricated notification into an outbound call of ours. 200 = durably decided, 5xx = redeliver.payment.canceledrecords afailedevent, delivering what PAYMENTS.md section 9 already specified..env.example/write-prod-env.sh/ the three workflows and recorded inbackend/internal/robokassa/README.md. Ledger rows keepprovider = 'robokassa'- load-bearing for the idempotency index.2. Refunds issued outside the console (D52)
The merchant cabinet is a second entry point: an operator can refund there, and that never passes through our API - the money went back while the chips stayed credited, silently.
refund.succeededis now handled, re-read from the API and bound to its order through the recorded payment id, idempotent on(provider, refund id)so the event for a refund the console already recorded reverses nothing twice.The reversal engine is full-refund-only by design, so a partial refund records nothing and is logged for an operator - there is no non-arbitrary chip cost for a part-refund.
Second hole closed: a refund can still be canceled while
pendingand the ledger is append-only, so the console now records only asucceededrefund and asks the operator to press again otherwise (the idempotency key returns the same refund rather than paying twice).3. No fiscal receipt (D51 revised)
The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported to «Мой налог» instead. So no
receiptis sent. This removes a failure class too - a malformed receipt was an API error at payment creation, which broke the purchase.The fiscal code is kept dormant, not deleted, behind one switch:
BACKEND_YOOKASSA_VAT_CODEunset (the default) sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. НПД carries an annual income ceiling, so the return is foreseeable - and is then a variable edit, not a rewrite.Bonus fix. The D36 email anchor still gates a direct purchase (only its receipt-address justification went; the recovery anchor stands alone). But the rule was enforced and never communicated: the wallet showed the packs to a player signed in through VK/Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab, linking to the profile. Spending already-earned chips is untouched.
Contour-safe: no migration, no wire change, no
PRERELEASE.mdstep.Tests
backend/internal/yookassa(request building, receipt incl. rune-safe truncation,Idempotence-Key, error retryability, non-final refund status, shop registry +ByShopID, the IP allowlist across v4/v6 bounds and IPv4-mapped);uivitest for the wallet email rule.payments_yookassa_test.go, 18 tests: credit-once + redelivery; a forged notification credits nothing when the confirming read disagrees; a foreign sender is refused; a live payment on a test shop is refused; a decline recordsfailed; the reconcile sweep credits a lost notification and leaves an unpaid order alone; the refund moves money then records - and records nothing when the provider fails or is still pending; a cabinet refund is reversed once and its redelivery is a no-op; a partial refund changes nothing; a purchase carries no receipt, and the dormant receipt path still works when a VAT code is set; the order path mints a payment; D36 still gates the rail.All green locally and in CI:
go build/vet/gofmt, the full unit suite, the full integration suite,svelte-check(0 errors), vitest (677 passed),vite build+bundle-size.mjs.Verified live on the contour against the YooKassa test shop: the edge route reaches the gateway, the backend route is registered, an unrecognised sender is refused (403), and the deployed backend runs with receipts off.
Owner actions before this is useful
<contour base URL>/pay/yookassa/notify, eventspayment.succeeded,payment.canceledandrefund.succeeded. (Credentials are already in Gitea and live on the contour.)PROD_BACKEND_YOOKASSA_WEB_*; same notification URL againsthttps://erudit-game.ru/pay/yookassa/notify.*_BACKEND_YOOKASSA_VAT_CODEunset - receipts are deliberately off.Notes
shared (svelte + i18n)bundle chunk is now 40 bytes under its 31 KB gzip budget. The gate passes, but the next localized string anywhere will break it; raisingBUDGETis an owner call and belongs in its own change.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.