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
+3
View File
@@ -67,12 +67,15 @@ func main() {
// ── Admin API (FlatRender admin JWT) ─────────────────────────────────────
admin := v1.Group("/admin", middleware.JWTAuth(cfg.JWTSecret), middleware.RequireAdmin())
{
admin.GET("/settings", adminH.GetSettings)
admin.PUT("/settings", adminH.UpdateSettings)
admin.GET("/clients", adminH.List)
admin.POST("/clients", adminH.Create)
admin.GET("/clients/:id", adminH.Get)
admin.PUT("/clients/:id", adminH.Update)
admin.DELETE("/clients/:id", adminH.Delete)
admin.POST("/clients/:id/rotate-secret", adminH.RotateSecret)
admin.POST("/clients/:id/test-payment", payH.AdminTest)
admin.GET("/transactions", adminH.ListTransactions)
}
+34 -5
View File
@@ -22,6 +22,35 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// ── Global settings (singleton row id=1) ─────────────────────────────────────
func (s *Store) GetSettings(ctx context.Context) (*models.Settings, error) {
var st models.Settings
err := s.pool.QueryRow(ctx, `
SELECT zarinpal_merchant_id, zarinpal_sandbox, zarinpal_amount_unit, updated_at
FROM payment.settings WHERE id = 1`).Scan(
&st.ZarinPalMerchantID, &st.ZarinPalSandbox, &st.ZarinPalAmountUnit, &st.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &st, err
}
func (s *Store) UpdateSettings(ctx context.Context, merchant string, sandbox bool, unit string) (*models.Settings, error) {
var st models.Settings
err := s.pool.QueryRow(ctx, `
INSERT INTO payment.settings (id, zarinpal_merchant_id, zarinpal_sandbox, zarinpal_amount_unit)
VALUES (1, $1, $2, $3)
ON CONFLICT (id) DO UPDATE SET
zarinpal_merchant_id = EXCLUDED.zarinpal_merchant_id,
zarinpal_sandbox = EXCLUDED.zarinpal_sandbox,
zarinpal_amount_unit = EXCLUDED.zarinpal_amount_unit
RETURNING zarinpal_merchant_id, zarinpal_sandbox, zarinpal_amount_unit, updated_at`,
merchant, sandbox, unit).Scan(
&st.ZarinPalMerchantID, &st.ZarinPalSandbox, &st.ZarinPalAmountUnit, &st.UpdatedAt)
return &st, err
}
// ── Client apps ───────────────────────────────────────────────────────────────
const clientCols = `id, tenant_id, name, slug, api_key, secret,
@@ -133,7 +162,7 @@ func (s *Store) DeleteClientApp(ctx context.Context, id uuid.UUID) error {
const txnCols = `id, client_app_id, status, gateway, amount_rial, currency, description,
client_ref, return_url, metadata, payer_mobile, payer_email,
authority, ref_id, card_pan, fee_rial, gateway_response, failure_reason,
paid_at, failed_at, expires_at, created_at, updated_at`
paid_at, failed_at, expires_at, created_at, updated_at, is_test`
func scanTxn(row pgx.Row) (*models.Transaction, error) {
var t models.Transaction
@@ -142,7 +171,7 @@ func scanTxn(row pgx.Row) (*models.Transaction, error) {
&t.ID, &t.ClientAppID, &t.Status, &t.Gateway, &t.AmountRial, &t.Currency, &t.Description,
&t.ClientRef, &t.ReturnURL, &meta, &t.PayerMobile, &t.PayerEmail,
&t.Authority, &t.RefID, &t.CardPan, &t.FeeRial, &gwResp, &t.FailureReason,
&t.PaidAt, &t.FailedAt, &t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
&t.PaidAt, &t.FailedAt, &t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt, &t.IsTest,
); err != nil {
return nil, err
}
@@ -159,11 +188,11 @@ func (s *Store) CreateTransaction(ctx context.Context, t *models.Transaction) (*
row := s.pool.QueryRow(ctx, `
INSERT INTO payment.transactions
(client_app_id, status, gateway, amount_rial, currency, description,
client_ref, return_url, metadata, payer_mobile, payer_email, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
client_ref, return_url, metadata, payer_mobile, payer_email, expires_at, is_test)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING `+txnCols,
t.ClientAppID, t.Status, t.Gateway, t.AmountRial, t.Currency, t.Description,
t.ClientRef, t.ReturnURL, meta, t.PayerMobile, t.PayerEmail, t.ExpiresAt)
t.ClientRef, t.ReturnURL, meta, t.PayerMobile, t.PayerEmail, t.ExpiresAt, t.IsTest)
return scanTxn(row)
}
@@ -3,6 +3,7 @@ package handlers
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"regexp"
"strconv"
@@ -44,6 +45,45 @@ type clientInput struct {
IsActive *bool `json:"is_active"`
}
// ── Global ZarinPal settings (admin-editable) ────────────────────────────────
func (h *AdminHandler) GetSettings(c *gin.Context) {
s, err := h.store.GetSettings(c.Request.Context())
if err != nil {
if errors.Is(err, db.ErrNotFound) {
// Row missing (table exists) — return sane defaults so the form renders.
c.JSON(http.StatusOK, models.Settings{ZarinPalSandbox: true, ZarinPalAmountUnit: "rial"})
return
}
// Most likely the table doesn't exist yet — tell the admin to run migration 32.
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func (h *AdminHandler) UpdateSettings(c *gin.Context) {
var in struct {
ZarinPalMerchantID string `json:"zarinpal_merchant_id"`
ZarinPalSandbox bool `json:"zarinpal_sandbox"`
ZarinPalAmountUnit string `json:"zarinpal_amount_unit"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid body"})
return
}
unit := strings.ToLower(strings.TrimSpace(in.ZarinPalAmountUnit))
if unit != "toman" {
unit = "rial"
}
s, err := h.store.UpdateSettings(c.Request.Context(), strings.TrimSpace(in.ZarinPalMerchantID), in.ZarinPalSandbox, unit)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func (h *AdminHandler) List(c *gin.Context) {
clients, err := h.store.ListClientApps(c.Request.Context())
if err != nil {
+94 -13
View File
@@ -1,6 +1,7 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -31,23 +32,37 @@ func NewPayHandler(store *db.Store, zp *zarinpal.Client, disp *Dispatcher, cfg c
return &PayHandler{store: store, zp: zp, disp: disp, cfg: cfg}
}
// merchantFor resolves the ZarinPal merchant + sandbox flag for a client
// (per-client override falls back to the broker default).
func (h *PayHandler) merchantFor(client *models.ClientApp) (string, bool) {
merchant := h.cfg.ZarinPalMerchantID
// effective resolves the ZarinPal merchant / sandbox flag / amount unit for a
// client. Precedence: per-client override > DB settings (admin-editable) > env
// default. DB settings are the source of truth once an admin saves them; env is
// only the fallback when the settings row is missing/unreachable.
func (h *PayHandler) effective(ctx context.Context, client *models.ClientApp) (merchant string, sandbox bool, unit string) {
merchant = h.cfg.ZarinPalMerchantID
sandbox = h.cfg.ZarinPalSandbox
unit = h.cfg.ZarinPalAmountUnit
if s, err := h.store.GetSettings(ctx); err == nil && s != nil {
if s.ZarinPalMerchantID != "" {
merchant = s.ZarinPalMerchantID
}
sandbox = s.ZarinPalSandbox
if s.ZarinPalAmountUnit != "" {
unit = s.ZarinPalAmountUnit
}
}
if client.ZarinPalMerchantID != nil && *client.ZarinPalMerchantID != "" {
merchant = *client.ZarinPalMerchantID
}
sandbox := h.cfg.ZarinPalSandbox
if client.ZarinPalSandbox != nil {
sandbox = *client.ZarinPalSandbox
}
return merchant, sandbox
return merchant, sandbox, unit
}
// zpAmount converts canonical Rial to the unit ZarinPal expects for this broker.
func (h *PayHandler) zpAmount(amountRial int64) int64 {
if h.cfg.ZarinPalAmountUnit == "toman" {
// zpAmount converts canonical Rial to the unit ZarinPal expects.
func zpAmount(amountRial int64, unit string) int64 {
if unit == "toman" {
return amountRial / 10
}
return amountRial
@@ -120,13 +135,13 @@ func (h *PayHandler) Request(c *gin.Context) {
return
}
merchant, sandbox := h.merchantFor(client)
merchant, sandbox, unit := h.effective(c.Request.Context(), client)
if merchant == "" {
c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "gateway_unconfigured", Message: "ZarinPal merchant id is not configured"})
return
}
res, err := h.zp.Request(c.Request.Context(), sandbox, merchant, h.zpAmount(amountRial),
res, err := h.zp.Request(c.Request.Context(), sandbox, merchant, zpAmount(amountRial, unit),
h.cfg.CallbackURL(), desc, map[string]string{"order_id": created.ID.String()})
if err != nil {
_, _ = h.store.MarkFailed(c.Request.Context(), created.ID, err.Error(), nil)
@@ -184,8 +199,8 @@ func (h *PayHandler) Callback(c *gin.Context) {
return
}
merchant, sandbox := h.merchantFor(client)
vr, err := h.zp.Verify(c.Request.Context(), sandbox, merchant, h.zpAmount(txn.AmountRial), authority)
merchant, sandbox, unit := h.effective(c.Request.Context(), client)
vr, err := h.zp.Verify(c.Request.Context(), sandbox, merchant, zpAmount(txn.AmountRial, unit), authority)
if err != nil {
failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, "verify error: "+err.Error(), rawJSON(vr))
final := pick(failed, txn)
@@ -270,6 +285,72 @@ func (h *PayHandler) Inquiry(c *gin.Context) {
c.JSON(http.StatusOK, txn)
}
// POST /v1/admin/clients/:id/test-payment (admin-authed, NOT client-signed)
// Creates a real ZarinPal transaction for the given client and returns its
// StartPay URL, so an admin can smoke-test the whole flow from the panel without
// wiring any consuming site. Returns to the broker's own /result page; the test
// metadata carries no site fields, so a client webhook (if any) won't credit.
func (h *PayHandler) AdminTest(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
return
}
client, err := h.store.GetClientApp(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "client not found"})
return
}
const testAmountRial = 10000 // 1,000 Toman
desc := "پرداخت آزمایشی FlatRender Pay"
ref := "admin-test"
meta := json.RawMessage(`{"test":true}`)
exp := time.Now().Add(30 * time.Minute)
txn := &models.Transaction{
ClientAppID: client.ID,
Status: models.StatusCreated,
Gateway: "ZarinPal",
IsTest: true, // broker-authoritative: webhook dispatcher skips test txns
AmountRial: testAmountRial,
Currency: "IRR",
Description: &desc,
ReturnURL: h.cfg.PublicBaseURL + "/result",
Metadata: meta,
ClientRef: &ref,
ExpiresAt: &exp,
}
created, err := h.store.CreateTransaction(c.Request.Context(), txn)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
merchant, sandbox, unit := h.effective(c.Request.Context(), client)
if merchant == "" {
c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "gateway_unconfigured", Message: "ZarinPal merchant id is not set — configure it in settings"})
return
}
res, err := h.zp.Request(c.Request.Context(), sandbox, merchant, zpAmount(testAmountRial, unit),
h.cfg.CallbackURL(), desc, map[string]string{"order_id": created.ID.String()})
if err != nil {
_, _ = h.store.MarkFailed(c.Request.Context(), created.ID, err.Error(), nil)
c.JSON(http.StatusBadGateway, models.APIError{Code: "gateway_error", Message: err.Error()})
return
}
if err := h.store.SetAuthority(c.Request.Context(), created.ID, res.Authority); err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: "could not persist authority"})
return
}
c.JSON(http.StatusOK, models.PayResponse{
ID: created.ID,
Status: models.StatusPending,
PaymentURL: res.StartPay,
Authority: res.Authority,
AmountRial: testAmountRial,
})
}
// ── helpers ──────────────────────────────────────────────────────────────────
func originAllowed(client *models.ClientApp, returnURL string) bool {
@@ -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
}
@@ -13,6 +13,14 @@ type APIError struct {
Message string `json:"message"`
}
// Settings is the singleton global broker config (admin-editable ZarinPal default).
type Settings struct {
ZarinPalMerchantID string `json:"zarinpal_merchant_id"`
ZarinPalSandbox bool `json:"zarinpal_sandbox"`
ZarinPalAmountUnit string `json:"zarinpal_amount_unit"` // "rial" | "toman"
UpdatedAt time.Time `json:"updated_at"`
}
// ── Client apps (tenants of the broker — each site that pays through it) ───────
type ClientApp struct {
@@ -39,6 +47,7 @@ type Transaction struct {
ClientSlug string `json:"client_slug,omitempty"` // joined for admin views
Status string `json:"status"`
Gateway string `json:"gateway"`
IsTest bool `json:"is_test"`
AmountRial int64 `json:"amount_rial"`
Currency string `json:"currency"`
Description *string `json:"description,omitempty"`