~/icsd.ir — bash
SYSTEM_ONLINE

🔁 فصل ۱۱: اشتراک و رزرواسیون

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

🤔 چه زمانی به اشتراک نیاز دارید؟

  • سرویس‌های ماهانه – قهوه، چای، گل، مجله
  • عضویت سایت – دسترسی به محتوای پولی
  • SaaS – نرم‌افزار به‌عنوان سرویس
  • کلاس‌های آنلاین – اشتراک ماهانه دروس
  • گارانتی توسعه‌یافته – پرداخت سالانه

🛠️ ساخت سیستم اشتراک ساده

پایه: 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 برای زمان‌های موجود
  • قوانین در دسترس بودن (ساعت، تعطیلات، تداخل)

نمایش سایت

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

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