feat(payments): Robokassa direct-rail payment intake #223

Merged
developer merged 9 commits from feature/payment-intake-robokassa into development 2026-07-09 16:33:12 +00:00
Owner

What

The direct-rail (Robokassa) payment intake, end-to-end: a chip-pack purchase opens a money
order, sends the player to Robokassa's hosted-payment page, and a verified server callback credits
the chips exactly once.

Pieces: the public offer page (/offer/); the provider-agnostic order/fund engine (idempotent
on the ledger (provider, provider_payment_id) unique index, honours an expired order); the
internal/robokassa adapter (SHA-256 sign/verify); POST /wallet/order + the internal Result
callback handlers (a D36 confirmed-email gate on the direct rail); the pending-order reaper;
the wallet.order edge wire (FBS + Connect op) + the public /pay/* gateway routes; the Wallet
purchase CTA replacing "Soon" (+ a public-offer link); and the contour deploy env.

Baked decisions

  • Match the order by a Robokassa Shp_order custom parameter, not the numeric InvId (an order
    id is a uuid); idempotency key = the order id.
  • The НПД receipt is formed shop-side in the Robokassa cabinet — no Receipt parameter is sent.
  • A chargeback never drives the balance negative (D27 stands, balances_chips_chk kept) → E5 is
    schema-free (no migration, no contour wipe).

Tests

Unit: the credit gate, the Robokassa sign/verify (round-trip, tamper, wrong password), the order
codec. Integration (Postgres): order→callback→credit once, a duplicate callback credits nothing, an
amount mismatch is refused, an expired order is still honoured. Mock e2e: the pack purchase opens
the provider page. CI probes: /pay/robokassa/result reaches the gateway, /offer/ serves the page.

Review on the contour

Wallet → a chip pack → Купить should open the Robokassa sandbox page (IsTest is forced on the
contour, so it can never take real money). A sandbox payment credits the chips.

Owner setup

The TEST_BACKEND_ROBOKASSA_* secrets are set. The Robokassa test shop needs its Result/Success/
Fail URLs at the contour's public base (/pay/robokassa/{result,success,fail}), SHA256 + POST.
Note: TEST_BACKEND_ROBOKASSA_TEST is unused — the contour forces IsTest itself.

Remaining (follow-ups, this stack)

The payment_events dispatcher (the succeeded notification); then the VK and TG-Stars rails and
refunds.

## What The **direct-rail (Robokassa) payment intake**, end-to-end: a chip-pack purchase opens a money order, sends the player to Robokassa's hosted-payment page, and a verified server callback credits the chips exactly once. **Pieces:** the public offer page (`/offer/`); the provider-agnostic order/`fund` engine (idempotent on the ledger `(provider, provider_payment_id)` unique index, honours an expired order); the `internal/robokassa` adapter (SHA-256 sign/verify); `POST /wallet/order` + the internal Result callback handlers (a **D36** confirmed-email gate on the `direct` rail); the pending-order reaper; the `wallet.order` edge wire (FBS + Connect op) + the public `/pay/*` gateway routes; the Wallet purchase CTA replacing "Soon" (+ a public-offer link); and the contour deploy env. ## Baked decisions - Match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId` (an order id is a uuid); idempotency key = the order id. - The **НПД receipt is formed shop-side in the Robokassa cabinet** — no `Receipt` parameter is sent. - A chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept) → E5 is **schema-free** (no migration, no contour wipe). ## Tests Unit: the credit gate, the Robokassa sign/verify (round-trip, tamper, wrong password), the order codec. Integration (Postgres): order→callback→credit once, a duplicate callback credits nothing, an amount mismatch is refused, an expired order is still honoured. Mock e2e: the pack purchase opens the provider page. CI probes: `/pay/robokassa/result` reaches the gateway, `/offer/` serves the page. ## Review on the contour Wallet → a chip pack → **Купить** should open the Robokassa **sandbox** page (IsTest is forced on the contour, so it can never take real money). A sandbox payment credits the chips. ## Owner setup The `TEST_BACKEND_ROBOKASSA_*` secrets are set. The Robokassa **test shop** needs its Result/Success/ Fail URLs at the contour's public base (`/pay/robokassa/{result,success,fail}`), **SHA256 + POST**. Note: `TEST_BACKEND_ROBOKASSA_TEST` is unused — the contour forces IsTest itself. ## Remaining (follow-ups, this stack) The `payment_events` dispatcher (the succeeded notification); then the VK and TG-Stars rails and refunds.
developer added 8 commits 2026-07-09 15:49:00 +00:00
Add the payment-intake write path (provider-agnostic) and the Robokassa
direct-rail glue, both unit-tested; transport, wire and UI follow.

- payments: extend the ledger insert to thread order_id/provider/
  provider_payment_id (spend/grant pass nil); add the order store
  (create/read/expire + a pack-price loader) and the fund credit — a
  fund ledger row + a guarded balance upsert + mark-paid in one tx,
  idempotent on the (provider, provider_payment_id) unique index, cache
  invalidated after commit. A valid callback is honoured even on an
  expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for
  the provider amount field.
- robokassa: build the signed hosted-payment URL (SHA-256, order id via
  Shp_order, InvId unused) and verify the Result callback signature
  (Password2), extracting the order and amount. Receipt/fiscalisation is
  configured shop-side, so no Receipt parameter is sent.
Wire the Robokassa direct rail into the backend transport. POST
/api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the
direct rail) opens a pending order and returns the signed Robokassa payment
URL. The internal, gateway-only /payments/robokassa/result endpoint verifies
the Result signature, credits the matched order exactly once via Fund (honoured
even if expired), records a succeeded payment event, and answers Robokassa's
"OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check
(D36), the payment_events writer, and a periodic pending-order reaper. The
routes register only when a Robokassa merchant login is configured.
Cover the order→callback→credit path over Postgres: a funded order credits its
segment exactly once; a replayed callback for the same order credits nothing
(the ledger idempotency index holds); a mismatched paid amount is refused with
no write; an expired pending order is still honoured by a late valid callback.
Add the WalletOrderRequest / WalletOrderResponse FlatBuffers messages and the
wallet.order Connect op: the gateway decodes the order request, forwards it to
the backend POST /wallet/order and returns the created order id plus the
provider launch URL the client opens. Regenerate the committed Go and TS
FlatBuffers code.
Add the public /pay/robokassa/result callback proxy (rate-limited; forwards the
provider's form parameters to the backend intake, the single writer, and echoes
its "OK<InvId>" back to Robokassa) and the /pay/robokassa/{success,fail}
browser-return redirects into the app. Route /pay/* to the gateway in the
contour Caddyfile so the callback reaches the edge, not the landing catch-all.
The backend intake now returns the echo body as JSON for the gateway to relay.
Replace the disabled "Soon" pack action with a real purchase: the Wallet opens a
money order (wallet.order) and sends the player to the provider's hosted-payment
page (window.open via openExternalUrl); the chips are credited later by the
verified server callback. Add a public-offer link under the packs (paying
accepts the offer). Codec order round-trip unit test + a mock-e2e purchase test;
the Google Play stub and the chip-spend paths are unchanged.
Map the Robokassa merchant login + Password1/Password2 into the backend
container env from the deploy secrets (TEST_BACKEND_ROBOKASSA_*), and force
IsTest on the test contour so it can never take real money. An empty login
leaves the direct order + callback endpoints unregistered.
docs(payments): bake the E5 direct-rail decisions + a /pay/ CI probe
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m40s
04435a3283
Record the E5 delivery and resolved decisions in PLAN.md (Shp_order matching,
order-id idempotency, cabinet-side receipt, never-negative → schema-free) and
mark the stage WIP. Add a CI probe asserting /pay/robokassa/result reaches the
gateway rather than the landing catch-all.
developer added 1 commit 2026-07-09 16:26:06 +00:00
feat(payments): payment-event dispatcher + the provider-return UX
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
3367cc2bf1
Deliver payment_events to connected clients as an in-app wallet-refresh push: a
background dispatcher drains undispatched events and publishes a KindNotification
"payment" signal, marking each delivered; the client bumps a wallet-refresh
counter the open Wallet screen watches, re-fetching in place. A return-focus
refetch is the fallback. The Robokassa Success/Fail return now serves a
self-closing page (the payment opens in a separate window) so the customer drops
back into the live app instead of a cold start. Integration test for the event
drain/mark queue.
owner approved these changes 2026-07-09 16:31:38 +00:00
developer merged commit 96adf98e1d into development 2026-07-09 16:33:12 +00:00
developer deleted branch feature/payment-intake-robokassa 2026-07-09 16:33:12 +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#223