feat(payment): admin-editable ZarinPal settings + in-panel test payment
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user