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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user