diff --git a/PLAN.md b/PLAN.md index e3995bb..9c99fcd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -39,10 +39,12 @@ status — without re-deriving decisions. | E7 | Admin, reports & catalog | 2 | DONE | | E8 | Guest limits | — | DONE | | E9 | Tournament fee | future | TODO | +| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | TODO | **Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). **Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in -parallel). E9 is future. +parallel). E9 is future. E10 splits the `direct` rail into per-channel Robokassa shops + ИП +fiscalization (post-launch, backend-first). --- @@ -892,6 +894,92 @@ tournament-entry storage + pricing design. --- +## E10 — Multi-shop direct rail + ИП fiscalization + +**Status:** TODO · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`, +the `robokassa` adapter, the Result callback), E7 (the per-user report — channel breakdown) · +mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42–D44. + +**Goal.** Split the single Robokassa `direct` rail into **per-channel merchant shops** (web, +android; ios later) — separate merchant accounts / withdrawal / accounting and a per-channel +breakdown in the E7 report — while keeping **one `direct` wallet** (D42). Land the wallet/identity +rules the native apps need (email-only anchor, D43) and revise fiscalization for the owner's move +to **ИП / 54-ФЗ** (D41 rev). All **additive / contour-safe** and **money-live** → expand-contract +throughout. + +**Locked decisions (owner interview 2026-07-14): D41 (rev), D42, D43, D44.** No wallet-model / +spend-wall change; the split is merchant-account routing under one `direct` segment. Route the shop +by the **trusted** `X-Platform` subtype, never a client field; unknown → `web`. + +**B1 — Config: one shop → a set of shops.** + +- `robokassa.Config` (single) → a **shops registry** keyed by channel (`web`, `android`; `ios` + later), each 4 fields (`MerchantLogin`/`Password1`/`Password2`/`IsTest`). New env + `BACKEND_ROBOKASSA_WEB_*`, `BACKEND_ROBOKASSA_ANDROID_*`. +- **Expand-contract, no flag-day:** the legacy `BACKEND_ROBOKASSA_*` seeds the `web` shop; add the + new vars; retire the legacy set after the prod rollout sets the split vars. `validate()`: a shop + with a login must carry both passwords (mirror the current check), per shop. +- Config/README + `deploy/.env.example` + the deploy ansible vars. + +**B2 — Route the shop by channel (intake).** + +- `handleWalletOrder` `SourceDirect` branch: pick the shop by `cxt.Subtype` (`web`/`android`; + unknown → `web`). Build the payment request from that shop. The subtype rides the trusted + gateway-injected `X-Platform` (`/`, `parsePlatformHeader`) — no spoofable client + field. **Verify the gateway emits `direct/android` for the native build**; if it sends a bare + `direct/`, add the android subtype (small gateway change). +- Record the chosen `shop` on the order (feeds B5). + +**B3 — Per-shop Result callbacks.** + +- The callback must select the right `Password2` → each shop gets its own Result URL: + **per-shop public edge routes** (mirror the existing single Robokassa Result route, e.g. + `…/result/web`, `…/result/android`), each forwarded to the backend and verified with that shop's + config, then the same `Fund` (source stays `direct`). Add the routes to the **Caddyfile + `@gateway` matcher** (else they fall to the landing catch-all) **+ a CI probe per route**. +- Keep the legacy single route alive (shop = `web`) through the expand-contract window until the + cabinet Result URLs are cut over. + +**B4 — ИП fiscalization (D41 rev) — orthogonal, gated on the owner's ИП/ОФД/СНО.** + +- **Owner/cabinet:** enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО). + Required for ИП regardless of code. +- **Code (optional, itemized чеки):** send a one-line `Receipt` (pack name, qty 1, `sum`, `tax` + per СНО, `payment_object`/`payment_method`) + the customer `Email` (the D36 confirmed anchor) in + the payment request; extend the signature to `MerchantLogin:OutSum:InvId:Receipt:Password1` + (URL-encode `Receipt`). One line fits the current GET redirect; POST-form only as a URL-length + fallback. One feature, all shops. **Sequenced last** — the split (B1–B3, B5, B6) needs no + fiscalization. Supersedes the E5 §12 shop-side НПД receipt note. + +**B5 — Channel in the report (D44).** + +- Additive `shop` column on the order (default `web` / backfill from `origin`); a per-channel + breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column), + **contour-safe** (a schema touch → note the contour `DROP SCHEMA` step in `PRERELEASE.md`). + +**B6 — Tests + docs.** + +- unit: the `robokassa` shops registry + per-shop `VerifyResult` (right `Password2`); + signature-with-`Receipt` (B4); intake shop routing by subtype (web/android/unknown→web). +- integration: order→per-shop callback→credit (each route), a duplicate credits once, an expired + order still honoured; the `shop` recorded + reported. +- docs: `PAYMENTS.md` (+`_ru`) the multi-shop topology + the ИП/54-ФЗ receipt revision; + `deploy/README` + `.env.example` the new vars + the per-shop Result routes + probes; + `PRERELEASE.md` the `shop` column contour step. + +**Done-criteria.** A `direct/web` order pays through the web shop and `direct/android` through the +android shop, each verified with its own `Password2`, both crediting one `direct` wallet exactly +once; the E7 report breaks payments down by channel; the split stays contour-safe (the legacy shop +still works until the cabinet cutover). B4 (fiscalization) verified when the owner's ИП/ОФД/СНО are +live. + +**Notes/risks.** Edge routes fall through if not in the Caddyfile `@gateway` matcher — add a CI +probe (field note). The prod rolling deploy skips caddy on config-only changes — force-recreate on a +Caddyfile change. Money-live: expand-contract only, image rollback DB-safe. Confirm the gateway +emits `direct/android` for the native build before relying on subtype routing. + +--- + ## Verification & CI (all stages) - Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 6b9bc29..97eef6e 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -76,10 +76,10 @@ {{$uid := .ID}} {{if .Finance.Ledger}} - + {{range .Finance.Ledger}} - + {{end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index c60e6f3..44afe8b 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -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 } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f3844ee..a533ea2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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", + } +} diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index cf65f3e..9605cf8 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -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). diff --git a/backend/internal/payments/statement.go b/backend/internal/payments/statement.go index 1ecc816..41045cf 100644 --- a/backend/internal/payments/statement.go +++ b/backend/internal/payments/statement.go @@ -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 } diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index b435226..c4c51ac 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -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) diff --git a/backend/internal/payments/store_statement.go b/backend/internal/payments/store_statement.go index 33cb623..78b8685 100644 --- a/backend/internal/payments/store_statement.go +++ b/backend/internal/payments/store_statement.go @@ -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 diff --git a/backend/internal/postgres/jet/payments/model/orders.go b/backend/internal/postgres/jet/payments/model/orders.go index 55010ff..3161506 100644 --- a/backend/internal/postgres/jet/payments/model/orders.go +++ b/backend/internal/postgres/jet/payments/model/orders.go @@ -25,4 +25,5 @@ type Orders struct { ProviderPaymentID *string CreatedAt time.Time UpdatedAt time.Time + Shop string } diff --git a/backend/internal/postgres/jet/payments/table/orders.go b/backend/internal/postgres/jet/payments/table/orders.go index 26de702..21a974b 100644 --- a/backend/internal/postgres/jet/payments/table/orders.go +++ b/backend/internal/postgres/jet/payments/table/orders.go @@ -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, diff --git a/backend/internal/postgres/migrations/00015_orders_shop.sql b/backend/internal/postgres/migrations/00015_orders_shop.sql new file mode 100644 index 0000000..cb9f111 --- /dev/null +++ b/backend/internal/postgres/migrations/00015_orders_shop.sql @@ -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; diff --git a/backend/internal/robokassa/shops.go b/backend/internal/robokassa/shops.go new file mode 100644 index 0000000..c328c48 --- /dev/null +++ b/backend/internal/robokassa/shops.go @@ -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 +} diff --git a/backend/internal/robokassa/shops_test.go b/backend/internal/robokassa/shops_test.go new file mode 100644 index 0000000..afdb467 --- /dev/null +++ b/backend/internal/robokassa/shops_test.go @@ -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") + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 2e3f114..5176daf 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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) } } diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index dcf06c6..0889050 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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), }) } diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index 10f6491..01d1f89 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -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") diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 4656c8c..096255f 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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 diff --git a/deploy/.env.example b/deploy/.env.example index 7846f75..c559a6c 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -142,6 +142,18 @@ ROBOKASSA_MERCHANT_LOGIN= ROBOKASSA_PASSWORD1= ROBOKASSA_PASSWORD2= ROBOKASSA_TEST= +# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here. +# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each +# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The +# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android. +ROBOKASSA_WEB_MERCHANT_LOGIN= +ROBOKASSA_WEB_PASSWORD1= +ROBOKASSA_WEB_PASSWORD2= +ROBOKASSA_WEB_TEST= +ROBOKASSA_ANDROID_MERCHANT_LOGIN= +ROBOKASSA_ANDROID_PASSWORD1= +ROBOKASSA_ANDROID_PASSWORD2= +ROBOKASSA_ANDROID_TEST= # --- Gateway anti-abuse ------------------------------------------------------ # Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban diff --git a/deploy/README.md b/deploy/README.md index 06e5d06..ec4417b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -79,6 +79,7 @@ compose binds from this directory. | `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. | | `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) | | `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. | +| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). | **Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 04a81fd..c200392 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -171,6 +171,18 @@ services: BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-} BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-} BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-} + # Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds + # the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android + # shop. Each shop credits the one direct wallet; an empty login drops that channel (order + # routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}. + BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-} + BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-} + BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-} + BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-} + BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-} + BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-} + BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-} + BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-} # The dictionary lives on a named volume seeded from the image on first boot # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # inherits). The admin console writes new version subdirectories here, and the diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index eadad8f..84f1f68 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -57,6 +57,15 @@ for outside the store's own cash desk. Chips funded inside VK (Votes) may only b the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside the stores. See §4. +**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per +channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype; +every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account +separation for accounting / receipts only: the order records its `shop` (shown in the admin report), +and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/`. +Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email +anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source +regardless of the number of shops (D41). + ## 3. Three operations, kept distinct Do not conflate these — they use different keys: diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 089774e..088bec9 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -234,11 +234,40 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз - **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты, гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27). -- **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД - (режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать - нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально - = не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK. - *(Не юридическая консультация — точную схему НПД владелец сверяет с налоговой стороной.)* +- **D41. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП).** + **Robokassa** (direct) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa + (провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате — + отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК Robokassa + (касса + ОФД + СНО). Код — опционально: слать детализированный `Receipt` + `Email` + покупателя (берём подтверждённый email-якорь D36) → чек с точным названием пакета и ставкой + по СНО; иначе — обобщённый дефолт-чек из кабинета. `Receipt` входит в подпись + (`MerchantLogin:OutSum:InvId:Receipt:Пароль#1`, URL-кодируется), одна позиция влезает в + текущий GET-redirect (POST-форма — фолбэк на длину URL). Источник чеков один — ИП/касса, + независимо от числа магазинов Robokassa. **VK** — процессит Голоса через налоговую сам. + **TG Stars** — вне рублёвого фискального контура (принимаем, чек не формируем). ОРД по + VK-рекламе — на стороне VK. *(Не юрконсультация — схему по 54-ФЗ/СНО владелец сверяет с + бухгалтером.)* +- **D42. Direct-рельс — несколько магазинов Robokassa = маршрутизация merchant-аккаунтов по + каналу при едином кошельке.** Кошелёк `direct` остаётся один. Отдельные магазины Robokassa + (web, android; ios — позже) различаются только кредами / Return-URL / учётом и **все + зачисляют в сегмент `direct`**. Модель кошельков, стенка трат и origin бенефитов не меняются. + Магазин выбирается по трастовому сигналу канала (`X-Platform` вида `/`: + `direct/web`, `direct/android`), а не по подделываемому клиентскому полю; неизвестный подтип + → магазин `web` (безопасный дефолт). Зачисление от выбора магазина не зависит (всегда + `direct`), поэтому ошибка маршрутизации влияет максимум на учёт, не на деньги. +- **D43. Standalone-приложения (Android, iOS) — вход только по email; покупка требует + email-якорь.** В нативной сборке доступны только guest + email: VK ID-логин (full-page + redirect на `id.vk.com`) не возвращается в Capacitor, TG Login Widget в WebView ненадёжен — + это уже действующая реальность сборки, не новое ограничение. Direct-покупка требует + подтверждённого email-якоря (D36) — он же адрес фискального чека (D41); гость не покупает. + Отдельный сегмент `apple` НЕ заводим: iOS-standalone — тот же `direct`-контекст, внешний гейт + (Robokassa) фондирует единый `direct`. Сегмент `apple` со стенкой (по образцу vk/tg) + понадобился бы только при Apple IAP (StoreKit) — отложено до решения по iOS; и identity-kind + `apple→direct`, и отдельный сегмент — аддитивны, без риска, делаются на месте. +- **D44. Канал платежа хранится на заказе для раздельного учёта/отчёта.** Заказ получает поле + `shop` (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для + разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет + (всегда `direct`, D42). ## Заметки к оформлению документов @@ -254,10 +283,15 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить feedback-память `prefer-interview-mode` после plan mode. -## Все развилки закрыты (D1-D41) +## Все развилки закрыты (D1-D44) Интервью завершено. Дальше — оформление документов и реализация по релизам. +**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на +ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит +`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке +`direct` и входе email-only в standalone-приложениях. Реализация — новый этап E10 в `PLAN.md`. + ## План внедрения (черновик PLAN.md — «слоями») Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 66d6363..c8f4886 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -57,6 +57,15 @@ в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне сторов. См. §4. +**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин +Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу +`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет). +Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в +админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по +`/pay/robokassa/result/`. В standalone-приложениях (Android/iOS) вход только по email, поэтому +у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под +одним ИП) — один источник независимо от числа магазинов (D41). + ## 3. Три операции, которые нельзя путать Не смешивать — у них разные ключи: diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 29d0dbe..7610e07 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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"). params carries the provider's raw form fields. -func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) { +// Robokassa ("OK"). 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 } diff --git a/gateway/internal/connectsrv/robokassa_channel_test.go b/gateway/internal/connectsrv/robokassa_channel_test.go new file mode 100644 index 0000000..551a3f7 --- /dev/null +++ b/gateway/internal/connectsrv/robokassa_channel_test.go @@ -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) + } + } +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index d5b2cfd..152b1ef 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -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/); 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" 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)
TimeKindSourceOriginChipsOrderProviderDetail
TimeKindSourceOriginChipsOrderProviderShopDetail
{{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{end}}
{{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{.Shop}}{{if .Snapshot}}{{.Snapshot}}{{end}} {{if and (eq .Kind "fund") .Order}}
{{end}}