Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf162de40 | |||
| 5ce9f7e9b4 | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -39,12 +39,10 @@ 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. E10 splits the `direct` rail into per-channel Robokassa shops + ИП
|
||||
fiscalization (post-launch, backend-first).
|
||||
parallel). E9 is future.
|
||||
|
||||
---
|
||||
|
||||
@@ -894,92 +892,6 @@ 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` (`<kind>/<subtype>`, `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
|
||||
|
||||
@@ -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>Shop</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>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>{{.Shop}}</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>{{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 / direct-rail shop it references (empty when none), the raw
|
||||
// snapshot JSON and the pre-formatted time.
|
||||
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
|
||||
// the pre-formatted time.
|
||||
type LedgerRow struct {
|
||||
Kind string
|
||||
Source string
|
||||
@@ -242,7 +242,6 @@ 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 — one merchant shop per channel
|
||||
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Shops
|
||||
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
|
||||
// leaves the direct order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -157,19 +157,11 @@ func Load() (Config, error) {
|
||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||
}
|
||||
|
||||
// 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
|
||||
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",
|
||||
}
|
||||
|
||||
c := Config{
|
||||
@@ -189,7 +181,7 @@ func Load() (Config, error) {
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
Robokassa: shops,
|
||||
Robokassa: robo,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -242,10 +234,8 @@ func (c Config) validate() error {
|
||||
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -286,15 +276,3 @@ 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,7 +46,6 @@ 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
|
||||
@@ -54,16 +53,6 @@ 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,8 +42,7 @@ type RiskInfo struct {
|
||||
}
|
||||
|
||||
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
|
||||
// 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).
|
||||
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
|
||||
type LedgerEntry struct {
|
||||
Kind string
|
||||
Source string
|
||||
@@ -53,7 +52,6 @@ type LedgerEntry struct {
|
||||
OrderID string
|
||||
Provider string
|
||||
ProviderPaymentID string
|
||||
Shop string
|
||||
Snapshot string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -151,7 +151,6 @@ type newOrder struct {
|
||||
amount Money
|
||||
origin Source
|
||||
provider string
|
||||
shop string
|
||||
}
|
||||
|
||||
// createOrder inserts a pending order.
|
||||
@@ -160,12 +159,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.Shop,
|
||||
table.Orders.CreatedAt, table.Orders.UpdatedAt,
|
||||
).VALUES(
|
||||
o.orderID, o.accountID, o.platform,
|
||||
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
||||
string(o.origin), "pending", o.provider,
|
||||
now, now, o.shop,
|
||||
now, now,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("payments: create order: %w", err)
|
||||
|
||||
@@ -74,39 +74,9 @@ 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,5 +25,4 @@ type Orders struct {
|
||||
ProviderPaymentID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Shop string
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ type ordersTable struct {
|
||||
ProviderPaymentID postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
Shop postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -83,10 +82,9 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
||||
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
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}
|
||||
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}
|
||||
)
|
||||
|
||||
return ordersTable{
|
||||
@@ -105,7 +103,6 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
||||
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
Shop: ShopColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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.Configured() {
|
||||
if s.robokassa.MerchantLogin != "" {
|
||||
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, Shop: e.Shop, Snapshot: e.Snapshot,
|
||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
|
||||
At: fmtTime(e.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
)
|
||||
|
||||
// Ledger/order provider tags per rail.
|
||||
@@ -69,8 +68,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
}
|
||||
switch cxt.Kind {
|
||||
case payments.SourceDirect:
|
||||
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
||||
if !ok {
|
||||
if s.robokassa.MerchantLogin == "" {
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||
return
|
||||
}
|
||||
@@ -91,7 +89,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
Rail: providerRobokassa,
|
||||
})
|
||||
case payments.SourceVK:
|
||||
@@ -138,16 +136,7 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
|
||||
for k, val := range params {
|
||||
v.Set(k, val)
|
||||
}
|
||||
// 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)
|
||||
orderID, outSum, ok := s.robokassa.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 — one merchant shop per channel; an empty
|
||||
// set leaves the order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Shops
|
||||
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
||||
// order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// 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.Shops
|
||||
robokassa robokassa.Config
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
|
||||
@@ -56,9 +56,6 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
zap.String("path", route),
|
||||
zap.Int("status", status),
|
||||
zap.Duration("latency", elapsed),
|
||||
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
||||
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
}
|
||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||
|
||||
|
||||
@@ -142,18 +142,6 @@ 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
|
||||
|
||||
@@ -79,7 +79,6 @@ 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
|
||||
|
||||
@@ -150,13 +150,6 @@
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
||||
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
||||
# services themselves run in UTC regardless; this is about host-side log correlation.
|
||||
- name: Set the system timezone to UTC
|
||||
community.general.timezone:
|
||||
name: Etc/UTC
|
||||
|
||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||
|
||||
@@ -171,18 +171,6 @@ 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
|
||||
|
||||
@@ -151,10 +151,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
||||
(carried on the request context), so the backend records the real caller — the
|
||||
account's last-login IP, chat/feedback moderation, the access log — rather than
|
||||
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||
of 2 idle connections per host; otherwise the per-request connection churn
|
||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||
@@ -1499,8 +1496,7 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
||||
last-login IP, chat/feedback moderation and the access log — survives the
|
||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||
|
||||
@@ -57,15 +57,6 @@ 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/<channel>`.
|
||||
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:
|
||||
|
||||
@@ -234,40 +234,11 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
- **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты,
|
||||
гранты, возвраты, полная история — расширение существующей карточки
|
||||
(`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27).
|
||||
- **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` вида `<kind>/<subtype>`:
|
||||
`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).
|
||||
- **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД
|
||||
(режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать
|
||||
нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально
|
||||
= не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK.
|
||||
*(Не юридическая консультация — точную схему НПД владелец сверяет с налоговой стороной.)*
|
||||
|
||||
## Заметки к оформлению документов
|
||||
|
||||
@@ -283,15 +254,10 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
||||
feedback-память `prefer-interview-mode` после plan mode.
|
||||
|
||||
## Все развилки закрыты (D1-D44)
|
||||
## Все развилки закрыты (D1-D41)
|
||||
|
||||
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
||||
|
||||
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
|
||||
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
|
||||
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
|
||||
`direct` и входе email-only в standalone-приложениях. Реализация — новый этап E10 в `PLAN.md`.
|
||||
|
||||
## План внедрения (черновик PLAN.md — «слоями»)
|
||||
|
||||
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
||||
|
||||
@@ -57,15 +57,6 @@
|
||||
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
||||
сторов. См. §4.
|
||||
|
||||
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
|
||||
Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
||||
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
|
||||
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
|
||||
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
|
||||
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
|
||||
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
|
||||
одним ИП) — один источник независимо от числа магазинов (D41).
|
||||
|
||||
## 3. Три операции, которые нельзя путать
|
||||
|
||||
Не смешивать — у них разные ключи:
|
||||
|
||||
@@ -498,15 +498,10 @@ type robokassaResultResp struct {
|
||||
|
||||
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
|
||||
// single writer, which verifies the signature and credits) and returns the body to echo to
|
||||
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields; 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) {
|
||||
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
|
||||
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
|
||||
var out robokassaResultResp
|
||||
path := "/api/v1/internal/payments/robokassa/result"
|
||||
if channel != "" {
|
||||
path += "?channel=" + url.QueryEscape(channel)
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, path, "", "", params, &out)
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
|
||||
return out.Response, err
|
||||
}
|
||||
|
||||
|
||||
@@ -139,26 +139,6 @@ func platformFromContext(ctx context.Context) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// clientIPCtxKey types the request-context slot the originating client IP rides in.
|
||||
type clientIPCtxKey struct{}
|
||||
|
||||
// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as
|
||||
// X-Forwarded-For on every downstream backend request — so the backend records the real caller
|
||||
// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's
|
||||
// own connection. An empty IP leaves ctx unchanged, so no header is sent.
|
||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||
if ip == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, clientIPCtxKey{}, ip)
|
||||
}
|
||||
|
||||
// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none.
|
||||
func clientIPFromContext(ctx context.Context) string {
|
||||
ip, _ := ctx.Value(clientIPCtxKey{}).(string)
|
||||
return ip
|
||||
}
|
||||
|
||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||
@@ -180,13 +160,6 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
||||
if userID != "" {
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
}
|
||||
// The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the
|
||||
// explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set
|
||||
// once per request in the Connect edge) — so the backend never falls back to the gateway's own
|
||||
// connection address.
|
||||
if clientIP == "" {
|
||||
clientIP = clientIPFromContext(ctx)
|
||||
}
|
||||
if clientIP != "" {
|
||||
req.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
|
||||
@@ -122,44 +122,3 @@ func TestXPlatformInjection(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
||||
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
||||
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
||||
// gateway's own connection. Absent when no client IP is set.
|
||||
func TestXForwardedForInjection(t *testing.T) {
|
||||
var gotXFF string
|
||||
var hadHeader bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotXFF = r.Header.Get("X-Forwarded-For")
|
||||
_, hadHeader = r.Header["X-Forwarded-For"]
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
||||
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
||||
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if gotXFF != "203.0.113.7" {
|
||||
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no client IP omits the header", func(t *testing.T) {
|
||||
hadHeader = true
|
||||
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if hadHeader {
|
||||
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package connectsrv
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRobokassaChannel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/pay/robokassa/result": "", // legacy bare path → the backend defaults to the web shop
|
||||
"/pay/robokassa/result/": "", // trailing slash, no channel → the web default
|
||||
"/pay/robokassa/result/web": "web",
|
||||
"/pay/robokassa/result/android": "android",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := robokassaChannel(path); got != want {
|
||||
t.Errorf("robokassaChannel(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,11 +274,8 @@ 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. The
|
||||
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
|
||||
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change.
|
||||
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
|
||||
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("Оплата не завершена."))
|
||||
@@ -421,10 +418,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||
}
|
||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||
// Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every
|
||||
// downstream REST call for this request — the account's last-login IP, chat/feedback moderation and
|
||||
// the backend access log, not only the chat/feedback calls that pass it explicitly.
|
||||
ctx = backendclient.WithClientIP(ctx, clientIP)
|
||||
|
||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||
if op.Auth {
|
||||
@@ -640,21 +633,6 @@ func (s *Server) exportDownloadHandler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// robokassaResultPrefix is the channel-suffixed Result path; the segment after it names the
|
||||
// per-shop channel (matches the direct-rail X-Platform subtype: "web", "android").
|
||||
const robokassaResultPrefix = "/pay/robokassa/result/"
|
||||
|
||||
// robokassaChannel extracts the shop channel from a Result callback path: the segment after
|
||||
// robokassaResultPrefix, or "" for the legacy bare /pay/robokassa/result (the backend then defaults
|
||||
// to the web shop). The backend selects the per-shop signature verifier from it.
|
||||
func robokassaChannel(path string) string {
|
||||
rest := strings.TrimPrefix(path, robokassaResultPrefix)
|
||||
if rest == path {
|
||||
return ""
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
|
||||
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
|
||||
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
|
||||
@@ -679,7 +657,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(), robokassaChannel(r.URL.Path), params)
|
||||
resp, err := s.backend.RobokassaResult(r.Context(), params)
|
||||
if err != nil {
|
||||
s.log.Warn("robokassa result proxy failed", zap.Error(err))
|
||||
http.Error(w, "not accepted", http.StatusBadGateway)
|
||||
|
||||
Reference in New Issue
Block a user