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
@@ -76,10 +76,10 @@
{{$uid := .ID}}
{{if .Finance.Ledger}}
<table class="list">
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
<tbody>
{{range .Finance.Ledger}}
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
{{end}}
</tbody>
+3 -2
View File
@@ -232,8 +232,8 @@ type BenefitRow struct {
}
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
// the pre-formatted time.
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
// snapshot JSON and the pre-formatted time.
type LedgerRow struct {
Kind string
Source string
@@ -242,6 +242,7 @@ type LedgerRow struct {
Product string
Order string
Provider string
Shop string
Snapshot string
At string
}
+33 -11
View File
@@ -65,9 +65,9 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
// leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Shops
}
// Defaults applied when the corresponding environment variable is unset.
@@ -157,11 +157,19 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
robo := robokassa.Config{
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
}
if web.MerchantLogin != "" {
shops[robokassa.ChannelWeb] = web
}
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
shops[robokassa.ChannelAndroid] = android
}
c := Config{
@@ -181,7 +189,7 @@ func Load() (Config, error) {
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: robo,
Robokassa: shops,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -234,8 +242,10 @@ func (c Config) validate() error {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
for channel, shop := range c.Robokassa {
if shop.Password1 == "" || shop.Password2 == "" {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
return nil
}
@@ -276,3 +286,15 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
}
return d, nil
}
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
func robokassaShop(prefix string) robokassa.Config {
return robokassa.Config{
MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
Password1: os.Getenv(prefix + "_PASSWORD1"),
Password2: os.Getenv(prefix + "_PASSWORD2"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}
@@ -46,6 +46,7 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
amount: pack.price,
origin: method,
provider: provider,
shop: directShop(cxt),
}
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
return OrderResult{}, err
@@ -53,6 +54,16 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
}
// directShop returns the merchant shop (channel) a direct order is issued through — the trusted
// platform subtype for the direct rail ("web"/"android"), or "" for any other rail (the per-shop
// split is direct-only; D42/D44). Recorded on the order for the admin per-channel breakdown.
func directShop(cxt Context) string {
if cxt.Kind == SourceDirect {
return cxt.Subtype
}
return ""
}
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
+3 -1
View File
@@ -42,7 +42,8 @@ type RiskInfo struct {
}
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
// the column is NULL; Shop is the direct-rail merchant channel the referenced order used (empty for
// other rails or order-less rows); Snapshot is the raw purchase/refund JSON (empty when none).
type LedgerEntry struct {
Kind string
Source string
@@ -52,6 +53,7 @@ type LedgerEntry struct {
OrderID string
Provider string
ProviderPaymentID string
Shop string
Snapshot string
CreatedAt time.Time
}
+3 -2
View File
@@ -151,6 +151,7 @@ type newOrder struct {
amount Money
origin Source
provider string
shop string
}
// createOrder inserts a pending order.
@@ -159,12 +160,12 @@ func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) erro
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
table.Orders.CreatedAt, table.Orders.UpdatedAt,
table.Orders.CreatedAt, table.Orders.UpdatedAt, table.Orders.Shop,
).VALUES(
o.orderID, o.accountID, o.platform,
o.productID, o.amount.Minor(), string(o.amount.Currency()),
string(o.origin), "pending", o.provider,
now, now,
now, now, o.shop,
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: create order: %w", err)
@@ -74,9 +74,39 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
CreatedAt: r.CreatedAt,
})
}
// Annotate direct-rail entries with the merchant shop (channel) their order was issued through
// (E10/D44), keyed by the ledger's order id. Non-direct / order-less entries stay "".
shops, err := s.orderShops(ctx, accountID)
if err != nil {
return Statement{}, err
}
for i := range out.Ledger {
out.Ledger[i].Shop = shops[out.Ledger[i].OrderID]
}
return out, nil
}
// orderShops maps an account's order ids (as strings) to the merchant shop (channel) each direct
// order was issued through, for annotating the ledger report (E10/D44). Orders with an empty shop
// (non-direct or pre-split) are omitted, so a lookup miss yields "".
func (s *Store) orderShops(ctx context.Context, accountID uuid.UUID) (map[string]string, error) {
var rows []model.Orders
if err := postgres.SELECT(table.Orders.OrderID, table.Orders.Shop).
FROM(table.Orders).
WHERE(table.Orders.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("payments: load order shops %s: %w", accountID, err)
}
m := make(map[string]string, len(rows))
for _, r := range rows {
if r.Shop != "" {
m[r.OrderID.String()] = r.Shop
}
}
return m, nil
}
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
var rows []model.Ledger
@@ -25,4 +25,5 @@ type Orders struct {
ProviderPaymentID *string
CreatedAt time.Time
UpdatedAt time.Time
Shop string
}
@@ -29,6 +29,7 @@ type ordersTable struct {
ProviderPaymentID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
Shop postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -82,9 +83,10 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
ShopColumn = postgres.StringColumn("shop")
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
)
return ordersTable{
@@ -103,6 +105,7 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
ProviderPaymentID: ProviderPaymentIDColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
Shop: ShopColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,13 @@
-- Multi-shop direct rail (E10/D44): record which Robokassa merchant shop (channel) issued a direct
-- order — "web" / "android" (ios later) — so the admin financial report can break direct payments
-- down by channel. Only the direct rail is per-channel; other rails leave it "". Additive only (a
-- defaulted column), applies forward via goose with no data rewrite (no contour wipe); an image
-- rollback ignores the column.
-- +goose Up
ALTER TABLE payments.orders
ADD COLUMN shop text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE payments.orders DROP COLUMN shop;
+54
View File
@@ -0,0 +1,54 @@
package robokassa
// Shops is a set of Robokassa merchant shops keyed by channel — the subtype of the trusted
// X-Platform signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a
// payment to the per-channel shop for separate merchant accounts, accounting and receipts, while
// every shop still credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set
// leaves the direct order and Result-callback endpoints unregistered.
type Shops map[string]Config
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
// default: an unknown or empty channel routes here on the order side, and the legacy single-shop
// configuration seeds it.
const (
ChannelWeb = "web"
ChannelAndroid = "android"
)
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
// costs at most accounting accuracy, not money. The second result is false when neither the channel
// nor the web shop has a merchant login (the rail is unconfigured).
func (s Shops) Shop(channel string) (Config, bool) {
if channel != "" {
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
return c, true
}
}
if c, ok := s[ChannelWeb]; ok && c.MerchantLogin != "" {
return c, true
}
return Config{}, false
}
// Verifier returns the shop whose Password2 verifies a Result callback delivered to channel's
// dedicated Result route. Unlike Shop it does not fall back to web: each shop's callback is verified
// only by that shop's own credentials, so a route with no configured shop reports false (and its
// handler answers as unregistered). The second result is false when channel has no merchant login.
func (s Shops) Verifier(channel string) (Config, bool) {
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
return c, true
}
return Config{}, false
}
// Configured reports whether at least one shop has a merchant login (the direct rail is live).
func (s Shops) Configured() bool {
for _, c := range s {
if c.MerchantLogin != "" {
return true
}
}
return false
}
+52
View File
@@ -0,0 +1,52 @@
package robokassa
import "testing"
func TestShopsShopRouting(t *testing.T) {
shops := Shops{
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
}
// Order side: the exact channel wins.
if c, ok := shops.Shop(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
t.Errorf("Shop(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
}
// Order side: an unknown or empty channel falls back to the web shop (safe — always credits direct).
if c, ok := shops.Shop("ios"); !ok || c.MerchantLogin != "webshop" {
t.Errorf("Shop(ios) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
}
if c, ok := shops.Shop(""); !ok || c.MerchantLogin != "webshop" {
t.Errorf("Shop(empty) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
}
// No web shop and an unknown channel → unconfigured.
if _, ok := (Shops{ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}}).Shop("ios"); ok {
t.Error("Shop(ios) with no web shop returned ok, want false")
}
}
func TestShopsVerifierIsStrict(t *testing.T) {
shops := Shops{
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
}
// Callback side: the exact channel's own credentials, no web fallback (each callback is verified
// only by its own shop's Password2).
if c, ok := shops.Verifier(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
t.Errorf("Verifier(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
}
if _, ok := shops.Verifier("ios"); ok {
t.Error("Verifier(ios) returned ok, want false (no fallback)")
}
}
func TestShopsConfigured(t *testing.T) {
if (Shops{}).Configured() {
t.Error("an empty set reported configured")
}
if (Shops{ChannelWeb: {}}).Configured() {
t.Error("a shop with no merchant login reported configured")
}
if !(Shops{ChannelWeb: {MerchantLogin: "s", Password1: "1", Password2: "2"}}).Configured() {
t.Error("a configured shop reported not configured")
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func (s *Server) registerRoutes() {
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" {
if s.robokassa.Configured() {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
}
@@ -482,7 +482,7 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
for _, e := range stmt.Ledger {
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
At: fmtTime(e.CreatedAt),
})
}
+14 -3
View File
@@ -12,6 +12,7 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/robokassa"
)
// Ledger/order provider tags per rail.
@@ -68,7 +69,8 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
switch cxt.Kind {
case payments.SourceDirect:
if s.robokassa.MerchantLogin == "" {
shop, ok := s.robokassa.Shop(cxt.Subtype)
if !ok {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
@@ -89,7 +91,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
@@ -136,7 +138,16 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
for k, val := range params {
v.Set(k, val)
}
orderID, outSum, ok := s.robokassa.VerifyResult(v)
// The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to
// the web shop. Each channel is verified only by its own shop's Password2 (Verifier is strict).
channel := c.DefaultQuery("channel", robokassa.ChannelWeb)
shop, ok := s.robokassa.Verifier(channel)
if !ok {
s.log.Warn("robokassa result: unknown shop channel", zap.String("channel", channel))
c.String(http.StatusBadRequest, "bad sign")
return
}
orderID, outSum, ok := shop.VerifyResult(v)
if !ok {
s.log.Warn("robokassa result: bad signature")
c.String(http.StatusBadRequest, "bad sign")
+4 -4
View File
@@ -115,9 +115,9 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
// order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// set leaves the order and Result-callback endpoints unregistered.
Robokassa robokassa.Shops
}
// Server owns the gin engine, the underlying HTTP server and the readiness
@@ -146,7 +146,7 @@ type Server struct {
ads *ads.Service
payments *payments.Service
gamelimits *gamelimits.Service
robokassa robokassa.Config
robokassa robokassa.Shops
notifier notify.Publisher
console *adminconsole.Renderer
exportKey []byte