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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user