1507ceb793
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.
108 lines
4.0 KiB
Go
108 lines
4.0 KiB
Go
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)
|
|
}
|