💳 فصل ۴: ساخت درگاه پرداخت ایرانی حرفهای
در این فصل یاد میگیرید چطور یک درگاه پرداخت ایرانی کامل (مثل زرینپال، سامان، ملت) را از صفر برای ووکامرس بسازید و آن را استاندارد بازار کنید.
🔍 آناتومی یک درگاه پرداخت ووکامرس
هر درگاه پرداخت در ووکامرس از سه بخش اصلی تشکیل میشود:
| بخش | نقش | مسئولیت |
|---|---|---|
| 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 با همین الگو پیادهسازی کنید.