Skip to content

Update Zarinpal Sandbox Integration to the New REST API (v4) #140

@moeinomidii

Description

@moeinomidii

Background:
The current implementation of the Zarinpal gateway in our repository uses a SOAP‑based integration for all environments. However, as of now, Zarinpal has deprecated the old SOAP-based sandbox endpoint. The sandbox is no longer supported via the SOAP API, resulting in errors (e.g., a 404 on the old SOAP sandbox WSDL and an HTTP 422 when attempting to call those methods).

Issue:
Sandbox payment requests are now failing because Zarinpal requires a completely new approach for the sandbox environment. According to the latest Zarinpal docs and the sandbox guide (docs link), the sandbox integration should now use a JSON‑based REST API rather than SOAP. The new endpoints are:

  • Payment Request: https://sandbox.zarinpal.com/pg/v4/payment/request.json
  • Payment Verification: https://sandbox.zarinpal.com/pg/v4/payment/verify.json
    Additionally, the redirect URL format for sandbox transactions is updated to https://sandbox.zarinpal.com/pg/StartPay/{authority}, and the expected payload field names have changed (e.g., using snake_case keys like merchant_id, callback_url, etc.).

Proposed Fix & Changes:

  1. Sandbox Mode Implementation:

    • In sandbox mode, switch from using SOAP calls to using the REST API via the requests library.
    • Build JSON payloads that conform to the new API’s format and omit fields that are not provided (e.g., avoid sending None for email).
  2. Production Compatibility:

    • Maintain the existing SOAP integration for production since the production endpoints remain unchanged.
  3. Error Handling:

    • Improve logging for HTTP errors by capturing the response status code and full response body, so any 422 errors seen in sandbox mode can be debugged more easily.

Attached Code Example:
Below is an updated version of the Zarinpal class implementing the new sandbox integration:

import logging
import requests  # for REST calls in sandbox mode

from zeep import Client, Transport

from azbankgateways.banks import BaseBank
from azbankgateways.exceptions import SettingDoesNotExist
from azbankgateways.exceptions.exceptions import BankGatewayRejectPayment
from azbankgateways.models import BankType, CurrencyEnum, PaymentStatus


class Zarinpal(BaseBank):
    _merchant_code = None
    _sandbox = None

    def __init__(self, **kwargs):
        kwargs.setdefault("SANDBOX", 0)
        super(Zarinpal, self).__init__(**kwargs)
        self.set_gateway_currency(CurrencyEnum.IRT)
        # Production URL remains unchanged.
        self._payment_url = "https://www.zarinpal.com/pg/StartPay/{}/ZarinGate"
        # Updated sandbox redirect URL per documentation.
        self._sandbox_url = "https://sandbox.zarinpal.com/pg/StartPay/{}"

    def get_bank_type(self):
        return BankType.ZARINPAL

    def set_default_settings(self):
        for item in ["MERCHANT_CODE", "SANDBOX"]:
            if item not in self.default_setting_kwargs:
                raise SettingDoesNotExist(f"{item} does not exist in default_setting_kwargs")
            setattr(self, f"_{item.lower()}", self.default_setting_kwargs[item])

    @classmethod
    def get_minimum_amount(cls):
        return 1000

    def _get_gateway_payment_url_parameter(self):
        if self._sandbox:
            return self._sandbox_url.format(self.get_reference_number())
        return self._payment_url.format(self.get_reference_number())

    def _get_gateway_payment_parameter(self):
        return {}

    def _get_gateway_payment_method_parameter(self):
        return "GET"

    def get_pay_data(self):
        # Production SOAP payload.
        description = "خرید با شماره پیگیری - {}".format(self.get_tracking_code())
        return {
            "Description": description,
            "MerchantID": self._merchant_code,
            "Amount": self.get_gateway_amount(),
            "Email": None,
            "Mobile": self.get_mobile_number(),
            "CallbackURL": self._get_gateway_callback_url(),
        }

    def prepare_pay(self):
        super(Zarinpal, self).prepare_pay()

    def pay(self):
        super(Zarinpal, self).pay()

        if self._sandbox:
            # Use the new REST API for the sandbox
            request_url = "https://sandbox.zarinpal.com/pg/v4/payment/request.json"
            payload = {
                "merchant_id": self._merchant_code,
                "amount": self.get_gateway_amount(),
                "callback_url": self._get_gateway_callback_url(),
                "description": "خرید با شماره پیگیری - {}".format(self.get_tracking_code())
            }
            metadata = {}
            mobile = self.get_mobile_number()
            if mobile:
                metadata["mobile"] = mobile
            email = None  # Set your email here, if available.
            if email:
                metadata["email"] = email
            if metadata:
                payload["metadata"] = metadata

            response = requests.post(request_url, json=payload)
            if response.status_code != 200:
                logging.critical("Zarinpal sandbox payment request failed: HTTP %s - %s",
                                 response.status_code, response.text)
                raise BankGatewayRejectPayment("HTTP Error: " + str(response.status_code))
            json_data = response.json()
            result_code = json_data.get("data", {}).get("code")
            if result_code == 100:
                token = json_data.get("data", {}).get("authority")
                self._set_reference_number(token)
            else:
                logging.critical("Zarinpal sandbox gateway rejected payment: %s", json_data)
                raise BankGatewayRejectPayment(self.get_transaction_status_text())
        else:
            data = self.get_pay_data()
            client = self._get_client()
            result = client.service.PaymentRequest(**data)
            if result.Status == 100:
                token = result.Authority
                self._set_reference_number(token)
            else:
                logging.critical("Zarinpal gateway rejected payment")
                raise BankGatewayRejectPayment(self.get_transaction_status_text())

    def prepare_verify_from_gateway(self):
        super(Zarinpal, self).prepare_verify_from_gateway()
        token = self.get_request().GET.get("Authority", None)
        self._set_reference_number(token)
        self._set_bank_record()

    def verify_from_gateway(self, request):
        super(Zarinpal, self).verify_from_gateway(request)

    def get_verify_data(self):
        super(Zarinpal, self).get_verify_data()
        return {
            "MerchantID": self._merchant_code,
            "Authority": self.get_reference_number(),
            "Amount": self.get_gateway_amount(),
        }

    def prepare_verify(self, tracking_code):
        super(Zarinpal, self).prepare_verify(tracking_code)

    def verify(self, transaction_code):
        super(Zarinpal, self).verify(transaction_code)
        data = self.get_verify_data()

        if self._sandbox:
            verify_url = "https://sandbox.zarinpal.com/pg/v4/payment/verify.json"
            payload = {
                "merchant_id": self._merchant_code,
                "authority": self.get_reference_number(),
                "amount": self.get_gateway_amount(),
            }
            response = requests.post(verify_url, json=payload)
            if response.status_code != 200:
                logging.debug("Zarinpal sandbox verification failed: HTTP %s - %s",
                              response.status_code, response.text)
                self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
                return
            json_data = response.json()
            result_code = json_data.get("data", {}).get("code")
            if result_code in [100, 101]:
                self._set_payment_status(PaymentStatus.COMPLETE)
            else:
                self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
                logging.debug("Zarinpal sandbox payment unapproved: %s", json_data)
        else:
            client = self._get_client(timeout=10)
            result = client.service.PaymentVerification(**data)
            if result.Status in [100, 101]:
                self._set_payment_status(PaymentStatus.COMPLETE)
            else:
                self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
                logging.debug("Zarinpal gateway unapproved payment")

    def _get_client(self, timeout=5):
        transport = Transport(timeout=timeout, operation_timeout=timeout)
        # In sandbox mode, we use REST, so we always return the production WSDL for SOAP.
        return Client(
            "https://www.zarinpal.com/pg/services/WebGate/wsdl",
            transport=transport,
        )

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions