feat(payment): admin-editable ZarinPal settings + in-panel test payment
CI/CD / CI · Web (tsc) (push) Successful in 1m33s
CI/CD / Deploy · full stack (push) Failing after 20s

Lets the broker's ZarinPal merchant / sandbox / amount-unit be set from
Admin → درگاه پرداخت (persisted in payment.settings) instead of env +
redeploy, and adds a per-app "test payment" button that mints a real
ZarinPal StartPay link straight from the panel — no site wiring needed.

- migration 33_payment_settings.sql: singleton payment.settings + a
  transactions.is_test column. (33, not 32 — 32 is content_render_engine.)
- broker read-path precedence: per-client override > DB settings > env.
- POST /v1/admin/clients/:id/test-payment + GET/PUT /v1/admin/settings.
- admin UI: «تنظیمات زرین‌پال» tab + «پرداخت آزمایشی» button.

Adversarial-review fixes (2 confirmed HIGH):
- do NOT pre-seed the settings row — a seeded sandbox=TRUE default would
  override a production ZARINPAL_SANDBOX=false env and silently route real
  payments to sandbox.zarinpal.com until an admin untouched the toggle.
  No row → env governs until an admin saves.
- test transactions are tagged is_test and the webhook dispatcher skips
  them, so an admin smoke-test can never notify (or credit) a real client,
  regardless of metadata. Broker-authoritative, not consumer-dependent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-26 00:47:10 +03:30
parent 3748b1c8d8
commit 62ea110605
9 changed files with 371 additions and 24 deletions
@@ -24,8 +24,13 @@ func NewDispatcher(store *db.Store) *Dispatcher {
}
// Enqueue builds the signed payload for a finished transaction and queues delivery.
// No-op if the client has no webhook_url configured.
// No-op if the client has no webhook_url configured, or if this is an admin
// smoke-test transaction (broker-authoritative: a test must never notify — and
// therefore never credit — a real client, regardless of metadata).
func (d *Dispatcher) Enqueue(ctx context.Context, client *models.ClientApp, t *models.Transaction, nowUnix int64) {
if t.IsTest {
return
}
if client.WebhookURL == nil || *client.WebhookURL == "" {
return
}