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