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
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:
@@ -39,7 +39,8 @@ 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+ | 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
|
||||||
@@ -896,7 +897,7 @@ tournament-entry storage + pricing design.
|
|||||||
|
|
||||||
## E10 — Multi-shop direct rail + ИП fiscalization
|
## 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) ·
|
the `robokassa` adapter, the Result callback), E7 (the per-user report — channel breakdown) ·
|
||||||
mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42–D44.
|
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
|
- Keep the legacy single route alive (shop = `web`) through the expand-contract window until the
|
||||||
cabinet Result URLs are cut over.
|
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 + ОФД + СНО).
|
- **Owner/cabinet:** enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО).
|
||||||
Required for ИП regardless of code.
|
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)
|
## 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 “temporarily unavailable”. Fail-open: a rail you never touch stays on. A per-user “allow” 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> — 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,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}}
|
{{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}}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -687,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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
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)
|
||||||
}
|
}
|
||||||
@@ -489,6 +494,47 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ 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:
|
||||||
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
||||||
|
|||||||
@@ -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
|
anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source
|
||||||
regardless of the number of shops (D41).
|
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:
|
||||||
|
|||||||
@@ -268,6 +268,19 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
`shop` (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для
|
`shop` (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для
|
||||||
разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет
|
разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет
|
||||||
(всегда `direct`, D42).
|
(всегда `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.
|
feedback-память `prefer-interview-mode` после plan mode.
|
||||||
|
|
||||||
## Все развилки закрыты (D1-D44)
|
## Все развилки закрыты (D1-D46)
|
||||||
|
|
||||||
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
||||||
|
|
||||||
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
|
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
|
||||||
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
|
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
|
||||||
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
|
`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 — «слоями»)
|
## План внедрения (черновик PLAN.md — «слоями»)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ Robokassa **на канал** — `web` и `android` (RuStore), позже `ios`
|
|||||||
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
|
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
|
||||||
одним ИП) — один источник независимо от числа магазинов (D41).
|
одним ИП) — один источник независимо от числа магазинов (D41).
|
||||||
|
|
||||||
|
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
|
||||||
|
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
|
||||||
|
попытке видит локализованную причину (`payment_unavailable`, едет на аддитивном
|
||||||
|
`ExecuteResponse.message`). **Fail-open:** рельс без строки статуса — включён. Per-account `allow`
|
||||||
|
обходит только этот ops-рубильник, не security-гейты (D46).
|
||||||
|
|
||||||
## 3. Три операции, которые нельзя путать
|
## 3. Три операции, которые нельзя путать
|
||||||
|
|
||||||
Не смешивать — у них разные ключи:
|
Не смешивать — у них разные ключи:
|
||||||
|
|||||||
@@ -472,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))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user