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