2026-06-15 23:59:54 +03:30
package handlers
import (
2026-06-26 00:47:10 +03:30
"context"
2026-06-15 23:59:54 +03:30
"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 }
}
2026-06-26 00:47:10 +03:30
// 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
}
}
2026-06-15 23:59:54 +03:30
if client . ZarinPalMerchantID != nil && * client . ZarinPalMerchantID != "" {
merchant = * client . ZarinPalMerchantID
}
if client . ZarinPalSandbox != nil {
sandbox = * client . ZarinPalSandbox
}
2026-06-26 00:47:10 +03:30
return merchant , sandbox , unit
2026-06-15 23:59:54 +03:30
}
2026-06-26 00:47:10 +03:30
// zpAmount converts canonical Rial to the unit ZarinPal expects.
func zpAmount ( amountRial int64 , unit string ) int64 {
if unit == "toman" {
2026-06-15 23:59:54 +03:30
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
}
2026-06-26 00:47:10 +03:30
merchant , sandbox , unit := h . effective ( c . Request . Context ( ) , client )
2026-06-15 23:59:54 +03:30
if merchant == "" {
c . JSON ( http . StatusServiceUnavailable , models . APIError { Code : "gateway_unconfigured" , Message : "ZarinPal merchant id is not configured" } )
return
}
2026-06-26 00:47:10 +03:30
res , err := h . zp . Request ( c . Request . Context ( ) , sandbox , merchant , zpAmount ( amountRial , unit ) ,
2026-06-15 23:59:54 +03:30
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
}
2026-06-26 00:47:10 +03:30
merchant , sandbox , unit := h . effective ( c . Request . Context ( ) , client )
vr , err := h . zp . Verify ( c . Request . Context ( ) , sandbox , merchant , zpAmount ( txn . AmountRial , unit ) , authority )
2026-06-15 23:59:54 +03:30
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 )
}
2026-06-26 00:47:10 +03:30
// 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 ,
} )
}
2026-06-15 23:59:54 +03:30
// ── 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
}