feat(payments): per-channel Robokassa shops on the direct rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
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 2m0s

Split the single Robokassa direct rail into one merchant shop per channel
(web / android; ios later), chosen by the trusted X-Platform subtype, while
every shop still credits the one `direct` wallet — merchant-account separation
for accounting and receipts, not a new wallet.

- config: a channel-keyed shops registry (web seeds from the legacy vars or
  _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant;
  per-shop validation.
- intake: the order picks its shop by subtype (unknown falls back to web); the
  per-shop Result callback is verified by that shop's own Password2 at
  /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's
  /pay/* glob already forwards it — no Caddyfile change).
- persistence: an additive `shop` column on the order (migration 00015),
  recorded from the payment context, surfaced per entry in the admin report.
- standalone apps sign in by email only, so a direct purchase keeps its email
  anchor.
- docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the
  decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan.

Contour-safe: dormant until shops are configured; the migration is additive
(no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway
`direct/android` subtype follow when the ИП / RuStore are live.
This commit is contained in:
Ilia Denisov
2026-07-14 14:57:34 +02:00
parent d8d1b06eee
commit eef90a152e
26 changed files with 448 additions and 42 deletions
+8 -3
View File
@@ -498,10 +498,15 @@ type robokassaResultResp struct {
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
// single writer, which verifies the signature and credits) and returns the body to echo to
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields; channel selects the
// per-shop signature verifier (empty → the web shop).
func (c *Client) RobokassaResult(ctx context.Context, channel string, params map[string]string) (string, error) {
var out robokassaResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
path := "/api/v1/internal/payments/robokassa/result"
if channel != "" {
path += "?channel=" + url.QueryEscape(channel)
}
err := c.do(ctx, http.MethodPost, path, "", "", params, &out)
return out.Response, err
}
@@ -0,0 +1,17 @@
package connectsrv
import "testing"
func TestRobokassaChannel(t *testing.T) {
cases := map[string]string{
"/pay/robokassa/result": "", // legacy bare path → the backend defaults to the web shop
"/pay/robokassa/result/": "", // trailing slash, no channel → the web default
"/pay/robokassa/result/web": "web",
"/pay/robokassa/result/android": "android",
}
for path, want := range cases {
if got := robokassaChannel(path); got != want {
t.Errorf("robokassaChannel(%q) = %q, want %q", path, got, want)
}
}
}
+20 -2
View File
@@ -274,8 +274,11 @@ func (s *Server) HTTPHandler() http.Handler {
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.
// 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.
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("Оплата не завершена."))
@@ -637,6 +640,21 @@ func (s *Server) exportDownloadHandler() http.Handler {
})
}
// robokassaResultPrefix is the channel-suffixed Result path; the segment after it names the
// per-shop channel (matches the direct-rail X-Platform subtype: "web", "android").
const robokassaResultPrefix = "/pay/robokassa/result/"
// robokassaChannel extracts the shop channel from a Result callback path: the segment after
// robokassaResultPrefix, or "" for the legacy bare /pay/robokassa/result (the backend then defaults
// to the web shop). The backend selects the per-shop signature verifier from it.
func robokassaChannel(path string) string {
rest := strings.TrimPrefix(path, robokassaResultPrefix)
if rest == path {
return ""
}
return rest
}
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
@@ -661,7 +679,7 @@ func (s *Server) robokassaResultHandler() http.Handler {
for k := range r.Form {
params[k] = r.Form.Get(k)
}
resp, err := s.backend.RobokassaResult(r.Context(), params)
resp, err := s.backend.RobokassaResult(r.Context(), robokassaChannel(r.URL.Path), params)
if err != nil {
s.log.Warn("robokassa result proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway)