🔁 فصل ۱۱: اشتراک و رزرواسیون
در این فصل یاد میگیرید چطور سیستم اشتراک ماهانه (مثل سرویس قهوه) و سیستم رزرواسیون (مثل کلاس آموزشی یا اتاق هتل) را در ووکامرس پیادهسازی کنید — هم با افزونه و هم اختصاصی از صفر.
🤔 چه زمانی به اشتراک نیاز دارید؟
- سرویسهای ماهانه – قهوه، چای، گل، مجله
- عضویت سایت – دسترسی به محتوای پولی
- SaaS – نرمافزار بهعنوان سرویس
- کلاسهای آنلاین – اشتراک ماهانه دروس
- گارانتی توسعهیافته – پرداخت سالانه
🛒 افزونههای آماده
| افزونه | ویژگیها | قیمت |
|---|---|---|
| WooCommerce Subscriptions | رسمی، کامل، پشتیبانی هلپدسک | $199/سال |
| YITH WooCommerce Subscription | تقریباً معادل، پشتیبانی YITH | رایگان + Pro |
| SUMO Subscriptions | سفارشیسازی بالا | $59 (CodeCanyon) |
| WC Bookings (Official) | رزرواسیون رسمی | $249/سال |
| YITH Booking | رزرو + اجاره | رایگان + Pro |
💡 توصیه برای ایران: چون افزونههای پولی Subscriptions با درگاه ایرانی بهخوبی کار نمیکنند، پیادهسازی اختصاصی توصیه میشود.
🛠️ ساخت سیستم اشتراک ساده
پایه: Custom Product Type «اشتراک» + جدول اختصاصی + Cron Job:
۱. ایجاد Custom Product Type
includes/class-wc-product-subscription.php
<?php
class WC_Product_Subscription extends WC_Product {
public function __construct($product = 0) {
$this->product_type = 'subscription';
parent::__construct($product);
}
public function get_type() { return 'subscription'; }
public function get_billing_period() {
return $this->get_meta('_subscription_period') ?: 'month';
}
public function get_billing_interval() {
return absint($this->get_meta('_subscription_interval')) ?: 1;
}
public function get_trial_days() {
return absint($this->get_meta('_subscription_trial_days'));
}
public function get_signup_fee() {
return floatval($this->get_meta('_subscription_signup_fee'));
}
public function get_price_html($deprecated = '') {
$price = wc_price($this->get_price());
$period = $this->get_billing_period();
$interval = $this->get_billing_interval();
$period_label = [
'day' => 'روز',
'week' => 'هفته',
'month' => 'ماه',
'year' => 'سال',
][$period] ?? 'دوره';
if ($interval > 1) {
return sprintf('%s / هر %d %s', $price, $interval, $period_label);
}
return sprintf('%s / %s', $price, $period_label);
}
}
۲. جدول اشتراکها
// ساخت جدول هنگام فعالسازی افزونه
register_activation_hook(__FILE__, function() {
global $wpdb;
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}icsd_subscriptions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
period VARCHAR(10) NOT NULL,
interval_count INT NOT NULL DEFAULT 1,
amount DECIMAL(15,2) NOT NULL,
next_payment DATETIME NOT NULL,
last_payment DATETIME,
end_date DATETIME,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY status (status),
KEY next_payment (next_payment)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
});
۳. ثبت اشتراک هنگام پرداخت موفق
add_action('woocommerce_order_status_processing', function($order_id) {
$order = wc_get_order($order_id);
if (!$order) return;
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product || $product->get_type() !== 'subscription') continue;
$period = $product->get_billing_period();
$interval = $product->get_billing_interval();
$trial_days = $product->get_trial_days();
// محاسبه next_payment
$next = new DateTime();
if ($trial_days > 0) {
$next->modify("+{$trial_days} days");
} else {
$next->modify("+{$interval} {$period}");
}
global $wpdb;
$wpdb->insert($wpdb->prefix . 'icsd_subscriptions', [
'user_id' => $order->get_user_id(),
'product_id' => $product->get_id(),
'order_id' => $order_id,
'status' => 'active',
'period' => $period,
'interval_count' => $interval,
'amount' => $product->get_price(),
'next_payment' => $next->format('Y-m-d H:i:s'),
'created_at' => current_time('mysql'),
]);
// ارسال ایمیل
do_action('icsd_subscription_created', $wpdb->insert_id, $order);
}
});
⏰ پرداخت تکراری با Cron
هر روز بررسی شود کدام اشتراکها سررسیدشان امروز است:
// زمانبندی Cron
add_action('init', function() {
if (!wp_next_scheduled('icsd_process_recurring_payments')) {
wp_schedule_event(time(), 'hourly', 'icsd_process_recurring_payments');
}
});
add_action('icsd_process_recurring_payments', function() {
global $wpdb;
$due = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}icsd_subscriptions
WHERE status = 'active'
AND next_payment <= NOW()
LIMIT 50"
);
foreach ($due as $sub) {
process_subscription_renewal($sub);
}
});
function process_subscription_renewal($subscription) {
global $wpdb;
$user = get_user_by('ID', $subscription->user_id);
if (!$user) return;
// ساخت سفارش renewal
$order = wc_create_order(['customer_id' => $user->ID]);
$product = wc_get_product($subscription->product_id);
$order->add_product($product, 1, ['total' => $subscription->amount]);
// کپی آدرس از سفارش اصلی
$original = wc_get_order($subscription->order_id);
$order->set_address($original->get_address('billing'), 'billing');
$order->set_address($original->get_address('shipping'), 'shipping');
$order->update_meta_data('_subscription_id', $subscription->id);
$order->update_meta_data('_renewal', 'yes');
$order->set_status('pending');
$order->calculate_totals();
$order->save();
// اطلاع به کاربر برای پرداخت
$payment_url = $order->get_checkout_payment_url();
wp_mail($user->user_email,
'پرداخت اشتراک شما در سررسید',
'لینک پرداخت: ' . $payment_url
);
// پشتیبانی از Direct Debit (در صورت موجود بودن)
if (apply_filters('icsd_can_charge_directly', false, $user->ID)) {
try_direct_charge($subscription, $order);
}
}
function try_direct_charge($subscription, $order) {
// اگر کارت ذخیرهشده وجود دارد، از API بانک بخواه
$token = get_user_meta($subscription->user_id, '_payment_token', true);
if (!$token) return;
$response = wp_remote_post('https://api.gateway.com/charge', [
'headers' => ['Authorization' => 'Bearer ' . API_KEY],
'body' => wp_json_encode([
'token' => $token,
'amount' => $order->get_total() * 10, // به ریال
'order_id' => $order->get_id(),
]),
]);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($body['success']) {
$order->payment_complete($body['ref_id']);
update_subscription_after_payment($subscription, true);
} else {
// ۳ بار تلاش بعدی، سپس expire
increment_failed_attempts($subscription);
}
}
function update_subscription_after_payment($subscription, $success) {
global $wpdb;
if (!$success) return;
$next = new DateTime($subscription->next_payment);
$next->modify("+{$subscription->interval_count} {$subscription->period}");
$wpdb->update($wpdb->prefix . 'icsd_subscriptions', [
'next_payment' => $next->format('Y-m-d H:i:s'),
'last_payment' => current_time('mysql'),
], ['id' => $subscription->id]);
}
⚠️ نکته مهم: در ایران Direct Debit (پرداخت خودکار) فعلاً از طریق پرداخت مستقیم زرینپال یا دایرکت بانک ملت ممکن است. در غیر اینصورت، روش «ارسال لینک به کاربر» بهترین گزینه است.
📅 مفهوم رزرواسیون
رزرواسیون = محصولی که کاربر یک «بازه زمانی» از آن را میخرد:
| مثال | واحد زمان | مدت |
|---|---|---|
| هتل | روز | چند روز |
| کلاس آموزشی | ساعت | ۱-۲ ساعت |
| میز رستوران | ۳۰ دقیقه | ۲-۳ ساعت |
| اجاره دستگاه | روز | چند روز/هفته |
📆 سیستم رزرو با FullCalendar
۱. Custom Product Type «Booking»
class WC_Product_Booking extends WC_Product {
public function __construct($product = 0) {
$this->product_type = 'booking';
parent::__construct($product);
}
public function get_type() { return 'booking'; }
public function get_resource_id() {
return absint($this->get_meta('_resource_id'));
}
public function get_duration_minutes() {
return absint($this->get_meta('_duration_minutes')) ?: 60;
}
public function get_available_hours() {
return $this->get_meta('_available_hours') ?: '09:00-18:00';
}
}
۲. جدول رزروها
CREATE TABLE wp_icsd_bookings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED,
start_datetime DATETIME NOT NULL,
end_datetime DATETIME NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
KEY product_id (product_id),
KEY date_range (start_datetime, end_datetime),
KEY status (status)
);
۳. صفحه محصول با FullCalendar
// لود FullCalendar
add_action('wp_enqueue_scripts', function() {
if (!is_product()) return;
global $product;
if (!$product || $product->get_type() !== 'booking') return;
wp_enqueue_script('fullcalendar',
'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js',
[], '6.1.10', true);
wp_enqueue_script('icsd-booking', plugins_url('assets/booking.js', __FILE__),
['fullcalendar'], '1.0', true);
});
// قالب فرم رزرو
add_action('woocommerce_single_product_summary', function() {
global $product;
if ($product->get_type() !== 'booking') return;
?>
۴. JavaScript FullCalendar
assets/booking.js
document.addEventListener('DOMContentLoaded', function() {
const calendarEl = document.getElementById('calendar');
const productId = parseInt(document.querySelector('[name="add-to-cart"]').value);
const calendar = new FullCalendar.Calendar(calendarEl, {
locale: 'fa',
direction: 'rtl',
firstDay: 6, // شنبه
initialView: 'timeGridWeek',
slotMinTime: '08:00',
slotMaxTime: '20:00',
allDaySlot: false,
selectable: true,
selectMirror: true,
// بارگذاری زمانهای رزرو شده از API
events: function(info, success, failure) {
fetch('/wp-json/icsd/v1/bookings/' + productId +
'?start=' + info.startStr + '&end=' + info.endStr)
.then(r => r.json())
.then(data => success(data))
.catch(failure);
},
select: function(info) {
// چک تداخل با رزروهای موجود
const events = calendar.getEvents();
const overlap = events.some(e =>
info.start < e.end && info.end > e.start
);
if (overlap) {
alert('این زمان قبلاً رزرو شده');
calendar.unselect();
return;
}
document.getElementById('selected_start').value = info.startStr;
document.getElementById('selected_end').value = info.endStr;
document.getElementById('time-display').textContent =
info.start.toLocaleString('fa-IR') + ' تا ' +
info.end.toLocaleString('fa-IR');
document.getElementById('selected-time').style.display = 'block';
},
eventColor: '#dc3545', // قرمز برای رزرو شده
eventTextColor: 'white',
});
calendar.render();
});
۵. Endpoint API برای رزروهای موجود
add_action('rest_api_init', function() {
register_rest_route('icsd/v1', '/bookings/(?P<product_id>d+)', [
'methods' => 'GET',
'callback' => function($request) {
global $wpdb;
$product_id = absint($request['product_id']);
$start = $request->get_param('start');
$end = $request->get_param('end');
$bookings = $wpdb->get_results($wpdb->prepare(
"SELECT start_datetime, end_datetime FROM {$wpdb->prefix}icsd_bookings
WHERE product_id = %d AND status IN ('confirmed', 'pending')
AND start_datetime < %s AND end_datetime > %s",
$product_id, $end, $start
));
$events = [];
foreach ($bookings as $b) {
$events[] = [
'title' => 'رزرو شده',
'start' => $b->start_datetime,
'end' => $b->end_datetime,
'display' => 'block',
];
}
return $events;
},
'permission_callback' => '__return_true',
]);
});
🔄 مدیریت زمانهای در دسترس
قوانین در دسترس بودن
function is_time_available($product_id, $start, $end) {
global $wpdb;
// ۱. چک ساعت کاری
$product = wc_get_product($product_id);
list($work_start, $work_end) = explode('-', $product->get_meta('_available_hours'));
$start_time = (new DateTime($start))->format('H:i');
$end_time = (new DateTime($end))->format('H:i');
if ($start_time < $work_start || $end_time > $work_end) {
return ['ok' => false, 'reason' => 'خارج از ساعت کاری'];
}
// ۲. چک روزهای تعطیل
$weekday = (int) (new DateTime($start))->format('w');
$closed_days = (array) $product->get_meta('_closed_days'); // [5] = جمعه
if (in_array($weekday, $closed_days)) {
return ['ok' => false, 'reason' => 'تعطیل است'];
}
// ۳. چک تداخل با رزروهای دیگر
$conflict = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}icsd_bookings
WHERE product_id = %d
AND status IN ('confirmed', 'pending')
AND start_datetime < %s AND end_datetime > %s",
$product_id, $end, $start
));
if ($conflict > 0) {
return ['ok' => false, 'reason' => 'این زمان رزرو شده'];
}
return ['ok' => true];
}
پاکسازی رزروهای منقضی
// رزروهای pending که ۱۵ دقیقه پرداخت نشدند، حذف شوند
add_action('init', function() {
if (!wp_next_scheduled('icsd_cleanup_pending_bookings')) {
wp_schedule_event(time(), 'hourly', 'icsd_cleanup_pending_bookings');
}
});
add_action('icsd_cleanup_pending_bookings', function() {
global $wpdb;
$wpdb->query(
"UPDATE {$wpdb->prefix}icsd_bookings
SET status = 'cancelled'
WHERE status = 'pending'
AND created_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE)"
);
});
📝 خلاصه فصل
- Custom Product Type «اشتراک» با دوره و فاصله
- جدول اختصاصی برای رهگیری اشتراکها
- پرداخت تکراری با WP Cron + ایمیل لینک
- سیستم رزرو با FullCalendar.js
- API برای زمانهای موجود
- قوانین در دسترس بودن (ساعت، تعطیلات، تداخل)