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.
This commit is contained in:
Ilia Denisov
2026-07-14 16:29:41 +02:00
parent 4b3c9d4fdd
commit 1507ceb793
23 changed files with 657 additions and 14 deletions
@@ -30,6 +30,18 @@
<div><button type="submit">Save</button></div>
</form>
</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>
<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">
@@ -72,6 +72,16 @@
{{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>
{{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>
{{$uid := .ID}}
{{if .Finance.Ledger}}
+15
View File
@@ -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
@@ -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())
}
@@ -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
}
@@ -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;
@@ -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}
@@ -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 {
@@ -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)