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