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:
@@ -3,7 +3,7 @@
|
||||
Technical, step-by-step implementation of the monetization domain. Business mechanics and
|
||||
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
|
||||
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); the frozen owner agreements they both derive
|
||||
from (the `D1`–`D41` decisions log) live in
|
||||
from (the `D1`–`D51` decisions log) live in
|
||||
[`docs/PAYMENTS_DECISIONS_ru.md`](docs/PAYMENTS_DECISIONS_ru.md) — the authority when a rule
|
||||
is disputed. This file is the *how*. Each stage is written to be self-sufficient: returning
|
||||
to it gives full context — goal, exact touch-points, tests, done-criteria, and current
|
||||
@@ -11,7 +11,7 @@ status — without re-deriving decisions.
|
||||
|
||||
## How to use this file
|
||||
|
||||
- **Stage granularity (E0–E9) is fixed.** Do not split or merge stages. Plan each stage
|
||||
- **Stage granularity (E0–E12) is fixed.** Do not split or merge stages. Plan each stage
|
||||
densely enough to execute whole.
|
||||
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
|
||||
titles/descriptions must read as finalized feature copy — never "E5", "stage 3", etc.
|
||||
@@ -41,6 +41,7 @@ status — without re-deriving decisions.
|
||||
| E9 | Tournament fee | future | TODO |
|
||||
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
|
||||
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
|
||||
| E12 | YooKassa direct rail (Robokassa retired) | 2+ | DONE |
|
||||
|
||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
||||
@@ -1012,6 +1013,55 @@ so nothing is disabled until an operator acts.
|
||||
|
||||
---
|
||||
|
||||
## E12 — YooKassa direct rail (Robokassa retired)
|
||||
|
||||
**Status:** DONE · **Release 2+ (money-live)** · depends on: E5 (intake/`Fund`, the order flow), E7
|
||||
(the admin refund + report), E10 (per-channel shops), E11 (the kill switch) · mechanics: PAYMENTS §2,
|
||||
§9, §11, §12; decisions D47–D51 (revising D41).
|
||||
|
||||
**Delivered.** The `direct` (RUB) rail settles through **YooKassa** instead of Robokassa. The wallet
|
||||
model is untouched — one `direct` segment, the same spend wall, the same per-channel shops (D42) and
|
||||
`shop` on the order (D44). Robokassa is **dormant, not deleted**: the direct rail falls back to it
|
||||
when no YooKassa shop is configured, so reviving it is a credentials change (D47).
|
||||
|
||||
- **Provider glue** — `backend/internal/yookassa`: an API client (`CreatePayment` / `GetPayment` /
|
||||
`CreateRefund`, HTTP Basic, `Idempotence-Key`), a per-channel `Shops` registry with `ByShopID` for
|
||||
attributing a notification, the notification envelope + the sender-IP allowlist, and the fiscal
|
||||
receipt builder. No DB, no payments-domain coupling — the same shape as the `robokassa` package.
|
||||
- **Order** — `handleWalletOrder` prefers a YooKassa shop and otherwise falls through to Robokassa.
|
||||
It creates the payment (order id as both `Idempotence-Key` and `metadata.order_id`), records the
|
||||
provider payment id on the order (`AttachProviderPayment`; no migration — the column existed) and
|
||||
returns `confirmation_url`. D36's email anchor now also feeds the receipt (`ConfirmedEmail`).
|
||||
- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: sender
|
||||
allowlist, then the confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the
|
||||
payment's metadata must name the order, and its `test` flag must match the shop's. `payment.canceled`
|
||||
records a `failed` event. 200 = durably decided, 5xx = redeliver.
|
||||
- **Reconcile** — `runOrderReaper` asks the provider about each pending order that reached its expiry
|
||||
age carrying a payment id, and credits the ones really paid (D49). One request per order.
|
||||
- **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on success, under the
|
||||
provider's own refund id (D50); a failure records nothing.
|
||||
- **Fiscalization** — «Чеки от ЮKassa» with the `receipt` sent from code (D51), reversing E10's B4:
|
||||
YooKassa has no cabinet-side generic receipt. `BACKEND_YOOKASSA_VAT_CODE` is a deploy variable;
|
||||
the settlement subject/method are constants.
|
||||
- **Config/deploy** — `BACKEND_YOOKASSA_{WEB,ANDROID}_{SHOP_ID,SECRET_KEY,TEST}` +
|
||||
`BACKEND_YOOKASSA_VAT_CODE`; every `ROBOKASSA_*` mapping removed from compose, `.env.example`,
|
||||
`write-prod-env.sh` and the three workflows, and recorded in `backend/internal/robokassa/README.md`.
|
||||
The CI edge probe now targets `/pay/yookassa/notify`.
|
||||
- **Tests** — unit (`yookassa`: request building, receipt, idempotence key, error retryability, shop
|
||||
registry, IP allowlist); integration (`payments_yookassa_test.go`: credit-once + redelivery, a
|
||||
forged notification credits nothing, 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 it fails, the
|
||||
order path mints a payment with a receipt, and D36 still gates the rail).
|
||||
|
||||
**Contour-safe:** no migration, no wire change; the client is rail-agnostic (only the mock URL and an
|
||||
e2e assertion name a provider).
|
||||
|
||||
**Verified on the contour** against a YooKassa **test shop** (test mode exists — a separate test shop
|
||||
with its own credentials and test cards), then in prod with one real payment.
|
||||
|
||||
---
|
||||
|
||||
## Verification & CI (all stages)
|
||||
|
||||
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
||||
|
||||
Reference in New Issue
Block a user