62ea110605
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>
386 lines
12 KiB
Go
386 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/flatrender/payment-svc/internal/config"
|
|
"github.com/flatrender/payment-svc/internal/db"
|
|
"github.com/flatrender/payment-svc/internal/middleware"
|
|
"github.com/flatrender/payment-svc/internal/models"
|
|
"github.com/flatrender/payment-svc/internal/signing"
|
|
"github.com/flatrender/payment-svc/internal/zarinpal"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const minAmountRial = 1000 // ZarinPal minimum (= 100 Toman)
|
|
|
|
type PayHandler struct {
|
|
store *db.Store
|
|
zp *zarinpal.Client
|
|
disp *Dispatcher
|
|
cfg config.Config
|
|
}
|
|
|
|
func NewPayHandler(store *db.Store, zp *zarinpal.Client, disp *Dispatcher, cfg config.Config) *PayHandler {
|
|
return &PayHandler{store: store, zp: zp, disp: disp, cfg: cfg}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if client.ZarinPalSandbox != nil {
|
|
sandbox = *client.ZarinPalSandbox
|
|
}
|
|
return merchant, sandbox, unit
|
|
}
|
|
|
|
// zpAmount converts canonical Rial to the unit ZarinPal expects.
|
|
func zpAmount(amountRial int64, unit string) int64 {
|
|
if unit == "toman" {
|
|
return amountRial / 10
|
|
}
|
|
return amountRial
|
|
}
|
|
|
|
// toRial normalizes the client-supplied amount + currency to canonical Rial.
|
|
func toRial(amount int64, currency string) int64 {
|
|
switch strings.ToUpper(strings.TrimSpace(currency)) {
|
|
case "IRT", "TOMAN", "TMN", "TOMANS":
|
|
return amount * 10
|
|
default: // IRR / RIAL / empty
|
|
return amount
|
|
}
|
|
}
|
|
|
|
// POST /v1/pay/request (client-authed: X-Api-Key + X-Signature)
|
|
func (h *PayHandler) Request(c *gin.Context) {
|
|
client := middleware.GetClient(c)
|
|
rawAny, _ := c.Get(middleware.CtxRawBody)
|
|
raw, _ := rawAny.([]byte)
|
|
|
|
var req models.PayRequest
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid JSON body"})
|
|
return
|
|
}
|
|
if req.ReturnURL == "" {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "return_url is required"})
|
|
return
|
|
}
|
|
if !originAllowed(client, req.ReturnURL) {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "return_url_not_allowed", Message: "return_url origin is not in the client's allowed list"})
|
|
return
|
|
}
|
|
amountRial := toRial(req.Amount, req.Currency)
|
|
if amountRial < minAmountRial {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "amount_too_low", Message: fmt.Sprintf("amount must be at least %d Rial (100 Toman)", minAmountRial)})
|
|
return
|
|
}
|
|
|
|
desc := req.Description
|
|
if desc == "" {
|
|
desc = "پرداخت آنلاین"
|
|
}
|
|
exp := time.Now().Add(30 * time.Minute)
|
|
txn := &models.Transaction{
|
|
ClientAppID: client.ID,
|
|
Status: models.StatusCreated,
|
|
Gateway: "ZarinPal",
|
|
AmountRial: amountRial,
|
|
Currency: "IRR",
|
|
Description: &desc,
|
|
ReturnURL: req.ReturnURL,
|
|
Metadata: req.Metadata,
|
|
ExpiresAt: &exp,
|
|
}
|
|
if req.ClientRef != "" {
|
|
txn.ClientRef = &req.ClientRef
|
|
}
|
|
if req.Mobile != "" {
|
|
txn.PayerMobile = &req.Mobile
|
|
}
|
|
if req.Email != "" {
|
|
txn.PayerEmail = &req.Email
|
|
}
|
|
|
|
created, err := h.store.CreateTransaction(c.Request.Context(), txn)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: "could not create transaction"})
|
|
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 configured"})
|
|
return
|
|
}
|
|
|
|
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)
|
|
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: amountRial,
|
|
})
|
|
}
|
|
|
|
// GET /callback/zarinpal?Authority=..&Status=OK|NOK (PUBLIC — ZarinPal hits this)
|
|
func (h *PayHandler) Callback(c *gin.Context) {
|
|
authority := c.Query("Authority")
|
|
status := c.Query("Status")
|
|
if authority == "" {
|
|
c.String(http.StatusBadRequest, "missing Authority")
|
|
return
|
|
}
|
|
|
|
txn, err := h.store.GetTransactionByAuthority(c.Request.Context(), authority)
|
|
if err != nil {
|
|
c.String(http.StatusNotFound, "transaction not found")
|
|
return
|
|
}
|
|
client, err := h.store.GetClientApp(c.Request.Context(), txn.ClientAppID)
|
|
if err != nil {
|
|
c.String(http.StatusInternalServerError, "client not found")
|
|
return
|
|
}
|
|
// Re-fetch with secret for signing the redirect/webhook.
|
|
client, _ = h.store.GetClientByAPIKey(c.Request.Context(), client.APIKey)
|
|
|
|
now := time.Now().Unix()
|
|
|
|
// User cancelled / bank declined before verify.
|
|
if status != "OK" {
|
|
failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, "user cancelled or status NOK", nil)
|
|
if failed != nil {
|
|
h.disp.Enqueue(c.Request.Context(), client, failed, now)
|
|
h.redirectBack(c, client, failed)
|
|
} else {
|
|
h.redirectBack(c, client, txn)
|
|
}
|
|
return
|
|
}
|
|
|
|
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)
|
|
h.disp.Enqueue(c.Request.Context(), client, final, now)
|
|
h.redirectBack(c, client, final)
|
|
return
|
|
}
|
|
|
|
if vr.Code == 100 || vr.Code == 101 {
|
|
paid, perr := h.store.MarkPaid(c.Request.Context(), txn.ID, vr.RefID, vr.CardPan, vr.Fee, rawJSON(vr))
|
|
if perr != nil {
|
|
// Already terminal (duplicate callback) — just bounce the user back with current state.
|
|
cur, _ := h.store.GetTransaction(c.Request.Context(), txn.ID)
|
|
h.redirectBack(c, client, pick(cur, txn))
|
|
return
|
|
}
|
|
h.disp.Enqueue(c.Request.Context(), client, paid, now)
|
|
h.redirectBack(c, client, paid)
|
|
return
|
|
}
|
|
|
|
failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, fmt.Sprintf("zarinpal verify code %d", vr.Code), rawJSON(vr))
|
|
final := pick(failed, txn)
|
|
h.disp.Enqueue(c.Request.Context(), client, final, now)
|
|
h.redirectBack(c, client, final)
|
|
}
|
|
|
|
// redirectBack bounces the user to the client's return_url with a signed result.
|
|
// Signature canonical string: "{id}.{status}.{ref_id}.{amount_rial}".
|
|
func (h *PayHandler) redirectBack(c *gin.Context, client *models.ClientApp, t *models.Transaction) {
|
|
ref := ""
|
|
if t.RefID != nil {
|
|
ref = *t.RefID
|
|
}
|
|
canonical := fmt.Sprintf("%s.%s.%s.%d", t.ID, t.Status, ref, t.AmountRial)
|
|
sign := signing.Sign(client.Secret, []byte(canonical))
|
|
|
|
u, err := url.Parse(t.ReturnURL)
|
|
if err != nil {
|
|
c.String(http.StatusOK, "payment %s (ref %s)", t.Status, ref)
|
|
return
|
|
}
|
|
q := u.Query()
|
|
q.Set("status", t.Status)
|
|
q.Set("id", t.ID.String())
|
|
q.Set("ref_id", ref)
|
|
q.Set("sign", sign)
|
|
u.RawQuery = q.Encode()
|
|
c.Redirect(http.StatusFound, u.String())
|
|
}
|
|
|
|
// POST /v1/pay/inquiry (client-authed) body: { "id": "<txn uuid>" }
|
|
// Authoritative server-side status check — clients should NOT trust the redirect alone.
|
|
func (h *PayHandler) Inquiry(c *gin.Context) {
|
|
client := middleware.GetClient(c)
|
|
rawAny, _ := c.Get(middleware.CtxRawBody)
|
|
raw, _ := rawAny.([]byte)
|
|
|
|
var body struct {
|
|
ID string `json:"id"`
|
|
ClientRef string `json:"client_ref"`
|
|
}
|
|
_ = json.Unmarshal(raw, &body)
|
|
|
|
var txn *models.Transaction
|
|
var err error
|
|
if body.ID != "" {
|
|
id, perr := uuid.Parse(body.ID)
|
|
if perr != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
|
return
|
|
}
|
|
txn, err = h.store.GetTransaction(c.Request.Context(), id)
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "id is required"})
|
|
return
|
|
}
|
|
if err != nil || txn.ClientAppID != client.ID {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "transaction not found"})
|
|
return
|
|
}
|
|
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 {
|
|
if len(client.AllowedReturnOrigins) == 0 {
|
|
return true // no allow-list configured → permissive
|
|
}
|
|
u, err := url.Parse(returnURL)
|
|
if err != nil || u.Host == "" {
|
|
return false
|
|
}
|
|
origin := strings.ToLower(u.Scheme + "://" + u.Host)
|
|
for _, o := range client.AllowedReturnOrigins {
|
|
if strings.ToLower(strings.TrimRight(o, "/")) == origin {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func rawJSON(vr *zarinpal.VerifyResult) []byte {
|
|
if vr == nil {
|
|
return nil
|
|
}
|
|
return vr.Raw
|
|
}
|
|
|
|
func pick(primary, fallback *models.Transaction) *models.Transaction {
|
|
if primary != nil {
|
|
return primary
|
|
}
|
|
return fallback
|
|
}
|