feat(payments): settle the direct rail through YooKassa #293

Merged
developer merged 6 commits from feature/yookassa-direct-rail into development 2026-07-28 10:49:47 +00:00
Owner

Replaces 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). 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

Robokassa YooKassa
Launch a payment offline signed URL, no network call outbound POST /v3/payments -> confirmation_url
Callback authenticity SHA-256 over Password2 none - webhooks are unsigned
Refund manual, in the cabinet POST /v3/refunds
  • New backend/internal/yookassa - API client (CreatePayment/GetPayment/CreateRefund/GetRefund, HTTP Basic, Idempotence-Key), per-channel Shops with ByShopID, notification envelope + sender-IP allowlist, receipt builder. Same shape as the robokassa package: no DB, no payments-domain coupling.
  • Order - 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. The provider payment id is recorded on the order (no migration - the column existed).
  • The notification is a hint, never evidence - the body only names a payment, which is re-read with GET /v3/payments/{id}; only that answer is acted on. 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. 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.
  • Expiry-time reconciliation - no polling, but a permanently lost notification would leave the money taken and the chips unowed, silently. The pending-order reaper now asks the provider about each order that reached its expiry age carrying a payment id. One request per order over its whole life.
  • A decline is surfaced - payment.canceled records a failed event, delivering what PAYMENTS.md section 9 already specified.
  • Robokassa 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 / the three workflows and recorded in backend/internal/robokassa/README.md. Ledger rows keep provider = '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.succeeded is 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 pending and the ledger is append-only, so the console now records only a succeeded refund 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 receipt is 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_CODE unset (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.md step.

Tests

  • unit - 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); ui vitest for the wallet email rule.
  • integration - 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 records failed; 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

  1. In the test shop cabinet set Интеграция - HTTP-уведомления to <contour base URL>/pay/yookassa/notify, events payment.succeeded, payment.canceled and refund.succeeded. (Credentials are already in Gitea and live on the contour.)
  2. Real web shop -> PROD_BACKEND_YOOKASSA_WEB_*; same notification URL against https://erudit-game.ru/pay/yookassa/notify.
  3. Leave *_BACKEND_YOOKASSA_VAT_CODE unset - receipts are deliberately off.

Notes

  • The 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; raising BUDGET is an owner call and belongs in its own change.
  • The «Мой налог» submission is a separate task. Everything it needs is already recorded (order id, provider payment id, amount, currency, credit time, pack snapshot, refunds as ledger rows, CSV export); the one thing it will add is a per-operation "already reported" marker for its own idempotency.
Replaces 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). 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 | | Robokassa | YooKassa | |---|---|---| | Launch a payment | offline signed URL, no network call | outbound `POST /v3/payments` -> `confirmation_url` | | Callback authenticity | SHA-256 over Password2 | **none - webhooks are unsigned** | | Refund | manual, in the cabinet | `POST /v3/refunds` | - **New `backend/internal/yookassa`** - API client (`CreatePayment`/`GetPayment`/`CreateRefund`/`GetRefund`, HTTP Basic, `Idempotence-Key`), per-channel `Shops` with `ByShopID`, notification envelope + sender-IP allowlist, receipt builder. Same shape as the `robokassa` package: no DB, no payments-domain coupling. - **Order** - 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. The provider payment id is recorded on the order (no migration - the column existed). - **The notification is a hint, never evidence** - the body only names a payment, which is re-read with `GET /v3/payments/{id}`; only that answer is acted on. 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. 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. - **Expiry-time reconciliation** - no polling, but a permanently lost notification would leave the money taken and the chips unowed, silently. The pending-order reaper now asks the provider about each order that reached its expiry age carrying a payment id. One request per order over its whole life. - **A decline is surfaced** - `payment.canceled` records a `failed` event, delivering what PAYMENTS.md section 9 already specified. - **Robokassa 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` / the three workflows and recorded in `backend/internal/robokassa/README.md`. Ledger rows keep `provider = '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.succeeded` is 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 `pending` and the ledger is append-only, so the console now records only a `succeeded` refund 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 `receipt` is 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_CODE` unset (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.md` step. ## Tests - **unit** - `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); `ui` vitest for the wallet email rule. - **integration** - `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 records `failed`; 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 1. In the **test shop** cabinet set **Интеграция - HTTP-уведомления** to `<contour base URL>/pay/yookassa/notify`, events `payment.succeeded`, `payment.canceled` **and `refund.succeeded`**. (Credentials are already in Gitea and live on the contour.) 2. **Real web shop** -> `PROD_BACKEND_YOOKASSA_WEB_*`; same notification URL against `https://erudit-game.ru/pay/yookassa/notify`. 3. Leave `*_BACKEND_YOOKASSA_VAT_CODE` **unset** - receipts are deliberately off. ## Notes - The `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; raising `BUDGET` is an owner call and belongs in its own change. - The «Мой налог» submission is a separate task. Everything it needs is already recorded (order id, provider payment id, amount, currency, credit time, pack snapshot, refunds as ledger rows, CSV export); the one thing it will add is a per-operation "already reported" marker for its own idempotency.
developer added 1 commit 2026-07-28 06:52:06 +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.
developer added 1 commit 2026-07-28 07:18:45 +00:00
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.
developer added 1 commit 2026-07-28 09:41:05 +00:00
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.
developer added 1 commit 2026-07-28 10:00:04 +00:00
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.
developer added 1 commit 2026-07-28 10:16:02 +00:00
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.
developer added 1 commit 2026-07-28 10:43:29 +00:00
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.
owner approved these changes 2026-07-28 10:49:27 +00:00
developer merged commit c13f5cdb1e into development 2026-07-28 10:49:47 +00:00
developer deleted branch feature/yookassa-direct-rail 2026-07-28 10:49:47 +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#293