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

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.
This commit is contained in:
Ilia Denisov
2026-07-09 18:26:06 +02:00
parent 04435a3283
commit 3367cc2bf1
9 changed files with 174 additions and 11 deletions
+19 -7
View File
@@ -199,8 +199,8 @@ func (s *Server) HTTPHandler() http.Handler {
// 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.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/robokassa/success", s.robokassaRedirectHandler())
mux.Handle("/pay/robokassa/fail", s.robokassaRedirectHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
// 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
@@ -522,12 +522,24 @@ func (s *Server) robokassaResultHandler() http.Handler {
})
}
// robokassaRedirectHandler redirects the customer's browser back into the app after Robokassa's
// Success/Fail return. The credit rides the server Result callback, never these redirects, so this
// only navigates home; the wallet reflects the credit once the callback lands.
func (s *Server) robokassaRedirectHandler() http.Handler {
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail 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
// updates in place from the payment push, with a return-focus refetch as the fallback.
func (s *Server) robokassaReturnHandler(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">
<p style="font-size: 1.1rem">` + message + `</p>
<p style="color: #5b6472">Можно закрыть это окно и вернуться в приложение.</p>
<p><a href="/app/">Открыть приложение</a></p>
<script>setTimeout(function () { try { window.close(); } catch (e) {} }, 1200);</script>
</body></html>`)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app/", http.StatusSeeOther)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(page)
})
}