Compare commits

...

1 Commits

Author SHA1 Message Date
soroush.asadi 62ea110605 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>
2026-06-26 00:47:10 +03:30
9 changed files with 371 additions and 24 deletions
@@ -0,0 +1,35 @@
-- =====================================================================
-- PAYMENT BROKER — global settings (admin-editable ZarinPal config) + is_test
-- Lets the merchant id / sandbox flag / amount unit be set from the admin
-- panel instead of env + redeploy. A client_app may still override per-site.
-- Also adds transactions.is_test so admin smoke-test payments never fire a
-- client's production webhook.
--
-- Apply manually on an existing volume (runs after 31_payment_broker.sql):
-- docker exec -i fr2-postgres psql -U flatrender -d flatrender < 33_payment_settings.sql
-- =====================================================================
CREATE SCHEMA IF NOT EXISTS payment;
SET search_path TO payment, public;
CREATE TABLE IF NOT EXISTS payment.settings (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- singleton row
zarinpal_merchant_id TEXT NOT NULL DEFAULT '',
zarinpal_sandbox BOOLEAN NOT NULL DEFAULT TRUE,
zarinpal_amount_unit TEXT NOT NULL DEFAULT 'rial', -- 'rial' | 'toman'
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- NOTE: the singleton row is intentionally NOT pre-seeded. Until an admin saves
-- settings, GetSettings returns no-row and the broker falls back to ENV
-- (ZARINPAL_MERCHANT_ID / ZARINPAL_SANDBOX / ZARINPAL_AMOUNT_UNIT). Seeding a
-- default row here would force sandbox=TRUE and silently override a production
-- env (ZARINPAL_SANDBOX=false), routing real payments to the sandbox gateway.
DROP TRIGGER IF EXISTS tg_pay_settings_updated ON payment.settings;
CREATE TRIGGER tg_pay_settings_updated BEFORE UPDATE ON payment.settings
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Mark admin smoke-test transactions so the webhook dispatcher never notifies a
-- real client (which could otherwise credit coins/activate a plan from a test).
ALTER TABLE payment.transactions ADD COLUMN IF NOT EXISTS is_test BOOLEAN NOT NULL DEFAULT FALSE;
+8 -3
View File
@@ -23,9 +23,14 @@ ZarinPal gateway shared by FlatRender + meezi.ir + bargevasat.ir — ZarinPal on
accepts callbacks on that one verified domain. It does NOT sit behind the API accepts callbacks on that one verified domain. It does NOT sit behind the API
gateway (clients authenticate with an API key + HMAC). See gateway (clients authenticate with an API key + HMAC). See
[`PAYMENTS.md`](./PAYMENTS.md) for the integration contract. The `payment` schema [`PAYMENTS.md`](./PAYMENTS.md) for the integration contract. The `payment` schema
is migration `31_payment_broker.sql` — on an existing DB volume it must be applied is migrations `31_payment_broker.sql` (tables) + `33_payment_settings.sql`
manually (migrations only auto-run on first volume creation): (admin-editable ZarinPal config + `transactions.is_test`) — apply BOTH, in order,
`docker exec -i fr2-postgres psql -U postgres -d flatrender < backend/db/migrations/31_payment_broker.sql`. on an existing DB volume (migrations only auto-run on first volume creation):
```
docker exec -i fr2-postgres psql -U flatrender -d flatrender < backend/db/migrations/31_payment_broker.sql
docker exec -i fr2-postgres psql -U flatrender -d flatrender < backend/db/migrations/33_payment_settings.sql
```
The broker image expects `is_test` (migration 33) — deploy it together with both migrations.
## One-time setup (do these BEFORE the first `git push gitea master`) ## One-time setup (do these BEFORE the first `git push gitea master`)
+3
View File
@@ -67,12 +67,15 @@ func main() {
// ── Admin API (FlatRender admin JWT) ───────────────────────────────────── // ── Admin API (FlatRender admin JWT) ─────────────────────────────────────
admin := v1.Group("/admin", middleware.JWTAuth(cfg.JWTSecret), middleware.RequireAdmin()) 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.GET("/clients", adminH.List)
admin.POST("/clients", adminH.Create) admin.POST("/clients", adminH.Create)
admin.GET("/clients/:id", adminH.Get) admin.GET("/clients/:id", adminH.Get)
admin.PUT("/clients/:id", adminH.Update) admin.PUT("/clients/:id", adminH.Update)
admin.DELETE("/clients/:id", adminH.Delete) admin.DELETE("/clients/:id", adminH.Delete)
admin.POST("/clients/:id/rotate-secret", adminH.RotateSecret) admin.POST("/clients/:id/rotate-secret", adminH.RotateSecret)
admin.POST("/clients/:id/test-payment", payH.AdminTest)
admin.GET("/transactions", adminH.ListTransactions) 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) } 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 ─────────────────────────────────────────────────────────────── // ── Client apps ───────────────────────────────────────────────────────────────
const clientCols = `id, tenant_id, name, slug, api_key, secret, 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, const txnCols = `id, client_app_id, status, gateway, amount_rial, currency, description,
client_ref, return_url, metadata, payer_mobile, payer_email, client_ref, return_url, metadata, payer_mobile, payer_email,
authority, ref_id, card_pan, fee_rial, gateway_response, failure_reason, 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) { func scanTxn(row pgx.Row) (*models.Transaction, error) {
var t models.Transaction 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.ID, &t.ClientAppID, &t.Status, &t.Gateway, &t.AmountRial, &t.Currency, &t.Description,
&t.ClientRef, &t.ReturnURL, &meta, &t.PayerMobile, &t.PayerEmail, &t.ClientRef, &t.ReturnURL, &meta, &t.PayerMobile, &t.PayerEmail,
&t.Authority, &t.RefID, &t.CardPan, &t.FeeRial, &gwResp, &t.FailureReason, &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 { ); err != nil {
return nil, err return nil, err
} }
@@ -159,11 +188,11 @@ func (s *Store) CreateTransaction(ctx context.Context, t *models.Transaction) (*
row := s.pool.QueryRow(ctx, ` row := s.pool.QueryRow(ctx, `
INSERT INTO payment.transactions INSERT INTO payment.transactions
(client_app_id, status, gateway, amount_rial, currency, description, (client_app_id, status, gateway, amount_rial, currency, description,
client_ref, return_url, metadata, payer_mobile, payer_email, expires_at) 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) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING `+txnCols, RETURNING `+txnCols,
t.ClientAppID, t.Status, t.Gateway, t.AmountRial, t.Currency, t.Description, 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) return scanTxn(row)
} }
@@ -3,6 +3,7 @@ package handlers
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@@ -44,6 +45,45 @@ type clientInput struct {
IsActive *bool `json:"is_active"` 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) { func (h *AdminHandler) List(c *gin.Context) {
clients, err := h.store.ListClientApps(c.Request.Context()) clients, err := h.store.ListClientApps(c.Request.Context())
if err != nil { if err != nil {
+94 -13
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "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} return &PayHandler{store: store, zp: zp, disp: disp, cfg: cfg}
} }
// merchantFor resolves the ZarinPal merchant + sandbox flag for a client // effective resolves the ZarinPal merchant / sandbox flag / amount unit for a
// (per-client override falls back to the broker default). // client. Precedence: per-client override > DB settings (admin-editable) > env
func (h *PayHandler) merchantFor(client *models.ClientApp) (string, bool) { // default. DB settings are the source of truth once an admin saves them; env is
merchant := h.cfg.ZarinPalMerchantID // 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 != "" { if client.ZarinPalMerchantID != nil && *client.ZarinPalMerchantID != "" {
merchant = *client.ZarinPalMerchantID merchant = *client.ZarinPalMerchantID
} }
sandbox := h.cfg.ZarinPalSandbox
if client.ZarinPalSandbox != nil { if client.ZarinPalSandbox != nil {
sandbox = *client.ZarinPalSandbox sandbox = *client.ZarinPalSandbox
} }
return merchant, sandbox return merchant, sandbox, unit
} }
// zpAmount converts canonical Rial to the unit ZarinPal expects for this broker. // zpAmount converts canonical Rial to the unit ZarinPal expects.
func (h *PayHandler) zpAmount(amountRial int64) int64 { func zpAmount(amountRial int64, unit string) int64 {
if h.cfg.ZarinPalAmountUnit == "toman" { if unit == "toman" {
return amountRial / 10 return amountRial / 10
} }
return amountRial return amountRial
@@ -120,13 +135,13 @@ func (h *PayHandler) Request(c *gin.Context) {
return return
} }
merchant, sandbox := h.merchantFor(client) merchant, sandbox, unit := h.effective(c.Request.Context(), client)
if merchant == "" { if merchant == "" {
c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "gateway_unconfigured", Message: "ZarinPal merchant id is not configured"}) c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "gateway_unconfigured", Message: "ZarinPal merchant id is not configured"})
return 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()}) h.cfg.CallbackURL(), desc, map[string]string{"order_id": created.ID.String()})
if err != nil { if err != nil {
_, _ = h.store.MarkFailed(c.Request.Context(), created.ID, err.Error(), nil) _, _ = h.store.MarkFailed(c.Request.Context(), created.ID, err.Error(), nil)
@@ -184,8 +199,8 @@ func (h *PayHandler) Callback(c *gin.Context) {
return return
} }
merchant, sandbox := h.merchantFor(client) merchant, sandbox, unit := h.effective(c.Request.Context(), client)
vr, err := h.zp.Verify(c.Request.Context(), sandbox, merchant, h.zpAmount(txn.AmountRial), authority) vr, err := h.zp.Verify(c.Request.Context(), sandbox, merchant, zpAmount(txn.AmountRial, unit), authority)
if err != nil { if err != nil {
failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, "verify error: "+err.Error(), rawJSON(vr)) failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, "verify error: "+err.Error(), rawJSON(vr))
final := pick(failed, txn) final := pick(failed, txn)
@@ -270,6 +285,72 @@ func (h *PayHandler) Inquiry(c *gin.Context) {
c.JSON(http.StatusOK, txn) 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 ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
func originAllowed(client *models.ClientApp, returnURL string) bool { 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. // 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) { 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 == "" { if client.WebhookURL == nil || *client.WebhookURL == "" {
return return
} }
@@ -13,6 +13,14 @@ type APIError struct {
Message string `json:"message"` 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) ─────── // ── Client apps (tenants of the broker — each site that pays through it) ───────
type ClientApp struct { type ClientApp struct {
@@ -39,6 +47,7 @@ type Transaction struct {
ClientSlug string `json:"client_slug,omitempty"` // joined for admin views ClientSlug string `json:"client_slug,omitempty"` // joined for admin views
Status string `json:"status"` Status string `json:"status"`
Gateway string `json:"gateway"` Gateway string `json:"gateway"`
IsTest bool `json:"is_test"`
AmountRial int64 `json:"amount_rial"` AmountRial int64 `json:"amount_rial"`
Currency string `json:"currency"` Currency string `json:"currency"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
+142 -2
View File
@@ -48,7 +48,7 @@ function statusBadge(s: string) {
} }
export function PaymentsAdmin() { export function PaymentsAdmin() {
const [tab, setTab] = useState<"apps" | "txns">("apps"); const [tab, setTab] = useState<"apps" | "txns" | "settings">("apps");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -60,6 +60,9 @@ export function PaymentsAdmin() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button className={tab === "settings" ? btn : btnGhost} onClick={() => setTab("settings")}>
تنظیمات زرینپال
</button>
<button className={tab === "apps" ? btn : btnGhost} onClick={() => setTab("apps")}> <button className={tab === "apps" ? btn : btnGhost} onClick={() => setTab("apps")}>
اپلیکیشنها اپلیکیشنها
</button> </button>
@@ -68,7 +71,124 @@ export function PaymentsAdmin() {
</button> </button>
</div> </div>
{tab === "apps" ? <ClientApps /> : <Transactions />} {tab === "settings" ? <ZarinpalSettings /> : tab === "apps" ? <ClientApps /> : <Transactions />}
</div>
);
}
// ── ZarinPal settings tab ──────────────────────────────────────────────────────
interface Settings {
zarinpal_merchant_id: string;
zarinpal_sandbox: boolean;
zarinpal_amount_unit: string;
}
function ZarinpalSettings() {
const [s, setS] = useState<Settings>({ zarinpal_merchant_id: "", zarinpal_sandbox: true, zarinpal_amount_unit: "rial" });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/pay/settings", { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "بارگذاری ناموفق بود (مایگریشن ۳۳ اجرا شده؟)");
setS({
zarinpal_merchant_id: data.zarinpal_merchant_id ?? "",
zarinpal_sandbox: data.zarinpal_sandbox !== false,
zarinpal_amount_unit: data.zarinpal_amount_unit ?? "rial",
});
} catch (e) {
setError(e instanceof Error ? e.message : "بارگذاری ناموفق بود");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
reload();
}, [reload]);
const save = async () => {
setSaving(true);
setError(null);
setMsg(null);
try {
const res = await fetch("/api/admin/pay/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(s),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "ذخیره ناموفق بود");
setMsg("ذخیره شد.");
} catch (e) {
setError(e instanceof Error ? e.message : "ذخیره ناموفق بود");
} finally {
setSaving(false);
}
};
if (loading) return <p className="py-10 text-center text-gray-500">در حال بارگذاری</p>;
return (
<div className={`${card} space-y-4 p-5`}>
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
{msg && <p className="rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">{msg}</p>}
<p className="text-xs text-gray-400">
مرچنت پیشفرض زرینپال برای همهٔ سایتها. هر اپلیکیشن میتواند مرچنت اختصاصی خود را داشته باشد (در فرم ساخت اپلیکیشن).
</p>
<div>
<label className={lbl}>شناسهٔ مرچنت زرینپال (Merchant ID)</label>
<input
className={inp}
dir="ltr"
value={s.zarinpal_merchant_id}
onChange={(e) => setS({ ...s, zarinpal_merchant_id: e.target.value })}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
</div>
<label className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={s.zarinpal_sandbox}
onClick={() => setS({ ...s, zarinpal_sandbox: !s.zarinpal_sandbox })}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${s.zarinpal_sandbox ? "bg-amber-600" : "bg-emerald-600"}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${s.zarinpal_sandbox ? "left-0.5" : "left-[22px]"}`} />
</button>
<span className="text-sm text-gray-200">
{s.zarinpal_sandbox ? "حالت تست (sandbox) فعال است" : "حالت واقعی (production)"}
</span>
</label>
<div>
<label className={lbl}>واحد مبلغ ارسالی به زرینپال</label>
<select
className={`${inp} w-auto`}
value={s.zarinpal_amount_unit}
onChange={(e) => setS({ ...s, zarinpal_amount_unit: e.target.value })}
>
<option value="rial">ریال (Rial پیشفرض v4)</option>
<option value="toman">تومان (Toman)</option>
</select>
<p className="mt-1 text-[11px] text-amber-300/80">
یک پرداخت آزمایشی انجام دهید و مطمئن شوید مبلغ درست است اگر ۱۰ برابر شد، واحد را به «تومان» تغییر دهید.
</p>
</div>
<button className={btn} onClick={save} disabled={saving}>
{saving ? "در حال ذخیره…" : "ذخیره تنظیمات"}
</button>
</div> </div>
); );
} }
@@ -109,6 +229,19 @@ function ClientApps() {
else setError(data?.error ?? "خطا"); else setError(data?.error ?? "خطا");
}; };
const testPayment = async (id: string) => {
setError(null);
try {
const res = await fetch(`/api/admin/pay/clients/${id}/test-payment`, { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "ساخت پرداخت آزمایشی ناموفق بود (مرچنت تنظیم شده؟)");
if (data.payment_url) window.open(data.payment_url, "_blank", "noopener");
else throw new Error("لینک پرداخت دریافت نشد");
} catch (e) {
setError(e instanceof Error ? e.message : "خطا");
}
};
const remove = async (id: string) => { const remove = async (id: string) => {
if (!confirm("این اپلیکیشن حذف شود؟")) return; if (!confirm("این اپلیکیشن حذف شود؟")) return;
const res = await fetch(`/api/admin/pay/clients/${id}`, { method: "DELETE" }); const res = await fetch(`/api/admin/pay/clients/${id}`, { method: "DELETE" });
@@ -196,6 +329,13 @@ function ClientApps() {
)} )}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button
className="rounded-lg border border-emerald-500/40 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
onClick={() => testPayment(c.id)}
title="ساخت یک تراکنش آزمایشی زرین‌پال و باز کردن صفحهٔ پرداخت"
>
پرداخت آزمایشی
</button>
<button className={btnGhost} onClick={() => toggleActive(c)}> <button className={btnGhost} onClick={() => toggleActive(c)}>
{c.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"} {c.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"}
</button> </button>