release: YooKassa direct rail (v1.26.0) #294

Merged
developer merged 7 commits from development into master 2026-07-28 11:06:39 +00:00
Owner

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 answers rail_unavailable and 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:

Entry Kind Value
PROD_BACKEND_YOOKASSA_WEB_SHOP_ID secret the live shop's shopId (Настройки — Магазин)
PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY secret that shop's API key (Интеграция — Ключи API)
PROD_BACKEND_YOOKASSA_WEB_TEST variable leave empty — this is a live shop
PROD_BACKEND_YOOKASSA_VAT_CODE variable leave empty — receipts are deliberately off (НПД)

And in that shop's cabinet, Интеграция — HTTP-уведомленияhttps://erudit-game.ru/pay/yookassa/notify, events payment.succeeded, payment.canceled, refund.succeeded.

The old PROD_BACKEND_ROBOKASSA_* secrets are no longer read and can be deleted.

What ships

  • The direct rail settles through YooKassa. Opening a purchase is now an outbound API call; 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.
  • Notifications are unsigned, so the body is never evidence — every one is re-read with GET /v3/payments/{id} and only that answer is acted on, with two guards: 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.
  • A safety net for a lost notification: the pending-order reaper asks the provider about any pending order older than a minute that carries a payment id. Worst case for a failed notification is minutes, not the order lifetime.
  • Refunds move money through the API and record only on a succeeded refund; a failure records nothing. A refund made in the merchant cabinet is picked up by refund.succeeded and reversed once (a partial one records nothing and is logged for a human — the engine is full-refund-only).
  • No fiscal receipt is sent (owner is ИП on НПД, outside 54-ФЗ; reporting goes to «Мой налог»). The receipt code is kept switchable behind BACKEND_YOOKASSA_VAT_CODE for the day the regime changes.
  • Robokassa is retired, not deleted — the rail falls back to it if its credentials ever return, so reviving it is a credentials change. backend/internal/robokassa/README.md records the retired variables and cabinet setup.
  • New /_gm/ledger admin 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 line yookassa reconcile: credited an order no notification confirmed appearing at all means the notification path is not reaching us — the money is still credited, but the cabinet's notification URL wants checking.

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 answers `rail_unavailable` and 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: | Entry | Kind | Value | | --- | --- | --- | | `PROD_BACKEND_YOOKASSA_WEB_SHOP_ID` | secret | the live shop's `shopId` (Настройки — Магазин) | | `PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY` | secret | that shop's API key (Интеграция — Ключи API) | | `PROD_BACKEND_YOOKASSA_WEB_TEST` | variable | **leave empty** — this is a live shop | | `PROD_BACKEND_YOOKASSA_VAT_CODE` | variable | **leave empty** — receipts are deliberately off (НПД) | And in that shop's cabinet, **Интеграция — HTTP-уведомления** → `https://erudit-game.ru/pay/yookassa/notify`, events `payment.succeeded`, `payment.canceled`, `refund.succeeded`. The old `PROD_BACKEND_ROBOKASSA_*` secrets are no longer read and can be deleted. ## What ships - **The direct rail settles through YooKassa.** Opening a purchase is now an outbound API call; 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. - **Notifications are unsigned, so the body is never evidence** — every one is re-read with `GET /v3/payments/{id}` and only that answer is acted on, with two guards: 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. - **A safety net for a lost notification**: the pending-order reaper asks the provider about any pending order older than a minute that carries a payment id. Worst case for a failed notification is minutes, not the order lifetime. - **Refunds move money through the API** and record only on a `succeeded` refund; a failure records nothing. A refund made in the merchant cabinet is picked up by `refund.succeeded` and reversed once (a partial one records nothing and is logged for a human — the engine is full-refund-only). - **No fiscal receipt is sent** (owner is ИП on НПД, outside 54-ФЗ; reporting goes to «Мой налог»). The receipt code is kept switchable behind `BACKEND_YOOKASSA_VAT_CODE` for the day the regime changes. - **Robokassa is retired, not deleted** — the rail falls back to it if its credentials ever return, so reviving it is a credentials change. `backend/internal/robokassa/README.md` records the retired variables and cabinet setup. - **New `/_gm/ledger` admin 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 line `yookassa reconcile: credited an order no notification confirmed` appearing at all means the notification path is not reaching us — the money is still credited, but the cabinet's notification URL wants checking.
developer added 7 commits 2026-07-28 11:02:22 +00:00
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
92ba527575
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.
feat(payments): reverse refunds issued outside the console
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
395a307eca
Two holes on the refund path, both found by asking what happens when a refund
does not come from our own `/_gm` button.

The merchant cabinet is a second entry point. An operator can refund there, and
such a refund never passes through our API — so the money went back while the
chips stayed credited, silently. Handle `refund.succeeded`: the refund is
re-read from the API (the notification body is no more evidence here than it is
for a payment), bound back to its order through the payment id recorded when
the payment was minted, and reversed through the same engine. It is 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 — it revokes exactly what the
pack funded and rejects any other amount — so a partial refund is recorded as
nothing at all and logged loudly for an operator. There is no non-arbitrary way
to decide how many chips a part-refund costs, and guessing would be worse than
asking a human.

Second hole: a refund can still be canceled while pending, and the ledger is
append-only. Recording on any non-empty refund id therefore risked revoking a
customer's chips for money that stayed with us, with no way to take the row
back. The console now records only a `succeeded` refund and tells the operator
to press again otherwise — the idempotency key returns the same refund rather
than paying twice.

Tests: unit (GetRefund, the refund notification envelope, a non-final status
surfaced to the caller); integration (a cabinet refund is reversed once and a
redelivery is a no-op, the event after a console refund changes nothing, a
partial refund records nothing, an unconfirmed refund reverses nothing, a
pending refund records nothing until it settles and then does).

The suite shares one database and the ledger dedupes refunds globally, so the
fake provider now mints a refund id per payment — a constant id made one test's
refund look like another's duplicate.

Decisions D50 (amended) and D52; the notification subscription list in the
deploy docs gains refund.succeeded.
feat(payments): send no fiscal receipt; keep the code switchable
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
12e616ceae
The merchant accepts payments as a sole proprietor on НПД, which is outside
54-ФЗ: there is no online cash register, YooKassa does not serve receipts for
that regime at all (checked with their support), and each operation is reported
by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent
with a payment or a refund.

This also removes a failure class rather than just code: a malformed receipt
was an API error at payment creation, which broke the purchase outright.

The fiscal code is kept dormant rather than deleted. All of it now sits behind
one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends
nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return
is foreseeable, which is why the switch exists: НПД carries an annual income
ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is
a deploy-variable edit instead of writing the integration again.

The D36 email anchor still gates a direct purchase. It had two justifications —
a recovery anchor and the receipt address — and only the second is gone; without
an email a paying customer who loses the account loses the chips with it. What
was missing is that the rule was enforced but never communicated: the wallet
showed the packs to a player signed in through VK or 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 instead, linking to the profile; spending
already-earned chips is untouched. The predicate is a pure function so it is
covered by the node-env unit tests rather than needing a browser.

Tests: the receipt-off default is pinned by an integration test asserting a
purchase carries no receipt, and the dormant path by one that switches a VAT
code on and checks the receipt reappears with the right fiscal attributes;
plus unit coverage for the enable predicate and the wallet's email rule.

A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is
now 40 bytes under its 31 KB gzip budget.

Decision D51 revised.
fix(payments): drop the notification sender-IP gate; re-check on its own cadence
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
4cac09c9f3
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.
fix(payments): a console refund that its own notification beat is not a repeat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 2m28s
e3961fe4ca
Refunding from /_gm reported "Already refunded" for a refund that had just
succeeded, which reads as "you clicked twice".

The two recording paths race. YooKassa fires refund.succeeded the moment
POST /v3/refunds returns, so the notification handler often writes the reversal
before the console's own write lands. Both name the same refund id, so the
ledger's idempotency index rejects the second — which is the mechanism working
exactly as intended: on the contour the money moved once, one refund row was
written, the balance is right and no abuse flag was raised. Only the message
was wrong about why.

The console now reports success on that path, and the notification's log line
no longer claims the refund was "issued outside the console" when it may well
have come from it.

Covered by an integration test that lands the notification first and then
refunds from the console, asserting the operator is told it succeeded and that
exactly one refund row exists.
feat(admin): an all-accounts ledger section with filters and totals
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
3b744b7d2f
There was no way to see the money as a whole: the ledger was only ever rendered
inside one account's card, so "what came in last month" meant exporting the
entire CSV and reading it elsewhere.

/_gm/ledger lists every operation, newest first, filtered by date range
(defaulting to the last 30 days), by wallet, by rail, by kind and by account.
Wallet and rail are deliberately separate axes because they answer different
questions — "what happened on VK" matches the funded segment or the benefit
origin, while "what came through YooKassa" matches the settling provider, and a
chip spend has no rail at all, so it drops out of that filter by construction.

Above the table sit the totals for everything the filter matches, not merely the
page on screen, which is the point of showing them. Money is listed per currency
because the rails settle in roubles, Votes and Stars and one sum across them
would mean nothing. Row amounts come from the operation snapshot, since the
ledger's own columns count chips rather than money.

Paging, the CSV export and a refund's way back are all built from the same
encoded filter, so none of them can quietly show a different slice than the
screen. The export moves here from the user card and gains the filter along with
money columns; it is capped, because an append-only ledger grows forever and an
unbounded export would eventually time out.

The refund action moves onto the funded rows here — an operator can now find a
payment by filter without knowing whose it is first — and returns to the same
filtered view. The destination travels with the form but is only honoured when
it is a console path, so a crafted form cannot turn it into an open redirect.

The user card keeps the account's standing rather than its history: balances,
benefits, the risk flag and a lifetime summary (money paid and refunded per
currency, chips credited and spent), plus a link into the ledger scoped to that
account. Operations are rendered in one place, with filters and paging, instead
of two.

Tests: the filter axes, paging that neither repeats nor drops a row, totals that
describe the range rather than the page, money recovered from a snapshot and
absent on a spend, and the console page, the export and the card hand-off. The
existing finance-panel test now asserts the summary and that the card no longer
re-renders the rows.
Merge pull request 'feat(payments): settle the direct rail through YooKassa' (#293) from feature/yookassa-direct-rail into development
CI / changes (push) Successful in 3s
CI / integration (push) Successful in 23s
CI / gate (push) Successful in 0s
CI / unit (push) Successful in 11s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m47s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
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) Has been skipped
c13f5cdb1e
owner approved these changes 2026-07-28 11:04:09 +00:00
developer merged commit 9fa11a400d into master 2026-07-28 11:06:39 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#294