diff --git a/backend/db/migrations/33_payment_settings.sql b/backend/db/migrations/33_payment_settings.sql new file mode 100644 index 0000000..deca262 --- /dev/null +++ b/backend/db/migrations/33_payment_settings.sql @@ -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; diff --git a/deploy/README.md b/deploy/README.md index 1192493..c8f13f4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 gateway (clients authenticate with an API key + HMAC). See [`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 -manually (migrations only auto-run on first volume creation): -`docker exec -i fr2-postgres psql -U postgres -d flatrender < backend/db/migrations/31_payment_broker.sql`. +is migrations `31_payment_broker.sql` (tables) + `33_payment_settings.sql` +(admin-editable ZarinPal config + `transactions.is_test`) — apply BOTH, in order, +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`) diff --git a/services/payment/cmd/server/main.go b/services/payment/cmd/server/main.go index c158cf1..d0db175 100644 --- a/services/payment/cmd/server/main.go +++ b/services/payment/cmd/server/main.go @@ -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) } diff --git a/services/payment/internal/db/db.go b/services/payment/internal/db/db.go index 0b16351..fdc2ec1 100644 --- a/services/payment/internal/db/db.go +++ b/services/payment/internal/db/db.go @@ -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) } diff --git a/services/payment/internal/handlers/admin.go b/services/payment/internal/handlers/admin.go index a5b95fb..59637a7 100644 --- a/services/payment/internal/handlers/admin.go +++ b/services/payment/internal/handlers/admin.go @@ -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 { diff --git a/services/payment/internal/handlers/pay.go b/services/payment/internal/handlers/pay.go index d092b4f..3863451 100644 --- a/services/payment/internal/handlers/pay.go +++ b/services/payment/internal/handlers/pay.go @@ -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 { diff --git a/services/payment/internal/handlers/webhooks.go b/services/payment/internal/handlers/webhooks.go index 0a890b0..ef0853a 100644 --- a/services/payment/internal/handlers/webhooks.go +++ b/services/payment/internal/handlers/webhooks.go @@ -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 } diff --git a/services/payment/internal/models/models.go b/services/payment/internal/models/models.go index 8dddc6b..ac1f5e4 100644 --- a/services/payment/internal/models/models.go +++ b/services/payment/internal/models/models.go @@ -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"` diff --git a/src/components/admin/PaymentsAdmin.tsx b/src/components/admin/PaymentsAdmin.tsx index df4602c..f502f02 100644 --- a/src/components/admin/PaymentsAdmin.tsx +++ b/src/components/admin/PaymentsAdmin.tsx @@ -48,7 +48,7 @@ function statusBadge(s: string) { } export function PaymentsAdmin() { - const [tab, setTab] = useState<"apps" | "txns">("apps"); + const [tab, setTab] = useState<"apps" | "txns" | "settings">("apps"); return (
@@ -60,6 +60,9 @@ export function PaymentsAdmin() {
+ @@ -68,7 +71,124 @@ export function PaymentsAdmin() {
- {tab === "apps" ? : } + {tab === "settings" ? : tab === "apps" ? : } +
+ ); +} + +// ── ZarinPal settings tab ────────────────────────────────────────────────────── + +interface Settings { + zarinpal_merchant_id: string; + zarinpal_sandbox: boolean; + zarinpal_amount_unit: string; +} + +function ZarinpalSettings() { + const [s, setS] = useState({ zarinpal_merchant_id: "", zarinpal_sandbox: true, zarinpal_amount_unit: "rial" }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(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

در حال بارگذاری…

; + + return ( +
+ {error &&

{error}

} + {msg &&

{msg}

} + +

+ مرچنت پیش‌فرض زرین‌پال برای همهٔ سایت‌ها. هر اپلیکیشن می‌تواند مرچنت اختصاصی خود را داشته باشد (در فرم ساخت اپلیکیشن). +

+ +
+ + setS({ ...s, zarinpal_merchant_id: e.target.value })} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + /> +
+ + + +
+ + +

+ ⚠️ یک پرداخت آزمایشی انجام دهید و مطمئن شوید مبلغ درست است — اگر ۱۰ برابر شد، واحد را به «تومان» تغییر دهید. +

+
+ +
); } @@ -109,6 +229,19 @@ function ClientApps() { 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) => { if (!confirm("این اپلیکیشن حذف شود؟")) return; const res = await fetch(`/api/admin/pay/clients/${id}`, { method: "DELETE" }); @@ -196,6 +329,13 @@ function ClientApps() { )}
+