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.
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.
Accept real money via Telegram Stars (XTR) — the third intake rail
alongside Robokassa (direct) and VK Votes.
Only the bot reaches Telegram, so the rail funnels through the reverse
mTLS bot-link:
- the gateway mints the invoice on a CreateInvoice command (the bot
calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice);
- the bot gates each pre_checkout_query via a ValidatePreCheckout unary
(the order must exist, be still creditable and not already paid — the
reusable-invoice double-pay guard; the decline reason is localised to
the order account's language);
- a completed successful_payment is queued in a durable pure-Go SQLite
outbox and forwarded via a ForwardPayment unary, credited once
(idempotent on telegram_payment_charge_id, honours an expired order),
re-driven on restart and every 30s.
The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays
inert until a chip pack carries an XTR price, so seeding a Stars price in
the admin is the go-live.
Tests: backend integration (order->forward->credit once, duplicate,
pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) +
executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the
platform/telegram README, PLAN.
Wire the VK Mini Apps ("голоса") rail end to end, reusing the intake engine. The
wallet.order endpoint branches by rail: a VK context opens a pending order
(provider vk) and returns its id, which the client passes to VKWebAppShowOrderBox.
VK's two-phase payment callback is verified at the gateway with the app protected
key (GATEWAY_VK_APP_SECRET) and proxied to a backend intake handler: get_item
returns the ordered pack's title and vote price; a chargeable order_status_change
credits the vk segment exactly once (the same Fund, idempotent on VK's own order
id) and records a succeeded event, so the dispatcher push refreshes the wallet.
Integration test for the VK order->credit path.
Add the public /pay/robokassa/result callback proxy (rate-limited; forwards the
provider's form parameters to the backend intake, the single writer, and echoes
its "OK<InvId>" back to Robokassa) and the /pay/robokassa/{success,fail}
browser-return redirects into the app. Route /pay/* to the gateway in the
contour Caddyfile so the callback reaches the edge, not the landing catch-all.
The backend intake now returns the echo body as JSON for the gateway to relay.
Wire the Robokassa direct rail into the backend transport. POST
/api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the
direct rail) opens a pending order and returns the signed Robokassa payment
URL. The internal, gateway-only /payments/robokassa/result endpoint verifies
the Result signature, credits the matched order exactly once via Fund (honoured
even if expired), records a succeeded payment event, and answers Robokassa's
"OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check
(D36), the payment_events writer, and a periodic pending-order reaper. The
routes register only when a Robokassa merchant login is configured.