package handlers import ( "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} } // 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 if client.ZarinPalMerchantID != nil && *client.ZarinPalMerchantID != "" { merchant = *client.ZarinPalMerchantID } sandbox := h.cfg.ZarinPalSandbox if client.ZarinPalSandbox != nil { sandbox = *client.ZarinPalSandbox } return merchant, sandbox } // zpAmount converts canonical Rial to the unit ZarinPal expects for this broker. func (h *PayHandler) zpAmount(amountRial int64) int64 { if h.cfg.ZarinPalAmountUnit == "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 := h.merchantFor(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), 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 := h.merchantFor(client) vr, err := h.zp.Verify(c.Request.Context(), sandbox, merchant, h.zpAmount(txn.AmountRial), 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": "" } // 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) } // ── 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 }