Compare commits

...

7 Commits

Author SHA1 Message Date
developer 74842dc624 Merge pull request 'feat(payments): live payment-availability kill switch + per-account override' (#267) from feature/payment-kill-switch into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m58s
2026-07-14 14:39:35 +00:00
Ilia Denisov 1507ceb793 feat(payments): live payment-availability kill switch + per-account override
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or
one account — and show the user a localized reason on their next attempt, so a
provider outage or a misconfig is explained instead of a silent dead button.

- rail kill switch (payments.rail_status, per rail direct:web / direct:android /
  vk / telegram): enabled + a per-language message, edited on the catalog page.
  Fail-open — a rail with no row stays enabled, so payments are never accidentally
  killed. The intake gate (CanPurchase in handleWalletOrder, before the order)
  returns payment_unavailable + the localized message, orthogonal to the security
  gates.
- per-account override (payments.account_payment_override, a row only for
  non-default): allow / deny / default, edited on the user card. "allow" bypasses
  ONLY the ops rail switch, never the security gates (trusted platform, the email
  anchor, the VK-iOS freeze, the min client version).
- wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe);
  the gateway forwards a backend domain-error message; the client shows it on a
  payment_unavailable buy attempt.
- admin: rail toggles on the catalog page, the override control on the user card.
- tests: the pure gate (unit, TDD), the store + gate + override end-to-end
  (integration, migration 00016), the client (svelte-check / vitest).
- docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays
  cabinet-side (owner decision) — no itemized-receipt code.

Contour-safe: additive migration (two new tables, no wipe), the wire add is
additive, and fail-open so nothing is disabled until an operator acts.
2026-07-14 16:29:41 +02:00
developer 4b3c9d4fdd Merge pull request 'feat(payments): per-channel Robokassa shops on the direct rail' (#266) from feature/robokassa-multishop into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m2s
2026-07-14 13:16:35 +00:00
Ilia Denisov eef90a152e feat(payments): per-channel Robokassa shops on the direct rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Split the single Robokassa direct rail into one merchant shop per channel
(web / android; ios later), chosen by the trusted X-Platform subtype, while
every shop still credits the one `direct` wallet — merchant-account separation
for accounting and receipts, not a new wallet.

- config: a channel-keyed shops registry (web seeds from the legacy vars or
  _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant;
  per-shop validation.
- intake: the order picks its shop by subtype (unknown falls back to web); the
  per-shop Result callback is verified by that shop's own Password2 at
  /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's
  /pay/* glob already forwards it — no Caddyfile change).
- persistence: an additive `shop` column on the order (migration 00015),
  recorded from the payment context, surfaced per entry in the admin report.
- standalone apps sign in by email only, so a direct purchase keeps its email
  anchor.
- docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the
  decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan.

Contour-safe: dormant until shops are configured; the migration is additive
(no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway
`direct/android` subtype follow when the ИП / RuStore are live.
2026-07-14 14:57:34 +02:00
developer d8d1b06eee Merge pull request 'fix(gateway): forward the real client IP to the backend on every call (last-login IP was the docker addr)' (#264) from feature/forward-client-ip into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 11s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 09:38:10 +00:00
Ilia Denisov c16008e67a chore(ansible): pin every host's system timezone to UTC
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
The tg (bot) VPS came up on Europe/Moscow while the main host is UTC, so host-level
timestamps (journald, file mtimes, cron) sat 3h apart across the fleet. Add a
community.general.timezone task to the common role so every provisioned host is UTC
(idempotent — a no-op on the already-UTC main host). Applied live to the tg host in
the same change; the containerised services run in UTC regardless, so no restart.
2026-07-14 11:33:39 +02:00
Ilia Denisov ff55d5de83 fix(gateway): forward the real client IP to the backend on every call
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The backend recorded 172.19.0.9 — the gateway's own docker connection address — as
the client IP for all users: the account's last-login IP shown in the admin console,
and it never reached the backend access log. The gateway forwarded the client IP as
X-Forwarded-For only on chat/feedback calls; every other backend call (including the
profile fetch that stamps last_login_ip) sent none, so the backend fell back to the
peer address.

Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and
set it once per request in the Connect edge, so the backend client injects
X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the
backend access log.

Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent
when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge).
2026-07-14 11:23:32 +02:00
45 changed files with 1188 additions and 53 deletions
+121 -1
View File
@@ -39,10 +39,13 @@ status — without re-deriving decisions.
| E7 | Admin, reports & catalog | 2 | DONE | | E7 | Admin, reports & catalog | 2 | DONE |
| E8 | Guest limits | — | DONE | | E8 | Guest limits | — | DONE |
| E9 | Tournament fee | future | TODO | | E9 | Tournament fee | future | TODO |
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). **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 **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 +895,123 @@ tournament-entry storage + pricing design.
--- ---
## E10 — Multi-shop direct rail + ИП fiscalization
**Status:** DONE (merged — the multi-shop PR) · **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), D42D44.
**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) — RESOLVED (owner 2026-07-14): cabinet-side only.** The owner keeps
Robokassa's cabinet auto-fiscalization (a generic чек is acceptable for the ИП); the optional
itemized-`Receipt` / `Email` code below is **dropped** — not needed. (Kept for context.)
- **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 (B1B3, 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.
---
## E11 — Payment availability kill switch + per-account override
**Status:** DONE · **Release 2** · depends on: E5 (intake/order), E7 (admin console) · mechanics:
PAYMENTS §9; decisions D45, D46.
**Delivered.** An operator disables purchases live from `/_gm` — a whole rail/channel or one account
— and the user sees a localized reason on the next purchase attempt. Motivation (owner): real apps
show broken payments with no explanation; this gives ops a live switch + a clear user message.
- **Rail kill switch** — `payments.rail_status` (per rail `direct:web` / `direct:android` / `vk` /
`telegram`): `enabled` + a per-language message, edited on the catalog page. **Fail-open** (no row
⇒ enabled, so payments are never accidentally killed). The intake gate — `CanPurchase` in
`handleWalletOrder`, before the order — returns `payment_unavailable` + the localized message;
orthogonal to the security gates.
- **Per-account override** — `payments.account_payment_override` (a row only for non-default; default
= no row, cleared by delete): allow / deny / default, edited on the user card. `allow` bypasses
**only** the ops rail switch, never the security gates (D46).
- **Wire** — the message rides an additive `ExecuteResponse.message` (envelope layer,
frozen-contract-safe; the gateway forwards a backend domain-error message via `DomainMessage`); the
client reads it into `GatewayError.message` and shows it on a `payment_unavailable` buy attempt.
- **Tests** — the pure gate `PurchaseGate` (unit, TDD); the store + gate + override end-to-end
(integration, migration `00016`); the client (svelte-check / vitest). **Docs** — PAYMENTS (+`_ru`),
decisions D45 / D46.
**Contour-safe:** additive migration (two new tables, no wipe); the wire add is additive; fail-open,
so nothing is disabled until an operator acts.
---
## Verification & CI (all stages) ## Verification & CI (all stages)
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a - Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
@@ -30,6 +30,18 @@
<div><button type="submit">Save</button></div> <div><button type="submit">Save</button></div>
</form> </form>
</section> </section>
<section class="panel"><h2>Payment availability (kill switch)</h2>
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in &ldquo;temporarily unavailable&rdquo;. Fail-open: a rail you never touch stays on. A per-user &ldquo;allow&rdquo; override (on a user card) bypasses this switch.</p>
{{range .Rails}}
<form class="form col" method="post" action="/_gm/catalog/rail-status">
<input type="hidden" name="rail" value="{{.Rail}}">
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> &mdash; purchases enabled</label>
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
<div><button type="submit">Save</button></div>
</form>
{{end}}
</section>
<section class="panel"><h2>Products</h2> <section class="panel"><h2>Products</h2>
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p> <p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
<table class="list"> <table class="list">
@@ -72,14 +72,24 @@
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}} {{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
</ul> </ul>
{{else}}<p class="note">no balances or benefits</p>{{end}} {{else}}<p class="note">no balances or benefits</p>{{end}}
<h3>Payment override</h3>
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
<label>Purchases for this account
<select name="override">
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
</select></label>
<div><button type="submit">Save</button></div>
</form>
<h3>Ledger</h3> <h3>Ledger</h3>
{{$uid := .ID}} {{$uid := .ID}}
{{if .Finance.Ledger}} {{if .Finance.Ledger}}
<table class="list"> <table class="list">
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead> <thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Finance.Ledger}} {{range .Finance.Ledger}}
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td> <tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr> <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}} {{end}}
</tbody> </tbody>
+18 -2
View File
@@ -198,6 +198,9 @@ type UserDetailView struct {
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present // Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
// is false when the payments domain is unwired. // is false when the payments domain is unwired.
Finance FinanceView Finance FinanceView
// PurchaseOverride is the account's per-account purchase override ("default"/"allow"/"deny"),
// shown in and edited from the user card's payment-override control.
PurchaseOverride string
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the // Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
// payments domain is unwired. // payments domain is unwired.
Grant GrantFormView Grant GrantFormView
@@ -232,8 +235,8 @@ type BenefitRow struct {
} }
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip // 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 // delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
// the pre-formatted time. // snapshot JSON and the pre-formatted time.
type LedgerRow struct { type LedgerRow struct {
Kind string Kind string
Source string Source string
@@ -242,6 +245,7 @@ type LedgerRow struct {
Product string Product string
Order string Order string
Provider string Provider string
Shop string
Snapshot string Snapshot string
At string At string
} }
@@ -686,6 +690,18 @@ type CatalogView struct {
RewardPayout int RewardPayout int
RewardDailyCap int RewardDailyCap int
RewardHourlyCap int RewardHourlyCap int
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
// in and edited from the page's payment-availability form.
Rails []RailStatusRow
}
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
type RailStatusRow struct {
Rail string
Enabled bool
MessageRU string
MessageEN string
} }
// ProductRow is one product in the catalog list: its composition, prices, the archived flag // ProductRow is one product in the catalog list: its composition, prices, the archived flag
+33 -11
View File
@@ -65,9 +65,9 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g. // RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact. // http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin // Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// leaves the direct order and Result-callback endpoints unregistered. // (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Config Robokassa robokassa.Shops
} }
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
@@ -157,11 +157,19 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
} }
robo := robokassa.Config{ // Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"), // the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"), // with no MerchantLogin is dropped (the rail stays dormant when none is configured).
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"), shops := robokassa.Shops{}
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1", 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{ c := Config{
@@ -181,7 +189,7 @@ func Load() (Config, error) {
GuestRetention: guestRetention, GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: robo, Robokassa: shops,
} }
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err 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) 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 == "") { for channel, shop := range c.Robokassa {
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is") 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 return nil
} }
@@ -276,3 +286,15 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
} }
return d, nil 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",
}
}
@@ -0,0 +1,83 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// TestPaymentAvailabilityKillSwitch exercises the per-rail kill switch + the per-account override
// end-to-end against Postgres: fail-open, a disabled rail with a localized message, per-rail
// granularity, and the allow/deny/default overrides.
func TestPaymentAvailabilityKillSwitch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
// Fail-open: an untouched rail is enabled.
if ok, _, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); err != nil || !ok {
t.Fatalf("fail-open: CanPurchase = %v/%v, want true", ok, err)
}
// Disable the web rail with a per-language message → blocked with that message, localized.
if err := svc.SetRailStatus(ctx, payments.RailDirectWeb, payments.RailAvailability{MessageRU: "Чиним", MessageEN: "Fixing"}); err != nil {
t.Fatalf("set rail status: %v", err)
}
if ok, reason, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "ru"); err != nil || ok || reason != "Чиним" {
t.Fatalf("disabled rail (ru): CanPurchase = %v/%q/%v, want false/Чиним", ok, reason, err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); ok || reason != "Fixing" {
t.Fatalf("disabled rail (en): = %v/%q, want false/Fixing", ok, reason)
}
// Per-rail granularity: another rail stays enabled.
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("android rail should stay enabled")
}
// A per-account "allow" override bypasses the disabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideAllow); err != nil {
t.Fatalf("set override allow: %v", err)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); !ok {
t.Fatalf("allow override should bypass the disabled rail")
}
if ov, err := svc.PurchaseOverrideFor(ctx, acc); err != nil || ov != payments.OverrideAllow {
t.Fatalf("PurchaseOverrideFor = %v/%v, want allow", ov, err)
}
// A "deny" override blocks even an enabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDeny); err != nil {
t.Fatalf("set override deny: %v", err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); ok || reason == "" {
t.Fatalf("deny override should block the enabled android rail with a reason, got %v/%q", ok, reason)
}
// Clearing the override (default) restores rail-driven behaviour.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDefault); err != nil {
t.Fatalf("clear override: %v", err)
}
if ov, _ := svc.PurchaseOverrideFor(ctx, acc); ov != payments.OverrideDefault {
t.Fatalf("after clear, override = %v, want default", ov)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("after clearing override, android rail should be enabled again")
}
// RailStatuses reflects the stored web-rail change and fills the rest fail-open.
all, err := svc.RailStatuses(ctx)
if err != nil {
t.Fatalf("rail statuses: %v", err)
}
if all[payments.RailDirectWeb].Enabled {
t.Fatalf("web rail should read disabled")
}
if !all[payments.RailVK].Enabled {
t.Fatalf("untouched vk rail should read enabled")
}
}
+107
View File
@@ -0,0 +1,107 @@
package payments
import "slices"
// PurchaseOverride is a per-account purchase override that forces purchases allowed or denied for
// one account regardless of the operational rail switch, or is absent (OverrideDefault) when the
// account follows the rail switch. The zero value is OverrideDefault, so a missing override row maps
// to it. OverrideAllow bypasses ONLY the operational rail switch — never the security / compliance
// gates (trusted platform, the D36 email anchor, the VK-iOS spend freeze, the min client version),
// which CreateOrder enforces separately.
type PurchaseOverride int
const (
// OverrideDefault follows the rail switch (no override row for the account).
OverrideDefault PurchaseOverride = iota
// OverrideAllow always allows the purchase, bypassing only the operational rail switch.
OverrideAllow
// OverrideDeny always denies the purchase for the account.
OverrideDeny
)
// RailAvailability is a payment rail's operational availability: whether purchases are enabled and,
// when disabled, the operator's explanation in each language, shown to the user on a purchase
// attempt. A rail with no status row is treated as enabled (fail-open — see the store), so the
// zero value is never used as a live "enabled" default.
type RailAvailability struct {
Enabled bool
MessageRU string
MessageEN string
}
// PurchaseGate decides whether an account may open a purchase order on a rail, from the per-account
// override and the rail's operational availability. It is the operational layer only — the caller
// still enforces the security gates. On a block it returns a reason localized to lang ("ru", else
// English). Fail-open: OverrideDefault on an enabled rail allows.
func PurchaseGate(override PurchaseOverride, rail RailAvailability, lang string) (ok bool, reason string) {
switch override {
case OverrideAllow:
return true, "" // bypasses only the ops switch; the security gates still apply upstream
case OverrideDeny:
return false, defaultUnavailable(lang) // a per-account block — a neutral reason
default: // OverrideDefault — follow the rail switch
if rail.Enabled {
return true, ""
}
return false, railMessage(rail, lang)
}
}
// railMessage returns the operator's rail-off message in lang, falling back to the other language
// and then to the built-in default when the operator left both blank.
func railMessage(rail RailAvailability, lang string) string {
if lang == "ru" {
if rail.MessageRU != "" {
return rail.MessageRU
}
if rail.MessageEN != "" {
return rail.MessageEN
}
} else {
if rail.MessageEN != "" {
return rail.MessageEN
}
if rail.MessageRU != "" {
return rail.MessageRU
}
}
return defaultUnavailable(lang)
}
// defaultUnavailable is the built-in localized "payments unavailable" reason used when the operator
// set no custom message, and for a per-account deny.
func defaultUnavailable(lang string) string {
if lang == "ru" {
return "Оплата временно недоступна. Попробуйте позже."
}
return "Payments are temporarily unavailable. Please try again later."
}
// Rail keys name the operational payment rails the kill switch and the per-account override key on.
// The direct rail is split per channel (D42); vk and telegram are single-rail.
const (
RailDirectWeb = "direct:web"
RailDirectAndroid = "direct:android"
RailVK = "vk"
RailTelegram = "telegram"
)
// KnownRails is the fixed set of rail keys, for the admin editor and for validation.
var KnownRails = []string{RailDirectWeb, RailDirectAndroid, RailVK, RailTelegram}
// RailKey maps a payment context to its operational rail key: the direct rail by channel subtype
// (android, else web), or the store rail's own name (vk / telegram).
func RailKey(kind Source, subtype string) string {
if kind == SourceDirect {
if subtype == "android" {
return RailDirectAndroid
}
return RailDirectWeb
}
return string(kind)
}
// isKnownRail reports whether rail is one of the fixed rail keys.
func isKnownRail(rail string) bool {
return slices.Contains(KnownRails, rail)
}
@@ -0,0 +1,40 @@
package payments
import "testing"
func TestPurchaseGate(t *testing.T) {
on := RailAvailability{Enabled: true}
offMsg := RailAvailability{Enabled: false, MessageRU: "Чиним", MessageEN: "Fixing"}
offNoMsg := RailAvailability{Enabled: false}
// OverrideDefault follows the rail switch.
if ok, _ := PurchaseGate(OverrideDefault, on, "en"); !ok {
t.Error("default + enabled rail should allow")
}
if ok, r := PurchaseGate(OverrideDefault, offMsg, "ru"); ok || r != "Чиним" {
t.Errorf("default + off rail (ru) = %v/%q, want false/Чиним", ok, r)
}
if ok, r := PurchaseGate(OverrideDefault, offMsg, "en"); ok || r != "Fixing" {
t.Errorf("default + off rail (en) = %v/%q, want false/Fixing", ok, r)
}
// Off rail with no custom message → the built-in default, localized (not the English default in ru).
if ok, r := PurchaseGate(OverrideDefault, offNoMsg, "ru"); ok || r != defaultUnavailable("ru") {
t.Errorf("default + off no-msg (ru) = %v/%q, want false + the ru default", ok, r)
}
// OverrideAllow bypasses the ops switch even when the rail is off.
if ok, r := PurchaseGate(OverrideAllow, offMsg, "en"); !ok || r != "" {
t.Errorf("allow + off rail = %v/%q, want true/empty (bypasses the ops switch)", ok, r)
}
// OverrideDeny blocks even when the rail is on, with a non-empty reason.
if ok, r := PurchaseGate(OverrideDeny, on, "en"); ok || r == "" {
t.Errorf("deny + on rail = %v/%q, want false + a reason", ok, r)
}
// The message falls back to the other language when only one is set.
if _, r := PurchaseGate(OverrideDefault, RailAvailability{MessageEN: "OnlyEN"}, "ru"); r != "OnlyEN" {
t.Errorf("off rail ru with only EN msg = %q, want OnlyEN (fallback)", r)
}
}
@@ -0,0 +1,63 @@
package payments
import (
"context"
"fmt"
"github.com/google/uuid"
)
// CanPurchase reports whether an account may open a purchase order on rail, combining the account's
// per-account override with the rail's operational switch (PurchaseGate). It is the operational
// availability layer only — the caller still enforces the security gates (trusted platform, the D36
// email anchor, the VK-iOS spend freeze, the min client version). On a block, reason is localized to
// lang for the user; err is only a store failure.
func (s *Service) CanPurchase(ctx context.Context, accountID uuid.UUID, rail, lang string) (ok bool, reason string, err error) {
ov, err := s.store.purchaseOverride(ctx, accountID)
if err != nil {
return false, "", err
}
avail, err := s.store.railAvailability(ctx, rail)
if err != nil {
return false, "", err
}
ok, reason = PurchaseGate(ov, avail, lang)
return ok, reason, nil
}
// RailStatuses returns every known rail's operational status for the admin editor, filling a rail
// with no stored row with the fail-open enabled default so the editor always shows one row per rail.
func (s *Service) RailStatuses(ctx context.Context) (map[string]RailAvailability, error) {
stored, err := s.store.allRailStatus(ctx)
if err != nil {
return nil, err
}
out := make(map[string]RailAvailability, len(KnownRails))
for _, rail := range KnownRails {
if a, ok := stored[rail]; ok {
out[rail] = a
} else {
out[rail] = RailAvailability{Enabled: true}
}
}
return out, nil
}
// SetRailStatus upserts a rail's operational status from the admin editor. It rejects an unknown rail
// key so a typo cannot create a dead row.
func (s *Service) SetRailStatus(ctx context.Context, rail string, a RailAvailability) error {
if !isKnownRail(rail) {
return fmt.Errorf("payments: unknown rail %q", rail)
}
return s.store.setRailStatus(ctx, rail, a, s.clock())
}
// PurchaseOverrideFor returns an account's purchase override (OverrideDefault when none is set).
func (s *Service) PurchaseOverrideFor(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
return s.store.purchaseOverride(ctx, accountID)
}
// SetPurchaseOverride sets or clears an account's purchase override (OverrideDefault deletes the row).
func (s *Service) SetPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride) error {
return s.store.setPurchaseOverride(ctx, accountID, ov, s.clock())
}
@@ -46,6 +46,7 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
amount: pack.price, amount: pack.price,
origin: method, origin: method,
provider: provider, provider: provider,
shop: directShop(cxt),
} }
if err := s.store.createOrder(ctx, o, s.clock()); err != nil { if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
return OrderResult{}, err 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 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 // 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 // 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). // and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
+3 -1
View File
@@ -42,7 +42,8 @@ type RiskInfo struct {
} }
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when // 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 { type LedgerEntry struct {
Kind string Kind string
Source string Source string
@@ -52,6 +53,7 @@ type LedgerEntry struct {
OrderID string OrderID string
Provider string Provider string
ProviderPaymentID string ProviderPaymentID string
Shop string
Snapshot string Snapshot string
CreatedAt time.Time CreatedAt time.Time
} }
@@ -0,0 +1,96 @@
package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// railAvailability reads a rail's operational availability. A missing row is fail-open: enabled with
// no message, so payments stay on unless an operator explicitly disabled the rail.
func (s *Store) railAvailability(ctx context.Context, rail string) (RailAvailability, error) {
var a RailAvailability
err := s.db.QueryRowContext(ctx,
`SELECT enabled, message_ru, message_en FROM payments.rail_status WHERE rail = $1`, rail).
Scan(&a.Enabled, &a.MessageRU, &a.MessageEN)
if errors.Is(err, sql.ErrNoRows) {
return RailAvailability{Enabled: true}, nil
}
if err != nil {
return RailAvailability{}, fmt.Errorf("payments: read rail status %q: %w", rail, err)
}
return a, nil
}
// allRailStatus reads every stored rail-status row for the admin editor, keyed by rail. Rails with
// no row are absent (the caller fills them with the fail-open enabled default).
func (s *Store) allRailStatus(ctx context.Context) (map[string]RailAvailability, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT rail, enabled, message_ru, message_en FROM payments.rail_status`)
if err != nil {
return nil, fmt.Errorf("payments: read rail statuses: %w", err)
}
defer rows.Close()
out := make(map[string]RailAvailability)
for rows.Next() {
var rail string
var a RailAvailability
if err := rows.Scan(&rail, &a.Enabled, &a.MessageRU, &a.MessageEN); err != nil {
return nil, fmt.Errorf("payments: scan rail status: %w", err)
}
out[rail] = a
}
return out, rows.Err()
}
// setRailStatus upserts a rail's operational status (admin).
func (s *Store) setRailStatus(ctx context.Context, rail string, a RailAvailability, now time.Time) error {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO payments.rail_status (rail, enabled, message_ru, message_en, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (rail) DO UPDATE SET enabled = $2, message_ru = $3, message_en = $4, updated_at = $5`,
rail, a.Enabled, a.MessageRU, a.MessageEN, now); err != nil {
return fmt.Errorf("payments: set rail status %q: %w", rail, err)
}
return nil
}
// purchaseOverride reads an account's purchase override; a missing row is OverrideDefault.
func (s *Store) purchaseOverride(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
var allow bool
err := s.db.QueryRowContext(ctx,
`SELECT allow FROM payments.account_payment_override WHERE account_id = $1`, accountID).Scan(&allow)
if errors.Is(err, sql.ErrNoRows) {
return OverrideDefault, nil
}
if err != nil {
return OverrideDefault, fmt.Errorf("payments: read purchase override %s: %w", accountID, err)
}
if allow {
return OverrideAllow, nil
}
return OverrideDeny, nil
}
// setPurchaseOverride upserts an account's override, or deletes the row for OverrideDefault (the
// default state is the absence of a row).
func (s *Store) setPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride, now time.Time) error {
if ov == OverrideDefault {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM payments.account_payment_override WHERE account_id = $1`, accountID); err != nil {
return fmt.Errorf("payments: clear purchase override %s: %w", accountID, err)
}
return nil
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO payments.account_payment_override (account_id, allow, updated_at) VALUES ($1, $2, $3)
ON CONFLICT (account_id) DO UPDATE SET allow = $2, updated_at = $3`,
accountID, ov == OverrideAllow, now); err != nil {
return fmt.Errorf("payments: set purchase override %s: %w", accountID, err)
}
return nil
}
+3 -2
View File
@@ -151,6 +151,7 @@ type newOrder struct {
amount Money amount Money
origin Source origin Source
provider string provider string
shop string
} }
// createOrder inserts a pending order. // 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.OrderID, table.Orders.AccountID, table.Orders.Platform,
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency, table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
table.Orders.Origin, table.Orders.Status, table.Orders.Provider, 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( ).VALUES(
o.orderID, o.accountID, o.platform, o.orderID, o.accountID, o.platform,
o.productID, o.amount.Minor(), string(o.amount.Currency()), o.productID, o.amount.Minor(), string(o.amount.Currency()),
string(o.origin), "pending", o.provider, string(o.origin), "pending", o.provider,
now, now, now, now, o.shop,
) )
if _, err := stmt.ExecContext(ctx, s.db); err != nil { if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: create order: %w", err) return fmt.Errorf("payments: create order: %w", err)
@@ -74,9 +74,39 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
CreatedAt: r.CreatedAt, 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 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. // allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) { func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
var rows []model.Ledger var rows []model.Ledger
@@ -25,4 +25,5 @@ type Orders struct {
ProviderPaymentID *string ProviderPaymentID *string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
Shop string
} }
@@ -29,6 +29,7 @@ type ordersTable struct {
ProviderPaymentID postgres.ColumnString ProviderPaymentID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz
Shop postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -82,9 +83,10 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id") ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn} ShopColumn = postgres.StringColumn("shop")
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn} allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn} mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
) )
return ordersTable{ return ordersTable{
@@ -103,6 +105,7 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
ProviderPaymentID: ProviderPaymentIDColumn, ProviderPaymentID: ProviderPaymentIDColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn, UpdatedAt: UpdatedAtColumn,
Shop: ShopColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -0,0 +1,13 @@
-- Multi-shop direct rail (E10/D44): record which Robokassa merchant shop (channel) issued a direct
-- order — "web" / "android" (ios later) — so the admin financial report can break direct payments
-- down by channel. Only the direct rail is per-channel; other rails leave it "". Additive only (a
-- defaulted column), applies forward via goose with no data rewrite (no contour wipe); an image
-- rollback ignores the column.
-- +goose Up
ALTER TABLE payments.orders
ADD COLUMN shop text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE payments.orders DROP COLUMN shop;
@@ -0,0 +1,26 @@
-- Payment availability controls (D45/D46): a per-rail operational kill switch with a localized message,
-- and a per-account purchase override. Both let an operator disable payments live from the admin —
-- a whole rail/channel, or one account — and explain why to the user on a purchase attempt. Additive
-- (new tables) — applies forward via goose with no data rewrite (no contour wipe); an image rollback
-- ignores both tables. Fail-open by design: a rail with no row is enabled; an account with no row
-- follows the rail switch.
-- +goose Up
CREATE TABLE payments.rail_status (
rail text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT true,
message_ru text NOT NULL DEFAULT '',
message_en text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE payments.account_payment_override (
account_id uuid PRIMARY KEY,
allow boolean NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE payments.account_payment_override;
DROP TABLE payments.rail_status;
+54
View File
@@ -0,0 +1,54 @@
package robokassa
// Shops is a set of Robokassa merchant shops keyed by channel — the subtype of the trusted
// X-Platform signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a
// payment to the per-channel shop for separate merchant accounts, accounting and receipts, while
// every shop still credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set
// leaves the direct order and Result-callback endpoints unregistered.
type Shops map[string]Config
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
// default: an unknown or empty channel routes here on the order side, and the legacy single-shop
// configuration seeds it.
const (
ChannelWeb = "web"
ChannelAndroid = "android"
)
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
// costs at most accounting accuracy, not money. The second result is false when neither the channel
// nor the web shop has a merchant login (the rail is unconfigured).
func (s Shops) Shop(channel string) (Config, bool) {
if channel != "" {
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
return c, true
}
}
if c, ok := s[ChannelWeb]; ok && c.MerchantLogin != "" {
return c, true
}
return Config{}, false
}
// Verifier returns the shop whose Password2 verifies a Result callback delivered to channel's
// dedicated Result route. Unlike Shop it does not fall back to web: each shop's callback is verified
// only by that shop's own credentials, so a route with no configured shop reports false (and its
// handler answers as unregistered). The second result is false when channel has no merchant login.
func (s Shops) Verifier(channel string) (Config, bool) {
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
return c, true
}
return Config{}, false
}
// Configured reports whether at least one shop has a merchant login (the direct rail is live).
func (s Shops) Configured() bool {
for _, c := range s {
if c.MerchantLogin != "" {
return true
}
}
return false
}
+52
View File
@@ -0,0 +1,52 @@
package robokassa
import "testing"
func TestShopsShopRouting(t *testing.T) {
shops := Shops{
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
}
// Order side: the exact channel wins.
if c, ok := shops.Shop(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
t.Errorf("Shop(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
}
// Order side: an unknown or empty channel falls back to the web shop (safe — always credits direct).
if c, ok := shops.Shop("ios"); !ok || c.MerchantLogin != "webshop" {
t.Errorf("Shop(ios) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
}
if c, ok := shops.Shop(""); !ok || c.MerchantLogin != "webshop" {
t.Errorf("Shop(empty) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
}
// No web shop and an unknown channel → unconfigured.
if _, ok := (Shops{ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}}).Shop("ios"); ok {
t.Error("Shop(ios) with no web shop returned ok, want false")
}
}
func TestShopsVerifierIsStrict(t *testing.T) {
shops := Shops{
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
}
// Callback side: the exact channel's own credentials, no web fallback (each callback is verified
// only by its own shop's Password2).
if c, ok := shops.Verifier(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
t.Errorf("Verifier(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
}
if _, ok := shops.Verifier("ios"); ok {
t.Error("Verifier(ios) returned ok, want false (no fallback)")
}
}
func TestShopsConfigured(t *testing.T) {
if (Shops{}).Configured() {
t.Error("an empty set reported configured")
}
if (Shops{ChannelWeb: {}}).Configured() {
t.Error("a shop with no merchant login reported configured")
}
if !(Shops{ChannelWeb: {MerchantLogin: "s", Password1: "1", Password2: "2"}}).Configured() {
t.Error("a configured shop reported not configured")
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func (s *Server) registerRoutes() {
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes. // 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/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment) s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" { if s.robokassa.Configured() {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult) s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
} }
} }
@@ -51,7 +51,16 @@ func (s *Server) consoleCatalog(c *gin.Context) {
s.consoleError(c, err) s.consoleError(c, err)
return return
} }
rails, err := s.payments.RailStatuses(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly} view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
for _, rail := range payments.KnownRails {
a := rails[rail]
view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN})
}
for _, p := range products { for _, p := range products {
view.Products = append(view.Products, catalogRow(p)) view.Products = append(view.Products, catalogRow(p))
} }
@@ -72,6 +81,23 @@ func (s *Server) consoleSetReward(c *gin.Context) {
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack) s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
} }
// consoleSetRailStatus toggles a payment rail's operational kill switch and its user-facing
// off-message (per language) from the catalog page's payment-availability form. An unchecked box
// disables the rail; an unknown rail is refused by the service.
func (s *Server) consoleSetRailStatus(c *gin.Context) {
rail := strings.TrimSpace(c.PostForm("rail"))
a := payments.RailAvailability{
Enabled: c.PostForm("enabled") == "on",
MessageRU: strings.TrimSpace(c.PostForm("message_ru")),
MessageEN: strings.TrimSpace(c.PostForm("message_en")),
}
if err := s.payments.SetRailStatus(c.Request.Context(), rail, a); err != nil {
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
return
}
s.renderConsoleMessage(c, "Saved", "payment availability was updated", catalogBack)
}
// catalogRow projects a product into its list row. // catalogRow projects a product into its list row.
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow { func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted} row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/users/:id/grant", s.consoleGrant) gm.POST("/users/:id/grant", s.consoleGrant)
gm.POST("/users/:id/grant-product", s.consoleGrantProduct) gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
gm.POST("/users/:id/refund", s.consoleRefund) gm.POST("/users/:id/refund", s.consoleRefund)
gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride)
gm.POST("/users/:id/delete", s.consoleDeleteUser) gm.POST("/users/:id/delete", s.consoleDeleteUser)
gm.GET("/reasons", s.consoleReasons) gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason) gm.POST("/reasons", s.consoleCreateReason)
@@ -113,6 +114,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/catalog", s.consoleCatalog) gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct) gm.POST("/catalog", s.consoleCreateProduct)
gm.POST("/catalog/reward", s.consoleSetReward) gm.POST("/catalog/reward", s.consoleSetReward)
gm.POST("/catalog/rail-status", s.consoleSetRailStatus)
gm.GET("/catalog/:id", s.consoleCatalogDetail) gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct) gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct) gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
@@ -461,6 +463,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err)) s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
} }
view.Grant = s.grantForm(ctx) view.Grant = s.grantForm(ctx)
if ov, oerr := s.payments.PurchaseOverrideFor(ctx, id); oerr == nil {
view.PurchaseOverride = overrideName(ov)
}
} }
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
} }
@@ -482,13 +487,54 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
for _, e := range stmt.Ledger { for _, e := range stmt.Ledger {
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{ fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta, 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), At: fmtTime(e.CreatedAt),
}) })
} }
return fv return fv
} }
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
func overrideName(ov payments.PurchaseOverride) string {
switch ov {
case payments.OverrideAllow:
return "allow"
case payments.OverrideDeny:
return "deny"
default:
return "default"
}
}
// consoleSetPurchaseOverride sets or clears an account's per-account purchase override (allow / deny
// / default) from the user card. "allow" bypasses only the operational rail switch, never the
// security gates; "default" clears the override (deletes the row).
func (s *Server) consoleSetPurchaseOverride(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not configured", back)
return
}
var ov payments.PurchaseOverride
switch c.PostForm("override") {
case "allow":
ov = payments.OverrideAllow
case "deny":
ov = payments.OverrideDeny
default:
ov = payments.OverrideDefault
}
if err := s.payments.SetPurchaseOverride(c.Request.Context(), id, ov); err != nil {
s.renderConsoleMessage(c, "Update failed", err.Error(), back)
return
}
s.renderConsoleMessage(c, "Saved", "the purchase override was updated", back)
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the // relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders. // user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow { func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
+29 -3
View File
@@ -12,6 +12,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"scrabble/backend/internal/payments" "scrabble/backend/internal/payments"
"scrabble/backend/internal/robokassa"
) )
// Ledger/order provider tags per rail. // Ledger/order provider tags per rail.
@@ -66,9 +67,25 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
// Operational availability gate (D45/D46): the per-rail kill switch + the per-account override,
// before any order is opened. Orthogonal to the security gates in the rail branches below — a
// per-account "allow" override bypasses only this switch, never those. The reason is localized to
// the account's language for the user.
lang := ""
if acc, aerr := s.accounts.GetByID(ctx, uid); aerr == nil {
lang = acc.PreferredLanguage
}
if ok, reason, aerr := s.payments.CanPurchase(ctx, uid, payments.RailKey(cxt.Kind, cxt.Subtype), lang); aerr != nil {
s.abortErr(c, aerr)
return
} else if !ok {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, errorResponse{Error: errorBody{Code: "payment_unavailable", Message: reason}})
return
}
switch cxt.Kind { switch cxt.Kind {
case payments.SourceDirect: 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"}}) c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return return
} }
@@ -89,7 +106,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
} }
c.JSON(http.StatusOK, walletOrderResponse{ c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(), 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, Rail: providerRobokassa,
}) })
case payments.SourceVK: case payments.SourceVK:
@@ -136,7 +153,16 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
for k, val := range params { for k, val := range params {
v.Set(k, val) 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 { if !ok {
s.log.Warn("robokassa result: bad signature") s.log.Warn("robokassa result: bad signature")
c.String(http.StatusBadRequest, "bad sign") c.String(http.StatusBadRequest, "bad sign")
+4 -4
View File
@@ -115,9 +115,9 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A // 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). // nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the // Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// order and Result-callback endpoints unregistered. // set leaves the order and Result-callback endpoints unregistered.
Robokassa robokassa.Config Robokassa robokassa.Shops
} }
// Server owns the gin engine, the underlying HTTP server and the readiness // Server owns the gin engine, the underlying HTTP server and the readiness
@@ -146,7 +146,7 @@ type Server struct {
ads *ads.Service ads *ads.Service
payments *payments.Service payments *payments.Service
gamelimits *gamelimits.Service gamelimits *gamelimits.Service
robokassa robokassa.Config robokassa robokassa.Shops
notifier notify.Publisher notifier notify.Publisher
console *adminconsole.Renderer console *adminconsole.Renderer
exportKey []byte exportKey []byte
+3
View File
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
zap.String("path", route), zap.String("path", route),
zap.Int("status", status), zap.Int("status", status),
zap.Duration("latency", elapsed), 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)...) fields = append(fields, TraceFieldsFromContext(ctx)...)
+12
View File
@@ -142,6 +142,18 @@ ROBOKASSA_MERCHANT_LOGIN=
ROBOKASSA_PASSWORD1= ROBOKASSA_PASSWORD1=
ROBOKASSA_PASSWORD2= ROBOKASSA_PASSWORD2=
ROBOKASSA_TEST= 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 ------------------------------------------------------ # --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban # Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
+1
View File
@@ -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_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_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_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 **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 secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
@@ -150,6 +150,13 @@
enabled: true enabled: true
state: started 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) --------------------------------------------------- # --- Swap file (OOM cushion) ---------------------------------------------------
# The prod main host is tight (1.9 GiB) and the per-container memory caps # 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 # (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
+12
View File
@@ -171,6 +171,18 @@ services:
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-} BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-} BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-} 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 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 # (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 # inherits). The admin console writes new version subdirectories here, and the
+6 -2
View File
@@ -151,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
and GCG are unaffected** (they stay decoded concrete characters, §9.1). and GCG are unaffected** (they stay decoded concrete characters, §9.1).
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects - **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated `X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the 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
gateway's REST client widens its keep-alive pool well past the stdlib default 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 of 2 idle connections per host; otherwise the per-request connection churn
exhausts ephemeral ports and burns gateway CPU under load (see exhausts ephemeral ports and burns gateway CPU under load (see
@@ -1496,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP 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 (`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 — private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the 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
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses 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 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 **bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
+15
View File
@@ -57,6 +57,21 @@ 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 VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
the stores. See §4. 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).
**Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel
(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the
user sees a localized reason on their next attempt (`payment_unavailable`, carried on the additive
`ExecuteResponse.message`). **Fail-open:** a rail with no status row is enabled. A per-account
`allow` override bypasses only this operational switch, never the security gates (D46).
## 3. Three operations, kept distinct ## 3. Three operations, kept distinct
Do not conflate these — they use different keys: Do not conflate these — they use different keys:
+55 -6
View File
@@ -234,11 +234,53 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
- **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты, - **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты,
гранты, возвраты, полная история — расширение существующей карточки гранты, возвраты, полная история — расширение существующей карточки
(`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27). (`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27).
- **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД - **D41. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП).**
(режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать **Robokassa** (direct) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa
нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально (провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате —
= не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK. отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК 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).
- **D45. Рубильник платежей per-rail + локализованное сообщение.** Оператор выключает покупки на
рельсе/канале (`direct:web`/`direct:android`/`vk`/`telegram`) живьём из `/_gm` (таблица
`payments.rail_status`, редактируется на странице каталога). **Fail-open:** нет строки → рельс
включён (случайно не убить платежи). Выключенный рельс на попытке покупки возвращает
`payment_unavailable` + сообщение админа на языке юзера (RU/EN; пусто → встроенное «временно
недоступно»). Сообщение едет клиенту через аддитивное `ExecuteResponse.message` (envelope-слой,
frozen-contract-safe). Гейт ортогонален security-гейтам.
- **D46. Per-user override покупок (allow/deny/default).** На карточке юзера (`/_gm`) —
переопределение на аккаунт: `allow` (всегда разрешить), `deny` (всегда запретить), `default` (по
рельс-рубильнику). Хранится в `payments.account_payment_override` строкой только для не-default
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
проверяются отдельно в CreateOrder.
## Заметки к оформлению документов ## Заметки к оформлению документов
@@ -254,10 +296,17 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
feedback-память `prefer-interview-mode` после plan mode. feedback-память `prefer-interview-mode` после plan mode.
## Все развилки закрыты (D1-D41) ## Все развилки закрыты (D1-D46)
Интервью завершено. Дальше — оформление документов и реализация по релизам. Интервью завершено. Дальше — оформление документов и реализация по релизам.
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
`direct` и входе email-only в standalone-приложениях (этап E10 в `PLAN.md`). Плюс D45-D46 —
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
## План внедрения (черновик PLAN.md — «слоями») ## План внедрения (черновик PLAN.md — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
+15
View File
@@ -57,6 +57,21 @@
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
сторов. См. §4. сторов. См. §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).
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
попытке видит локализованную причину (`payment_unavailable`, едет на аддитивном
`ExecuteResponse.message`). **Fail-open:** рельс без строки статуса — включён. Per-account `allow`
обходит только этот ops-рубильник, не security-гейты (D46).
## 3. Три операции, которые нельзя путать ## 3. Три операции, которые нельзя путать
Не смешивать — у них разные ключи: Не смешивать — у них разные ключи:
+8 -3
View File
@@ -498,10 +498,15 @@ type robokassaResultResp struct {
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the // 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 // 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. // Robokassa ("OK<InvId>"). params carries the provider's raw form fields; channel selects the
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) { // 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 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 return out.Response, err
} }
+27
View File
@@ -139,6 +139,26 @@ func platformFromContext(ctx context.Context) string {
return p 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; // 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 // 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 // platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
@@ -160,6 +180,13 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
if userID != "" { if userID != "" {
req.Header.Set("X-User-ID", 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 != "" { if clientIP != "" {
req.Header.Set("X-Forwarded-For", clientIP) req.Header.Set("X-Forwarded-For", clientIP)
} }
@@ -122,3 +122,44 @@ 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")
}
})
}
@@ -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)
}
}
}
+25 -2
View File
@@ -274,8 +274,11 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler()) mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single // 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/<channel>); 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/robokassa/result/", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler()) mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята.")) mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена.")) mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
@@ -418,6 +421,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType)) return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
} }
clientIP := peerIP(req.Peer().Addr, req.Header()) 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} tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth { if op.Auth {
@@ -465,6 +472,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
return connect.NewResponse(&edgev1.ExecuteResponse{ return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(), RequestId: req.Msg.GetRequestId(),
ResultCode: code, ResultCode: code,
Message: transcode.DomainMessage(err),
}), nil }), nil
} }
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err)) s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
@@ -633,6 +641,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 // 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 // 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, // "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
@@ -657,7 +680,7 @@ func (s *Server) robokassaResultHandler() http.Handler {
for k := range r.Form { for k := range r.Form {
params[k] = r.Form.Get(k) 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 { if err != nil {
s.log.Warn("robokassa result proxy failed", zap.Error(err)) s.log.Warn("robokassa result proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway) http.Error(w, "not accepted", http.StatusBadGateway)
+11
View File
@@ -189,6 +189,17 @@ func (r *Registry) Lookup(messageType string) (Op, bool) {
return op, ok return op, ok
} }
// DomainMessage returns the human-readable, already-localized reason a backend domain error carries
// for the user (e.g. an operator's payment-unavailable explanation), or "" when it carries none. It
// rides the Execute envelope's message field alongside the result code.
func DomainMessage(err error) string {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) {
return apiErr.Message
}
return ""
}
// DomainCode maps an error to a stable result code to surface in the Execute // DomainCode maps an error to a stable result code to surface in the Execute
// envelope, reporting false for an unexpected error the caller should treat as a // envelope, reporting false for an unexpected error the caller should treat as a
// transport-level internal failure. // transport-level internal failure.
+14 -2
View File
@@ -98,6 +98,10 @@ type ExecuteResponse struct {
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"` ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
// message is an optional, already-localized human-readable reason a domain outcome may carry for
// the user (e.g. a payment-unavailable explanation set by an operator). Additive and
// frozen-contract-safe; empty for the common case where result_code alone suffices.
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -153,6 +157,13 @@ func (x *ExecuteResponse) GetPayload() []byte {
return nil return nil
} }
func (x *ExecuteResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// SubscribeRequest opens the live stream. It is empty: the session is taken from // SubscribeRequest opens the live stream. It is empty: the session is taken from
// the Authorization header. // the Authorization header.
type SubscribeRequest struct { type SubscribeRequest struct {
@@ -262,13 +273,14 @@ const file_edge_v1_edge_proto_rawDesc = "" +
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" + "\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" + "\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x03 \x01(\tR\trequestId\"k\n" + "request_id\x18\x03 \x01(\tR\trequestId\"\x85\x01\n" +
"\x0fExecuteResponse\x12\x1d\n" + "\x0fExecuteResponse\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
"\vresult_code\x18\x02 \x01(\tR\n" + "\vresult_code\x18\x02 \x01(\tR\n" +
"resultCode\x12\x18\n" + "resultCode\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\"\x12\n" + "\apayload\x18\x03 \x01(\fR\apayload\x12\x18\n" +
"\amessage\x18\x04 \x01(\tR\amessage\"\x12\n" +
"\x10SubscribeRequest\"P\n" + "\x10SubscribeRequest\"P\n" +
"\x05Event\x12\x12\n" + "\x05Event\x12\x12\n" +
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
+4
View File
@@ -35,6 +35,10 @@ message ExecuteResponse {
string request_id = 1; string request_id = 1;
string result_code = 2; string result_code = 2;
bytes payload = 3; bytes payload = 3;
// message is an optional, already-localized human-readable reason a domain outcome may carry for
// the user (e.g. a payment-unavailable explanation set by an operator). Additive and
// frozen-contract-safe; empty for the common case where result_code alone suffices.
string message = 4;
} }
// SubscribeRequest opens the live stream. It is empty: the session is taken from // SubscribeRequest opens the live stream. It is empty: the session is taken from
+10 -1
View File
@@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file edge/v1/edge.proto. * Describes the file edge/v1/edge.proto.
*/ */
export const file_edge_v1_edge: GenFile = /*@__PURE__*/ export const file_edge_v1_edge: GenFile = /*@__PURE__*/
fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJLCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMIhIKEFN1YnNjcmliZVJlcXVlc3QiOAoFRXZlbnQSDAoEa2luZBgBIAEoCRIPCgdwYXlsb2FkGAIgASgMEhAKCGV2ZW50X2lkGAMgASgJMqUBCgdHYXRld2F5Ek4KB0V4ZWN1dGUSIC5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXF1ZXN0GiEuc2NyYWJibGUuZWRnZS52MS5FeGVjdXRlUmVzcG9uc2USSgoJU3Vic2NyaWJlEiIuc2NyYWJibGUuZWRnZS52MS5TdWJzY3JpYmVSZXF1ZXN0Ghcuc2NyYWJibGUuZWRnZS52MS5FdmVudDABQidaJXNjcmFiYmxlL2dhdGV3YXkvcHJvdG8vZWRnZS92MTtlZGdldjFiBnByb3RvMw"); fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJcCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMEg8KB21lc3NhZ2UYBCABKAkiEgoQU3Vic2NyaWJlUmVxdWVzdCI4CgVFdmVudBIMCgRraW5kGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEAoIZXZlbnRfaWQYAyABKAkypQEKB0dhdGV3YXkSTgoHRXhlY3V0ZRIgLnNjcmFiYmxlLmVkZ2UudjEuRXhlY3V0ZVJlcXVlc3QaIS5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXNwb25zZRJKCglTdWJzY3JpYmUSIi5zY3JhYmJsZS5lZGdlLnYxLlN1YnNjcmliZVJlcXVlc3QaFy5zY3JhYmJsZS5lZGdlLnYxLkV2ZW50MAFCJ1olc2NyYWJibGUvZ2F0ZXdheS9wcm90by9lZGdlL3YxO2VkZ2V2MWIGcHJvdG8z");
/** /**
* ExecuteRequest is the unary envelope. message_type selects the operation; * ExecuteRequest is the unary envelope. message_type selects the operation;
@@ -71,6 +71,15 @@ export type ExecuteResponse = Message<"scrabble.edge.v1.ExecuteResponse"> & {
* @generated from field: bytes payload = 3; * @generated from field: bytes payload = 3;
*/ */
payload: Uint8Array; payload: Uint8Array;
/**
* message is an optional, already-localized human-readable reason a domain outcome may carry for
* the user (e.g. a payment-unavailable explanation set by an operator). Additive and
* frozen-contract-safe; empty for the common case where result_code alone suffices.
*
* @generated from field: string message = 4;
*/
message: string;
}; };
/** /**
+3 -1
View File
@@ -118,7 +118,9 @@ export function createTransport(baseUrl: string): GatewayClient {
maintenanceRecovered(); maintenanceRecovered();
if (res.resultCode && res.resultCode !== 'ok') { if (res.resultCode && res.resultCode !== 'ok') {
if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired(); if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired();
throw new GatewayError(res.resultCode); // Carry the envelope's optional human-readable message (e.g. an operator's payment-unavailable
// explanation) so a caller that shows it to the user has the localized text.
throw new GatewayError(res.resultCode, res.message || undefined);
} }
return res.payload; return res.payload;
} }
+6
View File
@@ -200,7 +200,13 @@
openExternalUrl(order.redirectUrl); openExternalUrl(order.redirectUrl);
} }
} catch (e) { } catch (e) {
// A rail kill switch (or a per-account block) returns payment_unavailable with an operator's
// localized explanation — show it to the user instead of the generic error.
if (e instanceof GatewayError && e.code === 'payment_unavailable') {
showToast(e.message);
} else {
handleError(e); handleError(e);
}
} finally { } finally {
busyId = null; busyId = null;
} }