~/icsd.ir — bash
SYSTEM_ONLINE

💳 فصل ۴: ساخت درگاه پرداخت ایرانی حرفه‌ای

در این فصل یاد می‌گیرید چطور یک درگاه پرداخت ایرانی کامل (مثل زرین‌پال، سامان، ملت) را از صفر برای ووکامرس بسازید و آن را استاندارد بازار کنید.

🔍 آناتومی یک درگاه پرداخت ووکامرس

هر درگاه پرداخت در ووکامرس از سه بخش اصلی تشکیل می‌شود:

بخش نقش مسئولیت
Process Payment شروع پرداخت ساخت توکن، هدایت به بانک، ثبت سفارش با وضعیت pending
Callback Handler بازگشت از بانک دریافت پاسخ بانک، تایید نهایی، تغییر وضعیت سفارش
Verify API Call تایید سرور-به-سرور درخواست به API بانک برای تایید قطعی تراکنش
💡 نکته امنیتی حیاتی: هرگز فقط به Callback اعتماد نکنید. همیشه با درخواست Verify به API بانک، صحت پرداخت را تایید کنید. کاربر می‌تواند URL کال‌بک را دستکاری کند!

🏗️ ساختار کلاس Payment Gateway

هر درگاه باید کلاس WC_Payment_Gateway را extend کند. ساختار حرفه‌ای:

my-zarinpal-gateway.php
<?php
/**
 * Plugin Name: ICSD ZarinPal Gateway
 * Description: درگاه پرداخت زرین‌پال حرفه‌ای برای ووکامرس
 * Version: 1.0.0
 * Author: ICSD
 * Requires Plugins: woocommerce
 * WC requires at least: 8.0
 * WC tested up to: 9.4
 */

defined('ABSPATH') || exit;

add_action('plugins_loaded', 'icsd_init_zarinpal_gateway', 11);

function icsd_init_zarinpal_gateway() {
    if (!class_exists('WC_Payment_Gateway')) return;
    require_once __DIR__ . '/includes/class-wc-zarinpal.php';
    add_filter('woocommerce_payment_gateways', function($gateways) {
        $gateways[] = 'WC_Gateway_ICSD_ZarinPal';
        return $gateways;
    });
}

// اعلام سازگاری با HPOS (مهم برای ووکامرس مدرن)
add_action('before_woocommerce_init', function() {
    if (class_exists(AutomatticWooCommerceUtilitiesFeaturesUtil::class)) {
        AutomatticWooCommerceUtilitiesFeaturesUtil::declare_compatibility(
            'custom_order_tables', __FILE__, true
        );
    }
});

⚠️ HPOS چیست؟ از ووکامرس ۸ به بعد، High-Performance Order Storage جایگزین post type سفارش‌ها شد. اگر سازگاری اعلام نکنید، درگاه با هشدار نمایش داده می‌شود.

💎 پیاده‌سازی کامل زرین‌پال REST API v4

includes/class-wc-zarinpal.php
<?php
defined('ABSPATH') || exit;

class WC_Gateway_ICSD_ZarinPal extends WC_Payment_Gateway {
    
    const REQUEST_URL = 'https://payment.zarinpal.com/pg/v4/payment/request.json';
    const VERIFY_URL  = 'https://payment.zarinpal.com/pg/v4/payment/verify.json';
    const STARTPAY    = 'https://payment.zarinpal.com/pg/StartPay/';
    
    public function __construct() {
        $this->id                 = 'icsd_zarinpal';
        $this->method_title       = 'زرین‌پال (ICSD)';
        $this->method_description = 'پرداخت آنلاین از طریق زرین‌پال';
        $this->has_fields         = false;
        $this->supports           = ['products', 'refunds'];
        
        $this->init_form_fields();
        $this->init_settings();
        
        $this->title       = $this->get_option('title');
        $this->description = $this->get_option('description');
        $this->merchant_id = $this->get_option('merchant_id');
        $this->sandbox     = 'yes' === $this->get_option('sandbox');
        
        add_action('woocommerce_update_options_payment_gateways_' . $this->id, 
                   [$this, 'process_admin_options']);
        add_action('woocommerce_api_' . $this->id, [$this, 'handle_callback']);
    }
    
    public function init_form_fields() {
        $this->form_fields = [
            'enabled' => [
                'title'   => 'فعال‌سازی',
                'type'    => 'checkbox',
                'label'   => 'فعال کردن درگاه زرین‌پال',
                'default' => 'no',
            ],
            'title' => [
                'title'   => 'عنوان نمایشی',
                'type'    => 'text',
                'default' => 'پرداخت آنلاین (زرین‌پال)',
            ],
            'merchant_id' => [
                'title'       => 'Merchant ID',
                'type'        => 'text',
                'description' => 'کد ۳۶ کاراکتری دریافتی از زرین‌پال',
            ],
            'sandbox' => [
                'title' => 'حالت تست',
                'type'  => 'checkbox',
                'label' => 'استفاده از sandbox',
            ],
        ];
    }
    
    public function process_payment($order_id) {
        $order = wc_get_order($order_id);
        
        $callback_url = add_query_arg('order_id', $order_id, 
                          add_query_arg('wc-api', $this->id, home_url('/')));
        
        $amount = $this->get_amount_in_rial($order);
        
        $response = wp_remote_post(self::REQUEST_URL, [
            'timeout' => 30,
            'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
            'body' => wp_json_encode([
                'merchant_id'  => $this->merchant_id,
                'amount'       => $amount,
                'description'  => sprintf('سفارش #%s', $order_id),
                'callback_url' => $callback_url,
                'metadata'     => [
                    'mobile'   => $order->get_billing_phone(),
                    'email'    => $order->get_billing_email(),
                    'order_id' => $order_id,
                ],
            ]),
        ]);
        
        if (is_wp_error($response)) {
            wc_add_notice('خطا در ارتباط با درگاه: ' . $response->get_error_message(), 'error');
            return ['result' => 'failure'];
        }
        
        $body = json_decode(wp_remote_retrieve_body($response), true);
        
        if (isset($body['data']['code']) && $body['data']['code'] == 100) {
            $authority = $body['data']['authority'];
            $order->update_meta_data('_zarinpal_authority', $authority);
            $order->add_order_note('کد رهگیری زرین‌پال: ' . $authority);
            $order->save();
            
            return [
                'result'   => 'success',
                'redirect' => self::STARTPAY . $authority,
            ];
        }
        
        $error = $body['errors']['message'] ?? 'خطای ناشناخته';
        wc_add_notice('خطای زرین‌پال: ' . $error, 'error');
        return ['result' => 'failure'];
    }
    
    private function get_amount_in_rial($order) {
        $amount = (int) $order->get_total();
        if (in_array($order->get_currency(), ['IRT', 'TOMAN'])) {
            $amount *= 10;
        }
        return $amount;
    }
    
    public function handle_callback() {
        $authority = sanitize_text_field($_GET['Authority'] ?? '');
        $status    = sanitize_text_field($_GET['Status'] ?? '');
        $order_id  = absint($_GET['order_id'] ?? 0);
        
        $order = wc_get_order($order_id);
        if (!$order) wp_die('سفارش معتبر نیست', 'خطا', ['response' => 404]);
        
        // ضد تکراری
        if ($order->is_paid()) {
            wp_redirect($this->get_return_url($order));
            exit;
        }
        
        if ($status !== 'OK') {
            $order->update_status('failed', 'پرداخت توسط کاربر لغو شد');
            wc_add_notice('پرداخت لغو شد', 'error');
            wp_redirect(wc_get_checkout_url());
            exit;
        }
        
        $verify = $this->verify_payment($authority, $order);
        
        if ($verify['success']) {
            $ref_id = $verify['ref_id'];
            $order->payment_complete($ref_id);
            $order->add_order_note('پرداخت موفق - کد پیگیری: ' . $ref_id);
            $order->update_meta_data('_zarinpal_ref_id', $ref_id);
            $order->save();
            
            wc_add_notice('پرداخت موفق - کد: ' . $ref_id, 'success');
            WC()->cart->empty_cart();
            wp_redirect($this->get_return_url($order));
        } else {
            $order->update_status('failed', $verify['message']);
            wc_add_notice('پرداخت ناموفق: ' . $verify['message'], 'error');
            wp_redirect(wc_get_checkout_url());
        }
        exit;
    }
    
    private function verify_payment($authority, $order) {
        $response = wp_remote_post(self::VERIFY_URL, [
            'timeout' => 30,
            'headers' => ['Content-Type' => 'application/json'],
            'body' => wp_json_encode([
                'merchant_id' => $this->merchant_id,
                'amount'      => $this->get_amount_in_rial($order),
                'authority'   => $authority,
            ]),
        ]);
        
        if (is_wp_error($response)) {
            return ['success' => false, 'message' => $response->get_error_message()];
        }
        
        $body = json_decode(wp_remote_retrieve_body($response), true);
        $code = $body['data']['code'] ?? 0;
        
        // ۱۰۰ = موفق، ۱۰۱ = قبلاً تایید شده
        if (in_array($code, [100, 101])) {
            return ['success' => true, 'ref_id' => $body['data']['ref_id'] ?? ''];
        }
        
        return ['success' => false, 'message' => $body['errors']['message'] ?? 'تراکنش تایید نشد'];
    }
    
    public function process_refund($order_id, $amount = null, $reason = '') {
        $order = wc_get_order($order_id);
        $order->add_order_note(sprintf(
            'درخواست بازگشت وجه: %s - دلیل: %s',
            number_format($amount), $reason
        ));
        return new WP_Error('manual_refund', 'بازگشت وجه باید از پنل زرین‌پال انجام شود');
    }
}

✅ نکات کلیدی:

  • پشتیبانی کامل از HPOS
  • تبدیل خودکار تومان به ریال
  • متادیتای کامل (mobile, email) ارسال می‌شود
  • چک ضد تکراری برای جلوگیری از پرداخت دوباره
  • کد ۱۰۱ هم درست هندل می‌شود (تراکنش قبلاً تایید شده)

🔄 مدیریت Callback پیشرفته

۱. ثبت لاگ کامل برای دیباگ

private function log($message, $level = 'info') {
    if (!class_exists('WC_Logger')) return;
    $logger = wc_get_logger();
    $logger->log($level, $message, ['source' => 'icsd-zarinpal']);
}

// در handle_callback:
$this->log(sprintf(
    'Callback: Order=%d, Authority=%s, Status=%s',
    $order_id, $authority, $status
));

لاگ‌ها در WooCommerce → وضعیت → گزارش‌ها قابل مشاهده هستند.

۲. مدیریت مبلغ نامتناسب

// در verify_payment:
if (isset($body['data']['amount']) && $body['data']['amount'] != $amount) {
    $order->update_status('on-hold', 'مبلغ پرداختی با سفارش متفاوت است');
    return ['success' => false, 'message' => 'مبلغ نامتناسب'];
}

۳. Idempotency با Transient

$lock_key = 'zarinpal_lock_' . $order_id;
if (get_transient($lock_key)) {
    wp_die('در حال پردازش پرداخت قبلی...');
}
set_transient($lock_key, true, 30);
// ... پردازش ...
delete_transient($lock_key);

🔐 IPN و Webhook

برای درگاه‌هایی که Webhook دارند:

// در سازنده:
add_action('woocommerce_api_' . $this->id . '_webhook', [$this, 'handle_webhook']);

public function handle_webhook() {
    $payload = file_get_contents('php://input');
    $signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
    
    // تایید امضا
    $expected = hash_hmac('sha256', $payload, $this->webhook_secret);
    if (!hash_equals($expected, $signature)) {
        status_header(401);
        die('Invalid signature');
    }
    
    $data = json_decode($payload, true);
    $order = wc_get_order($data['metadata']['order_id'] ?? 0);
    
    if ($order && $data['status'] === 'paid' && !$order->is_paid()) {
        $order->payment_complete($data['ref_id']);
    }
    
    status_header(200);
    die('OK');
}

⚠️ مهم: همیشه از hash_equals() استفاده کنید نه === – این جلوی timing attack را می‌گیرد.

💸 پشتیبانی کامل از Refund

public function process_refund($order_id, $amount = null, $reason = '') {
    $order = wc_get_order($order_id);
    $ref_id = $order->get_meta('_zarinpal_ref_id');
    
    if (!$ref_id) {
        return new WP_Error('no_ref_id', 'کد پیگیری یافت نشد');
    }
    
    $response = wp_remote_post('https://api.gateway.com/refund', [
        'headers' => ['Authorization' => 'Bearer ' . $this->api_key],
        'body' => wp_json_encode([
            'transaction_id' => $ref_id,
            'amount' => $amount * 10,
            'reason' => $reason,
        ]),
    ]);
    
    $body = json_decode(wp_remote_retrieve_body($response), true);
    
    if ($body['success']) {
        $order->add_order_note(sprintf('بازگشت وجه موفق: %s', $body['refund_id']));
        return true;
    }
    
    return new WP_Error('refund_failed', $body['message'] ?? 'خطای نامشخص');
}

🎯 چند درگاه و انتخاب هوشمند

استراتژی ۱: انتخاب بر اساس مبلغ

add_filter('woocommerce_available_payment_gateways', function($gateways) {
    if (!is_checkout() || !WC()->cart) return $gateways;
    
    $total = WC()->cart->get_total('edit');
    
    if ($total > 10_000_000) {
        unset($gateways['icsd_zarinpal']); // فقط بانک ملت
    }
    if ($total < 100_000) {
        unset($gateways['mellat']); // فقط زرین‌پال
    }
    
    return $gateways;
});

استراتژی ۲: درگاه پشتیبان (Fallback)

public function process_payment($order_id) {
    $primary = $this->call_zarinpal($order_id);
    if (!$primary['success']) {
        $this->log('ZarinPal failed, trying Saman', 'warning');
        return $this->call_saman($order_id);
    }
    return $primary;
}

استراتژی ۳: A/B Testing درگاه

add_filter('woocommerce_available_payment_gateways', function($gateways) {
    $user_id = get_current_user_id() ?: wp_get_session_token();
    if (crc32($user_id) % 2 === 0) {
        unset($gateways['saman']);
    } else {
        unset($gateways['icsd_zarinpal']);
    }
    return $gateways;
});

🧪 تست و دیباگ درگاه

محیط Sandbox زرین‌پال

$base = $this->sandbox 
    ? 'https://sandbox.zarinpal.com/pg/v4/payment/'
    : 'https://payment.zarinpal.com/pg/v4/payment/';

چک‌لیست تست قبل از انتشار

  • پرداخت موفق با مبلغ کم (۱۰۰۰ تومان)
  • پرداخت ناموفق - کلیک روی دکمه انصراف
  • بستن مرورگر در حین پرداخت و باز کردن دوباره سفارش
  • پرداخت دو سفارش پشت‌سرهم
  • تست در موبایل
  • چک شدن لاگ در WooCommerce → Status → Logs
  • چک شدن وضعیت سفارش در پنل
  • چک شدن ref_id در meta سفارش
  • تست refund (در صورت پشتیبانی)

📝 خلاصه فصل

  • یاد گرفتید ساختار کامل یک Payment Gateway را
  • پیاده‌سازی کامل زرین‌پال REST API v4 را دیدید
  • با HPOS و سازگاری آن آشنا شدید
  • مدیریت Callback، Verify، Refund را پوشش دادید
  • استراتژی‌های چنددرگاهی و failover را یاد گرفتید
🎯 تمرین عملی: یک درگاه پرداخت برای IDPay یا NextPay با همین الگو پیاده‌سازی کنید.

نمایش سایت

رنگ سایت
حالت نمایش
اندازهٔ متن
خوانایی

این تنظیمات فقط روی مرورگر شما ذخیره می‌شود.