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

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:
Ilia Denisov
2026-07-28 08:51:31 +02:00
parent 985ed40639
commit 92ba527575
37 changed files with 2638 additions and 171 deletions
+54 -10
View File
@@ -273,15 +273,20 @@ func (s *Server) HTTPHandler() http.Handler {
// through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change.
// Direct-rail return + callback routes: the server notification (the single crediting signal,
// proxied to the backend intake) and the browser return page. Caddy's /pay/* matcher already
// forwards every path here, so a new route needs no Caddyfile change.
mux.Handle("/pay/yookassa/notify", s.yookassaNotifyHandler())
mux.Handle("/pay/yookassa/return", s.paymentReturnHandler("Оплата принята."))
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
// The retired Robokassa rail. Its routes stay registered — the backend simply has no shop to
// verify against — so reviving the rail is a credentials change, not a code change, and the
// edge probe keeps proving /pay/* reaches the gateway. The per-shop callback rides a channel
// suffix (/pay/robokassa/result/<channel>); the bare path is the legacy web shop.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
mux.Handle("/pay/robokassa/success", s.paymentReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.paymentReturnHandler("Оплата не завершена."))
// The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
@@ -691,6 +696,45 @@ func (s *Server) robokassaResultHandler() http.Handler {
})
}
// maxNotifyBytes caps the provider notification body the gateway will read and forward.
const maxNotifyBytes = 1 << 20
// yookassaNotifyHandler proxies a YooKassa webhook to the backend intake (the single writer). It
// rate-limits per IP and forwards the raw JSON body together with the true sender address, which
// only this hop sees; the backend checks that address against YooKassa's published sender ranges and
// then re-reads the payment from the provider, because YooKassa does not sign notifications.
//
// The backend answers 200 for anything durably decided and 5xx for a transient failure, and that is
// relayed as-is: YooKassa retries a notification for 24 hours until it gets a 200.
func (s *Server) yookassaNotifyHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "yookassa-notify")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxNotifyBytes))
if err != nil || !json.Valid(body) {
// Rejected here rather than proxied: a body that is not JSON cannot become valid on a
// redelivery, and forwarding it would only make the backend answer 5xx forever.
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := s.backend.YooKassaNotify(r.Context(), ip, body); err != nil {
s.log.Warn("yookassa notify proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
}
// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits
// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret),
// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or
@@ -731,12 +775,12 @@ func (s *Server) vkCallbackHandler() http.Handler {
})
}
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
// paymentReturnHandler serves a minimal self-closing page for a direct-rail provider's browser
// return. The payment opens in a separate window, so on return this closes that window and drops the
// customer back into the live app (never a cold app start); a link is the fallback if the window
// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet
// cannot self-close. The credit rides the server notification, never this redirect — the wallet
// updates in place from the payment push, with a return-focus refetch as the fallback.
func (s *Server) robokassaReturnHandler(message string) http.Handler {
func (s *Server) paymentReturnHandler(message string) http.Handler {
page := []byte(`<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">