From a63fe09f261a6a94500306d327b8d9b7aae30476 Mon Sep 17 00:00:00 2001 From: mohammadreza javid Date: Sun, 12 Apr 2026 17:09:32 +0330 Subject: [PATCH] Implement example of sep --- .gitignore | 1 + delivery/html/payment.html | 240 ++++++++++++++++++++++++ delivery/html/result.html | 172 +++++++++++++++++ delivery/httphandler/handler.go | 320 ++++++++++++++++++++++++++++++++ go.mod | 7 + go.sum | 0 main.go | 20 ++ service/sep.go | 182 ++++++++++++++++++ service/service.go | 154 +++++++++++++++ 9 files changed, 1096 insertions(+) create mode 100644 .gitignore create mode 100644 delivery/html/payment.html create mode 100644 delivery/html/result.html create mode 100644 delivery/httphandler/handler.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 service/sep.go create mode 100644 service/service.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/delivery/html/payment.html b/delivery/html/payment.html new file mode 100644 index 0000000..f943980 --- /dev/null +++ b/delivery/html/payment.html @@ -0,0 +1,240 @@ + + + + + + پرداخت آنلاین - بانک سامان + + + +
+
+
💳
+

پرداخت آنلاین

+

بانک سامان - IPG_SEP

+
+
+
+
+ + +
حداقل مبلغ: ۱۰۰۰ تومان
+
+
+ + +
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/delivery/html/result.html b/delivery/html/result.html new file mode 100644 index 0000000..5bbb89c --- /dev/null +++ b/delivery/html/result.html @@ -0,0 +1,172 @@ + + + + + + نتیجه پرداخت + + + +
+
+ + + + \ No newline at end of file diff --git a/delivery/httphandler/handler.go b/delivery/httphandler/handler.go new file mode 100644 index 0000000..0f47c77 --- /dev/null +++ b/delivery/httphandler/handler.go @@ -0,0 +1,320 @@ +package httphandler + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strings" + + "golang.project/gocasts/ipg-example/service" +) + +type Handler struct { + service service.PaymentService + templatesPath string + staticPath string +} + +func NewHandler(service service.PaymentService) *Handler { + return &Handler{ + service: service, + templatesPath: "./delivery/html/", + staticPath: "./delivery/static/", + } +} + +func (h *Handler) RegisterRoutes() *http.ServeMux { + mux := http.NewServeMux() + + mux.HandleFunc("/", h.homeHandler) + mux.HandleFunc("/api/init-payment", h.requestPaymentHandler) + mux.HandleFunc("/api/callback", h.callbackHandler) + mux.HandleFunc("/result", h.resultHandler) + + //mux.HandleFunc("/api/verify-payment", h.verifyPaymentHandler) + + mux.Handle("/static/", http.StripPrefix("/static/", + http.FileServer(http.Dir(h.staticPath)))) + + return mux +} + +func (h *Handler) StartServer(addr string) { + mux := h.RegisterRoutes() + log.Printf("Server running on http://localhost%s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +// Params +type PaymentRequest struct { + Amount int64 `json:"amount"` + Phone string `json:"phone"` +} + +type PaymentResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + PaymentURL string `json:"payment_url,omitempty"` + Message string `json:"message"` +} + +type VerifyResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + TransactionID string `json:"transaction_id,omitempty"` + Message string `json:"message"` +} + +type BankCallbackResponse struct { + Success bool `json:"success"` + Status string `json:"status"` + TransactionID string `json:"transaction_id,omitempty"` + Message string `json:"message"` +} + +// Handlers +func (h *Handler) homeHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + tmpl, err := template.ParseFiles(h.templatesPath + "payment.html") + if err != nil { + log.Printf("Template error: %v", err) + http.Error(w, "Template parsing error", http.StatusInternalServerError) + return + } + + if err := tmpl.Execute(w, nil); err != nil { + log.Printf("Template execute error: %v", err) + } +} + +func (h *Handler) requestPaymentHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + + var req PaymentRequest + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&req); err != nil { + json.NewEncoder(w).Encode(PaymentResponse{ + Success: false, + Message: "خطا در پارس کردن داده‌ها", + }) + return + } + + if req.Amount < 1000 { + json.NewEncoder(w).Encode(PaymentResponse{ + Success: false, + Message: "حداقل مبلغ ۱۰۰۰ تومان", + }) + return + } + + if req.Phone == "" { + json.NewEncoder(w).Encode(PaymentResponse{ + Success: false, + Message: "شماره تلفن الزامی است", + }) + return + } + + token, paymentURL, err := h.service.InitPayment(r.Context(), req.Amount, req.Phone) + if err != nil { + json.NewEncoder(w).Encode(PaymentResponse{ + Success: false, + Message: "خطا در ایجاد پرداخت: " + err.Error(), + }) + return + } + + json.NewEncoder(w).Encode(PaymentResponse{ + Success: true, + Token: token, + PaymentURL: paymentURL, + Message: "پرداخت ایجاد شد", + }) +} + +func (h *Handler) callbackHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var state, status, refNum, resNum, traceNo, amount string + + contentType := r.Header.Get("Content-Type") + + if strings.Contains(contentType, "application/json") { + var data map[string]interface{} + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&data); err != nil { + log.Printf("Failed to parse JSON: %v", err) + h.redirectToResult(w, r, "", "NOK", "failed", "", "") + return + } + + if v, ok := data["State"].(string); ok { + state = v + } + if v, ok := data["Status"]; ok { + status = fmt.Sprintf("%v", v) + } + if v, ok := data["RefNum"].(string); ok { + refNum = v + } + if v, ok := data["ResNum"].(string); ok { + resNum = v + } + if v, ok := data["TraceNo"].(string); ok { + traceNo = v + } + if v, ok := data["Amount"]; ok { + amount = fmt.Sprintf("%v", v) + } + } else { + err := r.ParseForm() + if err != nil { + log.Printf("Failed to parse form: %v", err) + h.redirectToResult(w, r, "", "NOK", "failed", "", "") + return + } + + state = r.FormValue("State") + status = r.FormValue("Status") + refNum = r.FormValue("RefNum") + resNum = r.FormValue("ResNum") + traceNo = r.FormValue("TraceNo") + amount = r.FormValue("Amount") + } + + log.Printf("Bank Callback Received:") + log.Printf(" State: %s", state) + log.Printf(" Status: %s", status) + log.Printf(" RefNum: %s", refNum) + log.Printf(" ResNum: %s", resNum) + log.Printf(" TraceNo: %s", traceNo) + log.Printf(" Amount: %s", amount) + + isSuccess := (state == "OK" || status == "0") + + if !isSuccess { + // payment failed + h.redirectToResult(w, r, "", state, "failed", refNum, amount) + return + } + + // verify transaction + transactionID, err := h.service.VerifyPayment(r.Context(), resNum, refNum, traceNo, amount) + if err != nil { + log.Printf("❌ Verify failed: %v", err) + h.redirectToResult(w, r, "", state, "verify_failed", refNum, amount) + return + } + + log.Printf("Payment verified successfully. TransactionID: %s", transactionID) + + h.redirectToResult(w, r, transactionID, state, "success", refNum, amount) +} + +func (h *Handler) resultHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/result" { + http.NotFound(w, r) + return + } + + if r.Method == http.MethodPost { + if err := r.ParseForm(); err != nil { + http.Error(w, "خطا در پارس کردن داده‌ها", http.StatusBadRequest) + return + } + } + + state := r.FormValue("state") + status := r.FormValue("status") + refNum := r.FormValue("refNum") + amount := r.FormValue("amount") + transactionID := r.FormValue("transactionId") + + isSuccess := (state == "OK" || status == "success") + + data := map[string]interface{}{ + "State": state, + "Status": status, + "IsSuccess": isSuccess, + "RefNum": refNum, + "Amount": amount, + "TransactionID": transactionID, + } + + tmpl, err := template.ParseFiles(h.templatesPath + "result.html") + if err != nil { + log.Printf("Template error: %v", err) + http.Error(w, "Template parsing error", http.StatusInternalServerError) + return + } + + if err := tmpl.Execute(w, data); err != nil { + log.Printf("Template execute error: %v", err) + } +} + +func (h *Handler) redirectToResult(w http.ResponseWriter, r *http.Request, transactionID, state, status, refNum, amount string) { + query := fmt.Sprintf("state=%s&status=%s", state, status) + + if transactionID != "" { + query += "&transactionId=" + url.QueryEscape(transactionID) + } + if refNum != "" { + query += "&refNum=" + url.QueryEscape(refNum) + } + if amount != "" { + query += "&amount=" + url.QueryEscape(amount) + } + + redirectURL := "/result?" + query + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +/*func (h *Handler) verifyPaymentHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + token := r.URL.Query().Get("token") + status := r.URL.Query().Get("status") + + if token == "" || status == "" { + json.NewEncoder(w).Encode(VerifyResponse{ + Success: false, + Status: "failed", + Message: "پارامترهای نامعتبر", + }) + return + } + + transactionID, err := h.service.VerifyPayment(r.Context(), token, status, "", "") + if err != nil || status != "OK" { + json.NewEncoder(w).Encode(VerifyResponse{ + Success: false, + Status: "failed", + Message: "پرداخت ناموفق", + }) + return + } + + json.NewEncoder(w).Encode(VerifyResponse{ + Success: true, + Status: "success", + TransactionID: transactionID, + Message: "پرداخت موفق", + }) +}*/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56560b2 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module golang.project/gocasts/ipg-example + +go 1.25.4 + +replace golang.project/gocasts/ipg-example/service => ./service + +replace golang.project/gocasts/ipg-example/delivery/httphandler => ./delivery/httphandler diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/main.go new file mode 100644 index 0000000..36ae2af --- /dev/null +++ b/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "golang.project/gocasts/ipg-example/delivery/httphandler" + "golang.project/gocasts/ipg-example/service" +) + +func main() { + config := service.Config{ + PaymentGetToken: "https://sep.shaparak.ir/OnlinePG/OnlinePG", + PaymentVerifyURL: "https://sep.shaparak.ir/verifyTxnRandomSessionkey/ipg/VerifyTransaction", + CallbackURL: "http://localhost:8070/api/callback", + Credential: "15144433", + } + + paymentSvc := service.NewPaymentService(config) + handler := httphandler.NewHandler(paymentSvc) + + handler.StartServer(":8070") +} diff --git a/service/sep.go b/service/sep.go new file mode 100644 index 0000000..2018984 --- /dev/null +++ b/service/sep.go @@ -0,0 +1,182 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log/slog" + "net/http" + "strconv" +) + +type SepTokenResponse struct { + Status int `json:"status"` + ErrorCode string `json:"errorCode"` + ErrorDesc string `json:"errorDesc"` + Token string `json:"token"` +} +type VerifyTransactionResponse struct { + TransactionDetail TransactionDetail `json:"TransactionDetail"` + ResultCode int `json:"ResultCode"` + ResultDescription string `json:"ResultDescription"` + Success bool `json:"Success"` +} + +type TransactionDetail struct { + RRN string `json:"RRN"` + RefNum string `json:"RefNum"` + MaskedPan string `json:"MaskedPan"` + HashedPan string `json:"HashedPan"` + TerminalNumber int `json:"TerminalNumber"` + OrginalAmount int64 `json:"OrginalAmount"` + AffectiveAmount int64 `json:"AffectiveAmount"` + StraceDate string `json:"StraceDate"` + StraceNo string `json:"StraceNo"` +} + +type ReverseTransactionResponse struct { + TransactionDetail TransactionDetail `json:"TransactionDetail"` + ResultCode int `json:"ResultCode"` + ResultDescription string `json:"ResultDescription"` + Success bool `json:"Success"` +} + +func (p *PaymentService) RealInitialize(ctx context.Context, credential, phone, resNum string, amount int64) (*SepTokenResponse, error) { + description := p.generateDescription() + + message := SepTokenRequest{ + Action: "token", + TerminalId: credential, + Amount: amount, + ResNum: resNum, + RedirectUrl: p.Cfg.CallbackURL, + CellNumber: phone, + TokenExpiryInMin: 20, + } + + if len(description) > 0 { + if len(description) <= 50 { + message.ResNum1 = description + } else if len(description) <= 100 { + message.ResNum1 = description[:50] + message.ResNum2 = description[50:] + } else if len(description) <= 150 { + message.ResNum1 = description[:50] + message.ResNum2 = description[50:100] + message.ResNum3 = description[100:] + } else { + message.ResNum1 = description[:50] + message.ResNum2 = description[50:100] + message.ResNum3 = description[100:150] + message.ResNum4 = description[150:] + } + } + + bodyBytes, err := json.Marshal(message) + if err != nil { + return nil, fmt.Errorf("marshal error: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.Cfg.PaymentGetToken, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("request creation error: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + slog.Info("SEP Token Request", "endpoint", p.Cfg.PaymentGetToken, "body", message) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request error: %v", err) + } + defer resp.Body.Close() + + responseBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body error: %v", err) + } + + slog.Info("SEP Token Response", "status_code", resp.StatusCode, "body", string(responseBody)) + + var parsed SepTokenResponse + if err := json.Unmarshal(responseBody, &parsed); err != nil { + return nil, fmt.Errorf("unmarshal error: %v", err) + } + + if parsed.Status == 1 && parsed.Token != "" { + slog.Info("sep payment initialized", "token", parsed.Token) + return &parsed, nil + } + + return nil, fmt.Errorf("sep init error (code %s): %s", parsed.ErrorCode, parsed.ErrorDesc) +} + +func (p *PaymentService) RealVerifyTransaction(ctx context.Context, refNum string) (*VerifyTransactionResponse, error) { + terminalNumber, err := strconv.Atoi(p.Cfg.Credential) + if err != nil { + return nil, fmt.Errorf("خطا در تبدیل TerminalId: %v", err) + } + + requestBody := map[string]interface{}{ + "RefNum": refNum, + "TerminalNumber": terminalNumber, + } + + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("marshal error: %v", err) + } + + verifyURL := p.Cfg.PaymentVerifyURL + if verifyURL == "" { + verifyURL = "https://sep.shaparak.ir/verifyTxnRandomSessionkey/ipg/VerifyTransaction" + } + + req, err := http.NewRequestWithContext(ctx, "POST", verifyURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("request creation error: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + slog.Info("SEP VerifyTransaction Request", + "endpoint", verifyURL, + "refNum", refNum, + "terminalNumber", terminalNumber) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request error: %v", err) + } + defer resp.Body.Close() + + respBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body error: %v", err) + } + + slog.Info("SEP VerifyTransaction Response", + "status_code", resp.StatusCode, + "body", string(respBytes)) + + var verifyResp VerifyTransactionResponse + if err := json.Unmarshal(respBytes, &verifyResp); err != nil { + return nil, fmt.Errorf("unmarshal error: %v", err) + } + + slog.Info("VerifyTransaction Result", + "success", verifyResp.Success, + "resultCode", verifyResp.ResultCode, + "resultDescription", verifyResp.ResultDescription) + + return &verifyResp, nil +} + +func (p *PaymentService) generateDescription() string { + description := "سفارش از درگاه" + if p.IPGTransaction != nil { + description += " : " + p.IPGTransaction.ID + } + return description +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..d044214 --- /dev/null +++ b/service/service.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "log/slog" + "strconv" + "time" +) + +type Config struct { + PaymentGetToken string // آدرس دریافت توکن + PaymentVerifyURL string // آدرس تأیید تراکنش + PaymentReverseURL string // آدرس برگشت تراکنش + CallbackURL string // آدرس بازگشت + Credential string // TerminalId +} + +type IPGTransaction struct { + ID string `json:"id"` + IPGType string `json:"ipg_type"` + GatewayMeta map[string]string `json:"gateway_meta,omitempty"` + Token string `json:"token"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` +} + +type PaymentService struct { + IPGTransaction *IPGTransaction + Cfg Config +} + +type InitPaymentResponse struct { + Success bool `json:"success"` + GatewayToken string `json:"gateway_token"` + RedirectUrl string `json:"redirect_url"` + IPGTransactionId string `json:"ipg_transaction_id"` + Message string `json:"message"` + Time time.Time `json:"time"` +} + +// SEP Request/Response +type SepTokenRequest struct { + Action string `json:"action"` + TerminalId string `json:"TerminalId"` + Amount int64 `json:"Amount"` + ResNum string `json:"ResNum"` + RedirectUrl string `json:"RedirectUrl"` + CellNumber string `json:"CellNumber"` + TokenExpiryInMin int `json:"TokenExpiryInMin,omitempty"` + ResNum1 string `json:"ResNum1,omitempty"` + ResNum2 string `json:"ResNum2,omitempty"` + ResNum3 string `json:"ResNum3,omitempty"` + ResNum4 string `json:"ResNum4,omitempty"` +} + +func NewPaymentService(cfg Config) PaymentService { + return PaymentService{ + Cfg: cfg, + } +} + +func (p *PaymentService) InitPayment(ctx context.Context, amount int64, phone string) (string, string, error) { + res, err := p.startPayment(ctx, amount, phone) + if err != nil { + return "", "", err + } + return res.GatewayToken, res.RedirectUrl, nil +} + +func (p *PaymentService) startPayment(ctx context.Context, amount int64, phone string) (*InitPaymentResponse, error) { + if amount < 1000 { + return nil, fmt.Errorf("حداقل مبلغ ۱۰۰۰ تومان است") + } + + resNum := GenerateUniqueString() + + ipgTx := &IPGTransaction{ + ID: resNum, + IPGType: "sep", + GatewayMeta: map[string]string{ + "callback_url": p.Cfg.CallbackURL, + }, + Amount: float64(amount), + CreatedAt: time.Now(), + } + p.IPGTransaction = ipgTx + + initRes, err := p.RealInitialize(ctx, p.Cfg.Credential, phone, resNum, amount) + if err != nil { + slog.Error("failed to init payment", "error", err) + return nil, err + } + + redirectUrl := fmt.Sprintf("https://sep.shaparak.ir/OnlinePG/SendToken?token=%s", initRes.Token) + + return &InitPaymentResponse{ + Success: true, + GatewayToken: initRes.Token, + RedirectUrl: redirectUrl, + IPGTransactionId: ipgTx.ID, + Time: time.Now(), + Message: "پرداخت ایجاد شد", + }, nil +} + +func (p *PaymentService) VerifyPayment(ctx context.Context, resNum, refNum, traceNo, amount string) (string, error) { + if resNum == "" { + return "", fmt.Errorf("شماره سفارش نامعتبر") + } + + expectedAmount, err := strconv.ParseInt(amount, 10, 64) + if err != nil { + return "", fmt.Errorf("مبلغ نامعتبر: %v", err) + } + + verifyResp, err := p.RealVerifyTransaction(ctx, refNum) + if err != nil { + return "", fmt.Errorf("خطا در تأیید تراکنش: %v", err) + } + + if !verifyResp.Success { + return "", fmt.Errorf("تأیید تراکنش ناموفق: %s", verifyResp.ResultDescription) + } + + actualAmount := verifyResp.TransactionDetail.OrginalAmount + if actualAmount != expectedAmount { + slog.Error("amount mismatch", + "expected", expectedAmount, + "actual", actualAmount) + return "", fmt.Errorf("مبلغ تراکنش مغایرت دارد") + } + + trackingCode := verifyResp.TransactionDetail.StraceNo + if trackingCode == "" { + trackingCode = verifyResp.TransactionDetail.RRN + } + + slog.Info("payment verified successfully", + "refNum", refNum, + "trackingCode", trackingCode, + "amount", actualAmount) + + return trackingCode, nil +} + +func GenerateUniqueString() string { + timestamp := time.Now().UnixNano() + randomBytes := make([]byte, 8) + rand.Read(randomBytes) + return fmt.Sprintf("%d-%s", timestamp, hex.EncodeToString(randomBytes)) +}