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
@@ -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