From 1507ceb793f89d4079b6cd7de3411818a217741c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 14 Jul 2026 16:29:41 +0200 Subject: [PATCH] feat(payments): live payment-availability kill switch + per-account override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 38 ++++++- .../templates/pages/catalog.gohtml | 12 ++ .../templates/pages/user_detail.gohtml | 10 ++ backend/internal/adminconsole/views.go | 15 +++ .../inttest/payment_availability_test.go | 83 ++++++++++++++ backend/internal/payments/availability.go | 107 ++++++++++++++++++ .../internal/payments/availability_test.go | 40 +++++++ .../internal/payments/service_availability.go | 63 +++++++++++ .../internal/payments/store_availability.go | 96 ++++++++++++++++ .../migrations/00016_payment_availability.sql | 26 +++++ .../internal/server/handlers_admin_catalog.go | 26 +++++ .../internal/server/handlers_admin_console.go | 46 ++++++++ backend/internal/server/handlers_intake.go | 15 +++ docs/PAYMENTS.md | 6 + docs/PAYMENTS_DECISIONS_ru.md | 19 +++- docs/PAYMENTS_ru.md | 6 + gateway/internal/connectsrv/server.go | 1 + gateway/internal/transcode/transcode.go | 11 ++ gateway/proto/edge/v1/edge.pb.go | 24 +++- gateway/proto/edge/v1/edge.proto | 4 + ui/src/gen/edge/v1/edge_pb.ts | 11 +- ui/src/lib/transport.ts | 4 +- ui/src/screens/Wallet.svelte | 8 +- 23 files changed, 657 insertions(+), 14 deletions(-) create mode 100644 backend/internal/inttest/payment_availability_test.go create mode 100644 backend/internal/payments/availability.go create mode 100644 backend/internal/payments/availability_test.go create mode 100644 backend/internal/payments/service_availability.go create mode 100644 backend/internal/payments/store_availability.go create mode 100644 backend/internal/postgres/migrations/00016_payment_availability.sql diff --git a/PLAN.md b/PLAN.md index 9c99fcd..987302c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -39,7 +39,8 @@ status — without re-deriving decisions. | E7 | Admin, reports & catalog | 2 | DONE | | E8 | Guest limits | — | DONE | | E9 | Tournament fee | future | TODO | -| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | TODO | +| 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 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in @@ -896,7 +897,7 @@ tournament-entry storage + pricing design. ## E10 — Multi-shop direct rail + ИП fiscalization -**Status:** TODO · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`, +**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), D42–D44. @@ -940,7 +941,9 @@ by the **trusted** `X-Platform` subtype, never a client field; unknown → `web` - Keep the legacy single route alive (shop = `web`) through the expand-contract window until the cabinet Result URLs are cut over. -**B4 — ИП fiscalization (D41 rev) — orthogonal, gated on the owner's ИП/ОФД/СНО.** +**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. @@ -980,6 +983,35 @@ 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) - Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a diff --git a/backend/internal/adminconsole/templates/pages/catalog.gohtml b/backend/internal/adminconsole/templates/pages/catalog.gohtml index 2c94452..f53fe84 100644 --- a/backend/internal/adminconsole/templates/pages/catalog.gohtml +++ b/backend/internal/adminconsole/templates/pages/catalog.gohtml @@ -30,6 +30,18 @@
+

Payment availability (kill switch)

+

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 “temporarily unavailable”. Fail-open: a rail you never touch stays on. A per-user “allow” override (on a user card) bypasses this switch.

+{{range .Rails}} +
+ + + + +
+
+{{end}} +

Products

Showing {{if .ShowAll}}all products (active + archived) — active only{{else}}active products — show all (incl. archived){{end}}.

diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 97eef6e..30bc5b3 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -72,6 +72,16 @@ {{if or .Finance.Abuse .Finance.Loss}}
  • Refund risk {{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}
  • {{end}} {{else}}

    no balances or benefits

    {{end}} +

    Payment override

    + + +
    +

    Ledger

    {{$uid := .ID}} {{if .Finance.Ledger}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 44afe8b..4099425 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -198,6 +198,9 @@ type UserDetailView struct { // Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present // is false when the payments domain is unwired. 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 // payments domain is unwired. Grant GrantFormView @@ -687,6 +690,18 @@ type CatalogView struct { RewardPayout int RewardDailyCap 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 diff --git a/backend/internal/inttest/payment_availability_test.go b/backend/internal/inttest/payment_availability_test.go new file mode 100644 index 0000000..fbce2e6 --- /dev/null +++ b/backend/internal/inttest/payment_availability_test.go @@ -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") + } +} diff --git a/backend/internal/payments/availability.go b/backend/internal/payments/availability.go new file mode 100644 index 0000000..0314658 --- /dev/null +++ b/backend/internal/payments/availability.go @@ -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) +} diff --git a/backend/internal/payments/availability_test.go b/backend/internal/payments/availability_test.go new file mode 100644 index 0000000..46be677 --- /dev/null +++ b/backend/internal/payments/availability_test.go @@ -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) + } +} diff --git a/backend/internal/payments/service_availability.go b/backend/internal/payments/service_availability.go new file mode 100644 index 0000000..b0253c7 --- /dev/null +++ b/backend/internal/payments/service_availability.go @@ -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()) +} diff --git a/backend/internal/payments/store_availability.go b/backend/internal/payments/store_availability.go new file mode 100644 index 0000000..41ebcdd --- /dev/null +++ b/backend/internal/payments/store_availability.go @@ -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 +} diff --git a/backend/internal/postgres/migrations/00016_payment_availability.sql b/backend/internal/postgres/migrations/00016_payment_availability.sql new file mode 100644 index 0000000..85af927 --- /dev/null +++ b/backend/internal/postgres/migrations/00016_payment_availability.sql @@ -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; diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go index a0d7686..e43abf1 100644 --- a/backend/internal/server/handlers_admin_catalog.go +++ b/backend/internal/server/handlers_admin_catalog.go @@ -51,7 +51,16 @@ func (s *Server) consoleCatalog(c *gin.Context) { s.consoleError(c, err) 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} + 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 { 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) } +// 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. func catalogRow(p payments.AdminProduct) adminconsole.ProductRow { row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 0889050..abe2297 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/grant", s.consoleGrant) gm.POST("/users/:id/grant-product", s.consoleGrantProduct) gm.POST("/users/:id/refund", s.consoleRefund) + gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride) gm.POST("/users/:id/delete", s.consoleDeleteUser) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) @@ -113,6 +114,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/catalog", s.consoleCatalog) gm.POST("/catalog", s.consoleCreateProduct) gm.POST("/catalog/reward", s.consoleSetReward) + gm.POST("/catalog/rail-status", s.consoleSetRailStatus) gm.GET("/catalog/:id", s.consoleCatalogDetail) gm.POST("/catalog/:id", s.consoleUpdateProduct) 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)) } 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) } @@ -489,6 +494,47 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView { 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 // user card renders. func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow { diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index 01d1f89..813b751 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -67,6 +67,21 @@ func (s *Server) handleWalletOrder(c *gin.Context) { s.abortErr(c, err) 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 { case payments.SourceDirect: shop, ok := s.robokassa.Shop(cxt.Subtype) diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 84f1f68..f2d9a10 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -66,6 +66,12 @@ Standalone apps (Android/iOS) sign in by email only, so a direct purchase always 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 Do not conflate these — they use different keys: diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 088bec9..f8bf5ba 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -268,6 +268,19 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз `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. ## Заметки к оформлению документов @@ -283,14 +296,16 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить feedback-память `prefer-interview-mode` после plan mode. -## Все развилки закрыты (D1-D44) +## Все развилки закрыты (D1-D46) Интервью завершено. Дальше — оформление документов и реализация по релизам. **Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит `direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке -`direct` и входе email-only в standalone-приложениях. Реализация — новый этап E10 в `PLAN.md`. +`direct` и входе email-only в standalone-приложениях (этап E10 в `PLAN.md`). Плюс D45-D46 — +рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация +(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца). ## План внедрения (черновик PLAN.md — «слоями») diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index c8f4886..cc1bdb2 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -66,6 +66,12 @@ Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` у 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. Три операции, которые нельзя путать Не смешивать — у них разные ключи: diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 152b1ef..beca627 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -472,6 +472,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut return connect.NewResponse(&edgev1.ExecuteResponse{ RequestId: req.Msg.GetRequestId(), ResultCode: code, + Message: transcode.DomainMessage(err), }), nil } s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err)) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index e3c60e4..6fc60b5 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -189,6 +189,17 @@ func (r *Registry) Lookup(messageType string) (Op, bool) { 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 // envelope, reporting false for an unexpected error the caller should treat as a // transport-level internal failure. diff --git a/gateway/proto/edge/v1/edge.pb.go b/gateway/proto/edge/v1/edge.pb.go index 95c7ba2..992def3 100644 --- a/gateway/proto/edge/v1/edge.pb.go +++ b/gateway/proto/edge/v1/edge.pb.go @@ -94,10 +94,14 @@ func (x *ExecuteRequest) GetRequestId() string { // ExecuteResponse is the unary reply. result_code is "ok" on success or a stable // error code; payload is the FlatBuffers-encoded response body (empty on error). type ExecuteResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` - Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + 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 sizeCache protoimpl.SizeCache } @@ -153,6 +157,13 @@ func (x *ExecuteResponse) GetPayload() []byte { 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 // the Authorization header. type SubscribeRequest struct { @@ -262,13 +273,14 @@ const file_edge_v1_edge_proto_rawDesc = "" + "\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" + "\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\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" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" + "\vresult_code\x18\x02 \x01(\tR\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" + "\x05Event\x12\x12\n" + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" + diff --git a/gateway/proto/edge/v1/edge.proto b/gateway/proto/edge/v1/edge.proto index 447c63f..e2ab4b3 100644 --- a/gateway/proto/edge/v1/edge.proto +++ b/gateway/proto/edge/v1/edge.proto @@ -35,6 +35,10 @@ message ExecuteResponse { string request_id = 1; string result_code = 2; 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 diff --git a/ui/src/gen/edge/v1/edge_pb.ts b/ui/src/gen/edge/v1/edge_pb.ts index db58ee8..cbf74d0 100644 --- a/ui/src/gen/edge/v1/edge_pb.ts +++ b/ui/src/gen/edge/v1/edge_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file edge/v1/edge.proto. */ export const file_edge_v1_edge: GenFile = /*@__PURE__*/ - fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJLCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMIhIKEFN1YnNjcmliZVJlcXVlc3QiOAoFRXZlbnQSDAoEa2luZBgBIAEoCRIPCgdwYXlsb2FkGAIgASgMEhAKCGV2ZW50X2lkGAMgASgJMqUBCgdHYXRld2F5Ek4KB0V4ZWN1dGUSIC5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXF1ZXN0GiEuc2NyYWJibGUuZWRnZS52MS5FeGVjdXRlUmVzcG9uc2USSgoJU3Vic2NyaWJlEiIuc2NyYWJibGUuZWRnZS52MS5TdWJzY3JpYmVSZXF1ZXN0Ghcuc2NyYWJibGUuZWRnZS52MS5FdmVudDABQidaJXNjcmFiYmxlL2dhdGV3YXkvcHJvdG8vZWRnZS92MTtlZGdldjFiBnByb3RvMw"); + fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJcCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMEg8KB21lc3NhZ2UYBCABKAkiEgoQU3Vic2NyaWJlUmVxdWVzdCI4CgVFdmVudBIMCgRraW5kGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEAoIZXZlbnRfaWQYAyABKAkypQEKB0dhdGV3YXkSTgoHRXhlY3V0ZRIgLnNjcmFiYmxlLmVkZ2UudjEuRXhlY3V0ZVJlcXVlc3QaIS5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXNwb25zZRJKCglTdWJzY3JpYmUSIi5zY3JhYmJsZS5lZGdlLnYxLlN1YnNjcmliZVJlcXVlc3QaFy5zY3JhYmJsZS5lZGdlLnYxLkV2ZW50MAFCJ1olc2NyYWJibGUvZ2F0ZXdheS9wcm90by9lZGdlL3YxO2VkZ2V2MWIGcHJvdG8z"); /** * 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; */ 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; }; /** diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 216bc78..82b5233 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -118,7 +118,9 @@ export function createTransport(baseUrl: string): GatewayClient { maintenanceRecovered(); if (res.resultCode && res.resultCode !== 'ok') { 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; } diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index ff4c1c5..e3f5ce9 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -200,7 +200,13 @@ openExternalUrl(order.redirectUrl); } } catch (e) { - handleError(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); + } } finally { busyId = null; }