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.
97 lines
3.6 KiB
Go
97 lines
3.6 KiB
Go
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
|
|
}
|