در این آموزش یک باشگاه مشتریان اختصاصی برای WooCommerce میسازیم که مشتری با خرید امتیاز میگیرد، موجودی امتیاز و تاریخچه خود را در My Account میبیند، براساس امتیازهای جمعشده به سطحهایی مثل عادی، نقرهای و طلایی میرسد و میتواند بخشی از امتیاز خود را به پاداش خرید تبدیل کند. در کنار اینها، سیستم باید بتواند Refund را مدیریت کند، امتیاز تکراری نسازد، جلوی خرجکردن بیش از موجودی را بگیرد و در صورت نیاز امتیازهای غیرفعال را منقضی کند.
در پایان چه چیزی داریم؟ یک افزونه اختصاصی WooCommerce با دفتر ثبت امتیاز، موجودی فعلی، امتیاز کل کسبشده، سطح مشتری، امتیاز خرید، برگشت امتیاز پس از Refund، تبدیل امتیاز به کوپن یکبارمصرف، تاریخچه My Account، انقضای امتیاز و ساختار قابل توسعه برای Referral و Review.
باشگاه مشتریان WooCommerce دقیقاً چیست؟
در سادهترین حالت، مشتری بابت خرید امتیاز میگیرد و بعداً همان امتیاز را خرج میکند. اما یک Loyalty Program قابل اعتماد فقط یک عدد داخل User Meta نیست. باید بتواند توضیح دهد هر امتیاز از کجا آمده، چه زمانی خرج شده، آیا سفارش مربوط Refund شده و آیا امتیاز یک رویداد قبلاً ثبت شده است یا نه.
| بخش | وظیفه |
|---|---|
| موجودی امتیاز | مشخص میکند مشتری اکنون چند امتیاز قابل استفاده دارد. |
| دفتر امتیاز | تمام افزایش و کاهشهای امتیاز را با علت و تاریخ ثبت میکند. |
| قانون کسب امتیاز | تعیین میکند هر مقدار خرید چند امتیاز داشته باشد. |
| پاداش | امتیاز را به تخفیف یا مزیت قابل استفاده تبدیل میکند. |
| سطح مشتری | مشتری وفادار را براساس سابقه واقعی از مشتری جدید جدا میکند. |
| Refund | در صورت برگشت خرید، امتیاز مربوط را نیز اصلاح میکند. |
| Idempotency | جلو میگیرد یک سفارش یا رویداد دوبار امتیاز ایجاد کند. |
آیا لازم است این سیستم را از صفر بسازیم؟
نه همیشه. WooCommerce افزونه رسمی Points and Rewards دارد که میتواند برای خرید، ثبتنام و ثبت Review امتیاز بدهد، امتیاز را به تخفیف تبدیل کند، مقدار پاداش را در سطح محصول یا دسته تغییر دهد و تاریخچه امتیاز را در My Account نشان دهد.
اگر همین امکانات برای فروشگاه کافی است، استفاده از افزونه آماده منطقیتر است. توسعه اختصاصی زمانی ارزش دارد که قوانین خاصی مثل سطح VIP، اتصال CRM، برنامه Referral اختصاصی، انقضای خاص، اتصال POS، چند فروشگاه یا گزارش داخلی متفاوت داشته باشید.
مقاله مرتبط
باشگاه مشتریان مستقیماً روی اطلاعات کاربر و سفارش اثر میگذارد. قبل از نصب نسخه آزمایشی روی سایت اصلی، راهنمای ساخت بکاپ خودکار WordPress روی S3 را ببینید تا در صورت خطای افزونه یا دیتابیس مسیر بازگشت داشته باشید.
چرا نباید فقط یک User Meta با نام points بسازیم؟
فرض کنید مشتری ۵۰۰ امتیاز دارد. با دیدن همین عدد نمیدانیم ۵۰۰ امتیاز از کدام سفارشها آمده، آیا ۲۰۰ امتیاز قبلاً خرج شده، Refund داشته یا مدیر فروشگاه امتیاز دستی اضافه کرده است.
برای همین دو مفهوم را جدا میکنیم:
Account: خلاصه سریع موجودی مشتری.
Ledger: دفتر تغییرات که هر افزایش و کاهش امتیاز را با علت نگه میدارد.
اگر روزی موجودی یک مشتری اشتباه شود، Ledger امکان بازسازی آن را فراهم میکند. این همان تفاوت یک سیستم قابل حسابرسی با یک عدد ساده است.
ساختار افزونه باشگاه مشتریان
کد را داخل قالب قرار نمیدهیم. یک افزونه مستقل میسازیم تا تغییر قالب یا بروزرسانی Theme روی باشگاه مشتریان اثر نگذارد.
wp-content/plugins/iranica-woo-loyalty/
├── iranica-woo-loyalty.php
├── includes/
│ ├── install.php
│ ├── ledger.php
│ ├── earning.php
│ ├── redeem.php
│ ├── account.php
│ └── scheduler.php
└── assets/
└── loyalty.js
مرحله ۱: فایل اصلی افزونه را بسازید
در مسیر wp-content/plugins/ پوشه iranica-woo-loyalty را ایجاد کنید. سپس فایل iranica-woo-loyalty.php را بسازید:
<?php
/**
* Plugin Name: Iranica Woo Loyalty
* Description: باشگاه مشتریان، امتیاز و پاداش برای WooCommerce
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
define( 'IRLOYALTY_VERSION', '1.0.0' );
define( 'IRLOYALTY_FILE', __FILE__ );
define( 'IRLOYALTY_DIR', plugin_dir_path( __FILE__ ) );
require_once IRLOYALTY_DIR . 'includes/install.php';
require_once IRLOYALTY_DIR . 'includes/ledger.php';
require_once IRLOYALTY_DIR . 'includes/earning.php';
require_once IRLOYALTY_DIR . 'includes/redeem.php';
require_once IRLOYALTY_DIR . 'includes/account.php';
require_once IRLOYALTY_DIR . 'includes/scheduler.php';
add_action(
'plugins_loaded',
function() {
if ( ! class_exists( 'WooCommerce' ) ) {
add_action(
'admin_notices',
function() {
echo '<div class="notice notice-error">';
echo '<p>Iranica Woo Loyalty به WooCommerce فعال نیاز دارد.</p>';
echo '</div>';
}
);
}
}
);
نتیجه مورد انتظار: افزونه باید در صفحه افزونههای WordPress دیده شود. فعلاً فعالش نکنید، چون هنوز جدولهای لازم را نساختهایم.
اگر سایت Fatal Error داد: معمولاً یکی از فایلهای داخل includes ساخته نشده یا نام آن اشتباه است. پوشه افزونه را موقتاً Rename کنید تا WordPress افزونه را غیرفعال کند، سپس مسیر فایلها را بررسی کنید.
مرحله ۲: دیتابیس باشگاه مشتریان را طراحی کنید
سه جدول میسازیم: حساب خلاصه مشتری، دفتر امتیازها و تبدیل امتیاز به پاداش.
فایل includes/install.php:
<?php
defined( 'ABSPATH' ) || exit;
function irloyalty_install_tables() {
global $wpdb;
$accounts =
$wpdb->prefix . 'irloyalty_accounts';
$ledger =
$wpdb->prefix . 'irloyalty_ledger';
$redemptions =
$wpdb->prefix . 'irloyalty_redemptions';
$charset =
$wpdb->get_charset_collate();
require_once ABSPATH .
'wp-admin/includes/upgrade.php';
$sql_accounts = "CREATE TABLE {$accounts} (
user_id bigint(20) unsigned NOT NULL,
balance_points bigint(20) NOT NULL DEFAULT 0,
lifetime_earned bigint(20) unsigned NOT NULL DEFAULT 0,
lifetime_redeemed bigint(20) unsigned NOT NULL DEFAULT 0,
tier varchar(30) NOT NULL DEFAULT 'bronze',
last_activity_at datetime NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (user_id),
KEY tier (tier),
KEY last_activity_at (last_activity_at)
) {$charset};";
$sql_ledger = "CREATE TABLE {$ledger} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
event_key varchar(190) NOT NULL,
points bigint(20) NOT NULL,
source varchar(50) NOT NULL,
order_id bigint(20) unsigned NULL,
reference_id bigint(20) unsigned NULL,
note varchar(255) NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY event_key (event_key),
KEY user_id (user_id),
KEY order_id (order_id),
KEY source (source),
KEY created_at (created_at)
) {$charset};";
$sql_redemptions = "CREATE TABLE {$redemptions} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
points bigint(20) unsigned NOT NULL,
discount_amount decimal(20,4) NOT NULL,
coupon_id bigint(20) unsigned NULL,
coupon_code varchar(190) NULL,
status varchar(30) NOT NULL DEFAULT 'active',
expires_at datetime NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY coupon_id (coupon_id),
KEY status (status),
KEY expires_at (expires_at)
) {$charset};";
dbDelta( $sql_accounts );
dbDelta( $sql_ledger );
dbDelta( $sql_redemptions );
update_option(
'irloyalty_db_version',
IRLOYALTY_VERSION
);
add_option(
'irloyalty_spend_per_point',
'100'
);
add_option(
'irloyalty_point_value',
'1'
);
add_option(
'irloyalty_min_redeem_points',
'100'
);
add_option(
'irloyalty_coupon_days',
'30'
);
add_option(
'irloyalty_expire_inactivity_days',
'365'
);
}
اعداد بالا نمونه اولیهاند. معنی آنها این است که بهطور پیشفرض به ازای هر ۱۰۰ واحد پول فروشگاه، یک امتیاز ساخته میشود و هر امتیاز هنگام تبدیل به پاداش یک واحد ارزش دارد. اگر ارز فروشگاه تومان، ریال، دلار یا ارز دیگری است، قبل از استفاده واقعی باید نسبتها را متناسب با اقتصاد فروشگاه تنظیم کنید.
مهم: این اعداد پیشنهاد مالی برای فروشگاه شما نیستند. اگر حاشیه سود محصول ۵ درصد است، برنامهای که عملاً ۱۰ درصد پاداش میدهد میتواند زیانساز شود. نرخ امتیاز باید با حاشیه سود واقعی، تخفیفهای فعال و هزینه بازگشت مشتری محاسبه شود.
Activation افزونه
function irloyalty_activate() {
irloyalty_install_tables();
irloyalty_register_account_endpoint();
flush_rewrite_rules();
}
function irloyalty_deactivate() {
if (
function_exists(
'as_unschedule_all_actions'
)
) {
as_unschedule_all_actions(
'irloyalty_expiry_scan',
array(),
'iranica-loyalty'
);
}
flush_rewrite_rules();
}
register_activation_hook(
IRLOYALTY_FILE,
'irloyalty_activate'
);
register_deactivation_hook(
IRLOYALTY_FILE,
'irloyalty_deactivate'
);
Rollback: غیرفعالکردن افزونه امتیازها را پاک نمیکند. این رفتار عمدی است. حذف اطلاعات مشتری فقط باید در Uninstall و با تصمیم صریح مدیر انجام شود.
مرحله ۳: موتور اصلی امتیاز یا Ledger را بسازید
این مهمترین بخش سیستم است. هر تغییر امتیاز یک event_key یکتا دارد. اگر یک Hook به هر دلیل دوبار اجرا شود، همان Event اجازه ثبت دوباره ندارد.
فایل includes/ledger.php:
<?php
defined( 'ABSPATH' ) || exit;
function irloyalty_get_tier(
int $lifetime_earned
): string {
if ( $lifetime_earned >= 5000 ) {
return 'gold';
}
if ( $lifetime_earned >= 1000 ) {
return 'silver';
}
return 'bronze';
}
function irloyalty_ensure_account(
int $user_id
): bool {
global $wpdb;
$table =
$wpdb->prefix . 'irloyalty_accounts';
$exists = $wpdb->get_var(
$wpdb->prepare(
"
SELECT user_id
FROM {$table}
WHERE user_id = %d
",
$user_id
)
);
if ( $exists ) {
return true;
}
$now = current_time(
'mysql',
true
);
$result = $wpdb->insert(
$table,
array(
'user_id' => $user_id,
'balance_points' => 0,
'lifetime_earned' => 0,
'lifetime_redeemed' => 0,
'tier' => 'bronze',
'last_activity_at' => $now,
'updated_at' => $now,
),
array(
'%d',
'%d',
'%d',
'%d',
'%s',
'%s',
'%s',
)
);
return false !== $result;
}
تابع اتمیک افزایش و کاهش امتیاز
function irloyalty_apply_points(
int $user_id,
int $points,
string $event_key,
string $source,
int $order_id = 0,
int $reference_id = 0,
string $note = '',
bool $allow_negative = false
) {
global $wpdb;
if (
$user_id < 1 ||
0 === $points ||
'' === $event_key
) {
return new WP_Error(
'invalid_points_request',
'درخواست امتیاز معتبر نیست.'
);
}
if ( ! irloyalty_ensure_account(
$user_id
) ) {
return new WP_Error(
'account_create_failed',
'حساب باشگاه مشتری ساخته نشد.'
);
}
$accounts =
$wpdb->prefix . 'irloyalty_accounts';
$ledger =
$wpdb->prefix . 'irloyalty_ledger';
$wpdb->query(
'START TRANSACTION'
);
$duplicate = $wpdb->get_var(
$wpdb->prepare(
"
SELECT id
FROM {$ledger}
WHERE event_key = %s
LIMIT 1
",
$event_key
)
);
if ( $duplicate ) {
$wpdb->query( 'ROLLBACK' );
return new WP_Error(
'duplicate_event',
'این رویداد قبلاً پردازش شده است.'
);
}
$account = $wpdb->get_row(
$wpdb->prepare(
"
SELECT *
FROM {$accounts}
WHERE user_id = %d
FOR UPDATE
",
$user_id
)
);
if ( ! $account ) {
$wpdb->query( 'ROLLBACK' );
return new WP_Error(
'account_missing',
'حساب امتیاز پیدا نشد.'
);
}
$new_balance =
(int) $account->balance_points
+ $points;
if (
$new_balance < 0 &&
! $allow_negative
) {
$wpdb->query( 'ROLLBACK' );
return new WP_Error(
'insufficient_points',
'امتیاز کافی وجود ندارد.'
);
}
$now = current_time(
'mysql',
true
);
$inserted = $wpdb->insert(
$ledger,
array(
'user_id' => $user_id,
'event_key' =>
sanitize_text_field(
$event_key
),
'points' => $points,
'source' =>
sanitize_key( $source ),
'order_id' =>
$order_id ?: null,
'reference_id' =>
$reference_id ?: null,
'note' =>
sanitize_text_field(
$note
),
'created_at' => $now,
)
);
if ( false === $inserted ) {
$wpdb->query( 'ROLLBACK' );
return new WP_Error(
'ledger_insert_failed',
'ثبت دفتر امتیاز ناموفق بود.'
);
}
$lifetime_earned =
(int) $account->lifetime_earned;
$lifetime_redeemed =
(int) $account->lifetime_redeemed;
if ( $points > 0 ) {
$lifetime_earned += $points;
}
if (
$points < 0 &&
'redemption' === $source
) {
$lifetime_redeemed +=
abs( $points );
}
$tier = irloyalty_get_tier(
$lifetime_earned
);
$updated = $wpdb->update(
$accounts,
array(
'balance_points' =>
$new_balance,
'lifetime_earned' =>
$lifetime_earned,
'lifetime_redeemed' =>
$lifetime_redeemed,
'tier' => $tier,
'last_activity_at' =>
$now,
'updated_at' => $now,
),
array(
'user_id' => $user_id,
)
);
if ( false === $updated ) {
$wpdb->query( 'ROLLBACK' );
return new WP_Error(
'account_update_failed',
'بروزرسانی موجودی انجام نشد.'
);
}
$wpdb->query( 'COMMIT' );
return array(
'balance' => $new_balance,
'tier' => $tier,
);
}
چرا Transaction مهم است؟ اگر دو درخواست همزمان بخواهند امتیاز یک مشتری را خرج کنند، ردیف حساب با FOR UPDATE قفل میشود و درخواست دوم باید تا پایان عملیات اول صبر کند. این کار خطر خرجکردن دوباره یک موجودی واحد را کاهش میدهد.
مرحله ۴: امتیاز خرید را فقط یک بار ثبت کنید
بزرگترین خطای برنامههای Loyalty این است که به هر بار اجرای Hook امتیاز بدهند. تغییر وضعیت یک Order ممکن است چند بار رخ دهد. برای همین Event سفارش باید یکتا باشد.
فایل includes/earning.php:
<?php
defined( 'ABSPATH' ) || exit;
function irloyalty_get_order_eligible_total(
WC_Order $order
): float {
$total = 0.0;
foreach (
$order->get_items( 'line_item' )
as $item
) {
$total +=
(float) $item->get_total();
}
return max( 0, $total );
}
function irloyalty_calculate_order_points(
WC_Order $order
): int {
$eligible =
irloyalty_get_order_eligible_total(
$order
);
$spend_per_point = max(
0.0001,
(float) get_option(
'irloyalty_spend_per_point',
'100'
)
);
return max(
0,
(int) floor(
$eligible /
$spend_per_point
)
);
}
در این فرمول از مجموع Line Itemها بعد از تخفیف و قبل از Tax استفاده میکنیم. بنابراین هزینه ارسال و مالیات بهطور پیشفرض امتیاز تولید نمیکنند.
Hook تغییر وضعیت سفارش
add_action(
'woocommerce_order_status_changed',
function(
$order_id,
$old_status,
$new_status,
$order
) {
if (
! $order instanceof WC_Order
) {
$order =
wc_get_order( $order_id );
}
if (
! $order instanceof WC_Order
) {
return;
}
$allowed_statuses = apply_filters(
'irloyalty_earning_statuses',
array(
'processing',
'completed',
)
);
if (
! in_array(
$new_status,
$allowed_statuses,
true
)
) {
return;
}
$user_id =
(int) $order->get_user_id();
if ( $user_id < 1 ) {
return;
}
$points =
irloyalty_calculate_order_points(
$order
);
if ( $points < 1 ) {
return;
}
$result =
irloyalty_apply_points(
$user_id,
$points,
'order:' .
$order_id .
':earn',
'order',
$order_id,
0,
'امتیاز خرید سفارش'
);
if (
! is_wp_error( $result )
) {
$order->add_order_note(
sprintf(
'%d امتیاز باشگاه مشتریان ثبت شد.',
$points
)
);
}
},
20,
4
);
اگر Order از Processing به Completed برود، Hook دوباره اجرا میشود؛ اما event_key همان order:ID:earn است و Ledger اجازه ثبت امتیاز دوم را نمیدهد.
درگاه و پرداخت در محل: زمان مناسب اعطای امتیاز برای همه فروشگاهها یکسان نیست. افزونه رسمی Points and Rewards نیز براساس تکمیل پرداخت یا عبور سفارش به وضعیتهای معتبر امتیاز میدهد. اگر فروشگاه شما COD دارد، ممکن است Processing قبل از دریافت پول ایجاد شود. وضعیت کسب امتیاز را با فرایند واقعی تحویل و دریافت وجه خودتان تنظیم کنید.
مرحله ۵: Refund باید امتیاز خرید را هم اصلاح کند
اگر مشتری کالایی را برگشت دهد ولی امتیاز خرید آن باقی بماند، میتواند هم پولش را پس بگیرد و هم امتیاز را خرج کند. بنابراین Refund بخشی از منطق اصلی Loyalty است، نه قابلیت جانبی.
برای Refund جزئی، فقط مبلغ کالاهای Refundشده را در محاسبه میآوریم:
function irloyalty_get_refund_eligible_total(
WC_Order_Refund $refund
): float {
$total = 0.0;
foreach (
$refund->get_items( 'line_item' )
as $item
) {
$total +=
abs(
(float) $item->get_total()
);
}
return $total;
}
add_action(
'woocommerce_order_refunded',
function(
$order_id,
$refund_id
) {
$order =
wc_get_order( $order_id );
$refund =
wc_get_order( $refund_id );
if (
! $order instanceof WC_Order ||
! $refund instanceof
WC_Order_Refund
) {
return;
}
$user_id =
(int) $order->get_user_id();
if ( $user_id < 1 ) {
return;
}
$refunded_total =
irloyalty_get_refund_eligible_total(
$refund
);
if ( $refunded_total <= 0 ) {
return;
}
$spend_per_point = max(
0.0001,
(float) get_option(
'irloyalty_spend_per_point',
'100'
)
);
$points =
(int) floor(
$refunded_total /
$spend_per_point
);
if ( $points < 1 ) {
return;
}
irloyalty_apply_points(
$user_id,
-$points,
'refund:' .
$refund_id .
':reverse',
'refund',
$order_id,
$refund_id,
'کسر امتیاز بابت Refund',
true
);
},
20,
2
);
پارامتر آخر true اجازه میدهد موجودی بعد از Refund منفی شود. این وضعیت عجیب به نظر میرسد اما ممکن است واقعاً رخ دهد: مشتری ابتدا امتیاز خرید را گرفته و خرج کرده، سپس همان خرید Refund شده است. افزونه رسمی WooCommerce Points and Rewards نیز در چنین شرایطی میتواند موجودی منفی ایجاد کند تا مشتری ابتدا بدهی امتیازی خود را جبران کند.
اگر Refund فقط بهصورت مبلغ دستی و بدون Line Item ثبت شود، کد بالا نمیتواند بفهمد کدام کالا برگشته است. در چنین فروشگاهی باید برای Refundهای Amount-only یک Rule جدا یا بررسی دستی مدیر داشته باشید.
مرحله ۶: سطح VIP را از موجودی قابل خرج جدا کنید
اگر سطح مشتری را براساس موجودی فعلی بسازیم، مشتری بعد از خرج امتیاز ممکن است ناگهان از Gold به Bronze برگردد. این رفتار معمولاً منطقی نیست.
در معماری ما Tier از lifetime_earned ساخته میشود، نه از Balance. مثال این آموزش:
| سطح | امتیاز کسبشده تاریخی | کاربرد احتمالی |
|---|---|---|
| Bronze | کمتر از ۱۰۰۰ | عضو عادی باشگاه |
| Silver | ۱۰۰۰ تا ۴۹۹۹ | امتیاز بیشتر یا پیشنهاد اختصاصی |
| Gold | ۵۰۰۰ به بالا | مزایای VIP طبق سیاست فروشگاه |
این مرزها فقط نمونه فنی هستند. اگر فروشگاه شما متوسط سفارش متفاوتی دارد باید Thresholdها را براساس رفتار واقعی مشتری تنظیم کنید.
مرحله ۷: امتیاز را به پاداش قابل استفاده تبدیل کنیم
WooCommerce بهصورت بومی Coupon دارد. برای نسخه پایه بهجای دستکاری مستقیم قیمت Checkout، مشتری بخشی از امتیاز را به یک کوپن یکبارمصرف تبدیل میکند. این کوپن به ایمیل همان مشتری محدود میشود و تاریخ انقضا دارد.
فایل includes/redeem.php:
<?php
defined( 'ABSPATH' ) || exit;
add_action(
'rest_api_init',
function() {
register_rest_route(
'iranica-loyalty/v1',
'/redeem',
array(
'methods' =>
WP_REST_Server::CREATABLE,
'callback' =>
'irloyalty_rest_redeem',
'permission_callback' =>
function() {
return is_user_logged_in();
},
)
);
}
);
ساخت پاداش
function irloyalty_rest_redeem(
WP_REST_Request $request
) {
global $wpdb;
if ( ! wc_coupons_enabled() ) {
return new WP_Error(
'coupons_disabled',
'برای تبدیل امتیاز، Coupon باید در WooCommerce فعال باشد.',
array( 'status' => 409 )
);
}
$user_id =
get_current_user_id();
$points = absint(
$request->get_param(
'points'
)
);
$minimum = absint(
get_option(
'irloyalty_min_redeem_points',
100
)
);
if ( $points < $minimum ) {
return new WP_Error(
'minimum_points',
'امتیاز واردشده کمتر از حداقل مجاز است.',
array( 'status' => 400 )
);
}
$point_value = max(
0,
(float) get_option(
'irloyalty_point_value',
'1'
)
);
$discount =
$points * $point_value;
if ( $discount <= 0 ) {
return new WP_Error(
'invalid_value',
'ارزش امتیاز تنظیم نشده است.',
array( 'status' => 500 )
);
}
$uuid =
wp_generate_uuid4();
$event_key =
'redeem:' . $uuid;
$debit =
irloyalty_apply_points(
$user_id,
-$points,
$event_key,
'redemption',
0,
0,
'تبدیل امتیاز به کوپن'
);
if ( is_wp_error( $debit ) ) {
return $debit;
}
$user =
get_user_by(
'id',
$user_id
);
try {
$coupon =
new WC_Coupon();
$code =
'LOYALTY-' .
strtoupper(
wp_generate_password(
10,
false,
false
)
);
$coupon->set_code( $code );
$coupon->set_discount_type(
'fixed_cart'
);
$coupon->set_amount(
wc_format_decimal(
$discount
)
);
$coupon->set_individual_use(
true
);
$coupon->set_usage_limit( 1 );
$coupon->set_usage_limit_per_user(
1
);
if (
$user &&
$user->user_email
) {
$coupon->set_email_restrictions(
array(
$user->user_email,
)
);
}
$days = max(
1,
absint(
get_option(
'irloyalty_coupon_days',
30
)
)
);
$expires_at =
time() +
( $days * DAY_IN_SECONDS );
$coupon->set_date_expires(
$expires_at
);
$coupon->set_description(
'پاداش باشگاه مشتریان'
);
$coupon->update_meta_data(
'_irloyalty_user_id',
$user_id
);
$coupon->update_meta_data(
'_irloyalty_points',
$points
);
$coupon_id = $coupon->save();
if ( ! $coupon_id ) {
throw new Exception(
'Coupon save failed.'
);
}
$now = current_time(
'mysql',
true
);
$wpdb->insert(
$wpdb->prefix .
'irloyalty_redemptions',
array(
'user_id' => $user_id,
'points' => $points,
'discount_amount' =>
$discount,
'coupon_id' =>
$coupon_id,
'coupon_code' =>
$code,
'status' =>
'active',
'expires_at' =>
gmdate(
'Y-m-d H:i:s',
$expires_at
),
'created_at' => $now,
'updated_at' => $now,
)
);
return rest_ensure_response(
array(
'success' => true,
'coupon' => $code,
'discount' =>
wc_price( $discount ),
'balance' =>
$debit['balance'],
)
);
} catch ( Throwable $e ) {
irloyalty_apply_points(
$user_id,
$points,
$event_key . ':rollback',
'redemption_rollback',
0,
0,
'بازگشت امتیاز به علت خطای ساخت کوپن',
true
);
return new WP_Error(
'coupon_create_failed',
'ساخت پاداش انجام نشد و امتیاز بازگردانده شد.',
array( 'status' => 500 )
);
}
}
اگر ساخت Coupon شکست بخورد، یک Event معکوس امتیاز را برمیگرداند. این بخش مهم است؛ کمشدن امتیاز بدون ساختهشدن پاداش نباید ممکن باشد.
مرحله ۸: کوپن منقضیشده و استفادهنشده را مدیریت کنیم
در سیاست این نمونه، وقتی مشتری امتیاز را به Coupon تبدیل میکند، امتیاز همان لحظه مصرف میشود. اگر Coupon تا زمان انقضا اصلاً استفاده نشود، میتوانیم امتیاز را به حساب برگردانیم.
برای بررسی دورهای از Action Scheduler استفاده میکنیم:
function irloyalty_schedule_expiry_scan() {
if (
! function_exists(
'as_has_scheduled_action'
) ||
! function_exists(
'as_schedule_recurring_action'
)
) {
return;
}
if (
as_has_scheduled_action(
'irloyalty_expiry_scan',
array(),
'iranica-loyalty'
)
) {
return;
}
as_schedule_recurring_action(
time() + 300,
DAY_IN_SECONDS,
'irloyalty_expiry_scan',
array(),
'iranica-loyalty',
true
);
}
add_action(
'action_scheduler_init',
'irloyalty_schedule_expiry_scan'
);
بازگرداندن Coupon منقضی و استفادهنشده
add_action(
'irloyalty_expiry_scan',
function() {
global $wpdb;
$table =
$wpdb->prefix .
'irloyalty_redemptions';
$now = current_time(
'mysql',
true
);
$rows = $wpdb->get_results(
$wpdb->prepare(
"
SELECT *
FROM {$table}
WHERE status = 'active'
AND expires_at IS NOT NULL
AND expires_at <= %s
LIMIT 200
",
$now
)
);
foreach ( $rows as $row ) {
$coupon =
new WC_Coupon(
(int) $row->coupon_id
);
if (
$coupon->get_id() &&
$coupon->get_usage_count() > 0
) {
$wpdb->update(
$table,
array(
'status' => 'used',
'updated_at' => $now,
),
array(
'id' => $row->id,
)
);
continue;
}
$event =
'redemption:' .
$row->id .
':expired-restore';
$restore =
irloyalty_apply_points(
(int) $row->user_id,
(int) $row->points,
$event,
'expired_reward_restore',
0,
(int) $row->id,
'بازگشت امتیاز Coupon منقضی'
);
if (
! is_wp_error(
$restore
)
) {
$wpdb->update(
$table,
array(
'status' =>
'expired_restored',
'updated_at' => $now,
),
array(
'id' => $row->id,
)
);
}
}
}
);
برای فروشگاه بزرگ، Batch را کوچک نگه دارید و همه Redemptionها را در یک Job پردازش نکنید.
مرحله ۹: انقضای امتیاز مشتری غیرفعال
انقضای Loyalty دو مدل رایج دارد: هر بسته امتیاز تاریخ انقضای خودش را داشته باشد، یا اگر مشتری برای مدت مشخصی هیچ فعالیتی نداشت موجودی او منقضی شود.
مدل اول دقیقتر است اما به Bucket Accounting نیاز دارد؛ یعنی هر Credit باید Remaining Points جدا داشته باشد و هنگام خرج امتیاز قدیمیترین Credit مصرف شود. برای نسخه پایه، مدل «انقضا پس از عدم فعالیت» سادهتر و قابل اتکاتر است.
function irloyalty_expire_inactive_accounts() {
global $wpdb;
$days = absint(
get_option(
'irloyalty_expire_inactivity_days',
365
)
);
if ( $days < 1 ) {
return;
}
$cutoff = gmdate(
'Y-m-d H:i:s',
time() -
( $days * DAY_IN_SECONDS )
);
$accounts =
$wpdb->prefix .
'irloyalty_accounts';
$rows = $wpdb->get_results(
$wpdb->prepare(
"
SELECT user_id, balance_points
FROM {$accounts}
WHERE balance_points > 0
AND last_activity_at IS NOT NULL
AND last_activity_at < %s
LIMIT 200
",
$cutoff
)
);
foreach ( $rows as $row ) {
$points =
(int) $row->balance_points;
irloyalty_apply_points(
(int) $row->user_id,
-$points,
'account:' .
$row->user_id .
':expire:' .
gmdate( 'Y-m-d' ),
'expiry',
0,
0,
'انقضای امتیاز به علت عدم فعالیت'
);
}
}
اگر قرار است امتیاز منقضی شود، این قانون باید قبل از عضویت و در صفحه قوانین باشگاه مشتریان برای کاربر روشن باشد. انقضای ناگهانی امتیاز بدون اطلاع قبلی تجربه بدی ایجاد میکند.
مرحله ۱۰: بخش «باشگاه مشتریان» را به My Account اضافه کنید
فایل includes/account.php:
<?php
defined( 'ABSPATH' ) || exit;
function irloyalty_register_account_endpoint() {
add_rewrite_endpoint(
'loyalty',
EP_ROOT | EP_PAGES
);
}
add_action(
'init',
'irloyalty_register_account_endpoint'
);
add_filter(
'woocommerce_account_menu_items',
function( $items ) {
$logout =
$items['customer-logout']
?? null;
unset(
$items['customer-logout']
);
$items['loyalty'] =
'باشگاه مشتریان';
if ( $logout ) {
$items['customer-logout'] =
$logout;
}
return $items;
}
);
add_action(
'woocommerce_account_loyalty_endpoint',
'irloyalty_render_account'
);
نمایش موجودی و تاریخچه
function irloyalty_render_account() {
global $wpdb;
$user_id =
get_current_user_id();
irloyalty_ensure_account(
$user_id
);
$account = $wpdb->get_row(
$wpdb->prepare(
"
SELECT *
FROM {$wpdb->prefix}irloyalty_accounts
WHERE user_id = %d
",
$user_id
)
);
$history = $wpdb->get_results(
$wpdb->prepare(
"
SELECT *
FROM {$wpdb->prefix}irloyalty_ledger
WHERE user_id = %d
ORDER BY id DESC
LIMIT 50
",
$user_id
)
);
echo '<h2>باشگاه مشتریان</h2>';
echo '<p>امتیاز قابل استفاده: <strong>' .
esc_html(
$account->balance_points
) .
'</strong></p>';
echo '<p>سطح فعلی: <strong>' .
esc_html(
irloyalty_tier_label(
$account->tier
)
) .
'</strong></p>';
echo '<p>کل امتیاز کسبشده: ' .
esc_html(
$account->lifetime_earned
) .
'</p>';
echo '<h3>تاریخچه امتیاز</h3>';
echo '<table class="shop_table shop_table_responsive">';
echo '<thead><tr>';
echo '<th>تاریخ</th>';
echo '<th>تغییر</th>';
echo '<th>توضیح</th>';
echo '</tr></thead><tbody>';
foreach ( $history as $row ) {
echo '<tr>';
echo '<td>' .
esc_html(
$row->created_at
) .
'</td>';
echo '<td>' .
esc_html(
$row->points
) .
'</td>';
echo '<td>' .
esc_html(
$row->note
) .
'</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
تابع ترجمه Tier:
function irloyalty_tier_label(
string $tier
): string {
$labels = array(
'bronze' => 'برنزی',
'silver' => 'نقرهای',
'gold' => 'طلایی',
);
return $labels[ $tier ]
?? 'برنزی';
}
اگر Endpoint خطای 404 دارد: یک بار از مسیر «تنظیمات ← پیوندهای یکتا» روی ذخیره بزنید یا افزونه را غیرفعال و دوباره فعال کنید تا Rewrite Rules بازسازی شوند.
مرحله ۱۱: فرم تبدیل امتیاز به پاداش
در صفحه باشگاه مشتریان یک فرم ساده قرار میدهیم:
<form id="irloyalty-redeem">
<p>
<label for="irloyalty-points">
چند امتیاز تبدیل شود؟
</label>
<input
id="irloyalty-points"
name="points"
type="number"
min="1"
step="1"
required
>
</p>
<button
class="button"
type="submit"
>
تبدیل امتیاز به پاداش
</button>
<p id="irloyalty-result"></p>
</form>
JavaScript درخواست REST را میفرستد. Nonce را با wp_create_nonce('wp_rest') به صفحه بدهید و در Header از X-WP-Nonce استفاده کنید.
document
.querySelector('#irloyalty-redeem')
?.addEventListener(
'submit',
async (event) => {
event.preventDefault();
const points =
parseInt(
document
.querySelector(
'#irloyalty-points'
)
.value,
10
);
const response =
await fetch(
IRLOYALTY.restUrl +
'redeem',
{
method: 'POST',
credentials:
'same-origin',
headers: {
'Content-Type':
'application/json',
'X-WP-Nonce':
IRLOYALTY.nonce
},
body: JSON.stringify({
points
})
}
);
const data =
await response.json();
const target =
document.querySelector(
'#irloyalty-result'
);
if (!response.ok) {
target.textContent =
data.message ||
'تبدیل امتیاز ناموفق بود.';
return;
}
target.textContent =
'کد پاداش شما: ' +
data.coupon;
}
);
ثبتنام، Review و Referral؛ چه زمانی امتیاز بدهیم؟
بعد از پایدارشدن موتور خرید، میتوانید Eventهای دیگری اضافه کنید. اما هر Event باید ضد سوءاستفاده طراحی شود.
| رویداد | روش امن | ریسک |
|---|---|---|
| ثبتنام | فقط یک Event با کلید user:ID:signup | ساخت حسابهای متعدد |
| Review | فقط Review تأییدشده و ترجیحاً خریدار واقعی | Review اسپم |
| Referral | پاداش بعد از اولین سفارش معتبر فرد معرفیشده | خودمعرفی و حساب جعلی |
| تولد | حداکثر یکبار در سال و تاریخ تأییدشده | تغییر مکرر تاریخ |
افزونههای Loyalty موجود در اکوسیستم WooCommerce نیز قابلیتهایی مثل ثبتنام، Review، Referral، اولین سفارش و رویدادهای خاص را ارائه میکنند؛ اما هرچه Rule بیشتر میشود، کنترل تقلب اهمیت بیشتری پیدا میکند.
آیا مشتری مهمان امتیاز بگیرد؟
سفارش Guest به User ID متصل نیست. سادهترین و امنترین نسخه این است که فقط کاربر عضو باشگاه امتیاز بگیرد و در Checkout به او پیشنهاد ورود یا ساخت حساب داده شود.
اگر میخواهید Guest نیز بعداً امتیاز سفارش قبلی را دریافت کند، باید سفارشها را با Billing Email مطابقت دهید. این روش فقط زمانی قابل اتکاتر است که مالکیت همان ایمیل پس از ثبتنام تأیید شده باشد.
امتیاز سفارش مهمان را مستقیم به هر حسابی که ایمیل مشابه وارد کرده نسبت ندهید، مگر فرایند تأیید هویت شما این رفتار را پوشش دهد.
چطور از تقلب در باشگاه مشتریان جلوگیری کنیم؟
Loyalty Program چون ارزش مالی ایجاد میکند، باید مثل یک سیستم اعتباری طراحی شود. حداقل کنترلهای زیر را داشته باشید:
- برای هر Event یک کلید یکتا داشته باشید.
- موجودی را هنگام خرج امتیاز داخل Transaction قفل کنید.
- ثبتنام و Referral را Rate Limit کنید.
- Review فقط بعد از تأیید و طبق سیاست فروشگاه امتیاز بگیرد.
- Refund امتیاز خرید را اصلاح کند.
- مدیر نتواند بدون ثبت Ledger موجودی را مستقیم تغییر دهد.
- تغییر دستی امتیاز باید نام مدیر و علت داشته باشد.
- امتیاز مشتری را برای تخفیف نقدی خارج از قوانین تبدیل نکنید.
مدیر چگونه امتیاز دستی اضافه یا کم کند؟
برای هدیه، جبران مشکل یا کمپین خاص ممکن است مدیر نیاز به تغییر امتیاز داشته باشد. حتی در این حالت Balance را مستقیم Update نکنید.
irloyalty_apply_points(
125,
250,
'manual:' .
wp_generate_uuid4(),
'manual_adjustment',
0,
get_current_user_id(),
'هدیه پشتیبانی'
);
برای کاهش امتیاز مقدار منفی بدهید. در پنل مدیریتی، Reason باید اجباری باشد تا Audit قابل فهم باقی بماند.
باشگاه مشتریان و CRM چه ارتباطی دارند؟
CRM میتواند Tier، موجودی امتیاز و آخرین فعالیت Loyalty را دریافت کند و برای بخشبندی مشتری استفاده کند. برای مثال تیم فروش بتواند مشتریان Gold را جدا ببیند یا مشتریای را که مدت زیادی خرید نکرده شناسایی کند.
اما CRM نباید منبع اصلی Balance شود. Ledger داخل سیستم Loyalty باید مرجع امتیاز باقی بماند و CRM فقط نسخهای برای بازاریابی و پیگیری مشتری دریافت کند.
باشگاه مشتریان و POS حضوری
اگر فروش حضوری نیز با WooCommerce ثبت میشود، سفارش POS باید مثل هر Order دیگر وارد موتور Loyalty شود. چون Event براساس Order ID ساخته میشود، مشتری برای فروش حضوری و آنلاین یک حساب امتیاز مشترک خواهد داشت.
اگر POS از سیستم جداگانهای استفاده میکند، بهتر است ابتدا فروش حضوری را به Order WooCommerce تبدیل کنید و بعد Loyalty روی همان Order اجرا شود؛ نه اینکه POS و سایت هر کدام Balance جدا داشته باشند.
چه گزارشهایی واقعاً مهماند؟
| گزارش | سؤال مدیریتی |
|---|---|
| امتیاز صادرشده | در این ماه چند امتیاز ایجاد کردهایم؟ |
| امتیاز مصرفشده | مشتریان چه مقدار از اعتبار را واقعاً استفاده کردهاند؟ |
| امتیاز باز | چه مقدار تعهد پاداش هنوز در حساب مشتریان وجود دارد؟ |
| Repeat Purchase | اعضای باشگاه بیشتر از کاربران عادی برمیگردند یا نه؟ |
| سطح مشتری | چه تعداد Bronze، Silver و Gold داریم؟ |
| Refund بعد از امتیاز | آیا برنامه Loyalty رفتار سوءاستفادهپذیر ایجاد کرده است؟ |
اگر فقط تعداد اعضا را گزارش کنید، نمیفهمید باشگاه واقعاً فروش تکراری ساخته یا فقط هزینه تخفیف را بالا برده است.
فروشگاه پرتراکنش دارید؟
Ledger امتیاز، Action Scheduler، Coupon و گزارش Loyalty روی دیتابیس و PHP فشار اضافه ایجاد میکنند. اگر فروشگاه سفارش زیادی دارد یا باشگاه مشتریان به CRM، POS و Workerهای جدا متصل است، بهتر است پردازشهای سنگین را از Checkout جدا نگه دارید و منابع سرور را متناسب با حجم واقعی انتخاب کنید.
تماس با پشتیبانی ایرانیکاسرور: 021-91302467
خطاهای رایج در ساخت Loyalty برای WooCommerce
| مشکل | علت محتمل | راهحل |
|---|---|---|
| سفارش دوبار امتیاز گرفته | Event یکتا ندارید | برای خرید از event_key ثابت order:ID:earn استفاده کنید. |
| امتیاز بعد از Refund باقی مانده | Hook برگشت امتیاز اجرا نمیشود | woocommerce_order_refunded و Refundهای Amount-only را بررسی کنید. |
| مشتری بیش از موجودی خرج کرده | عملیات همزمان بدون Lock انجام شده | Account را داخل Transaction با FOR UPDATE قفل کنید. |
| کوپن ساخته نشد ولی امتیاز کم شد | Rollback ندارید | در خطای ساخت Coupon، Event بازگشت امتیاز ثبت کنید. |
| صفحه Loyalty خطای 404 دارد | Rewrite Rules بهروز نشده | Permalinks را یک بار Save کنید یا افزونه را دوباره فعال کنید. |
| Job انقضا اجرا نمیشود | Action Scheduler یا WP-Cron مشکل دارد | WooCommerce → Status → Scheduled Actions را بررسی کنید. |
| سطح مشتری بعد از خرج امتیاز افت کرده | Tier از Balance ساخته شده | Tier را از Lifetime Earned یا معیار مستقل محاسبه کنید. |
چکلیست قبل از فعالسازی روی فروشگاه اصلی
- یک سفارش Processing فقط یک بار امتیاز بسازد.
- Processing به Completed امتیاز دوم ایجاد نکند.
- Refund جزئی فقط امتیاز همان مقدار را اصلاح کند.
- دو درخواست همزمان نتوانند یک امتیاز را دو بار خرج کنند.
- Coupon فقط برای کاربر مربوط قابل استفاده باشد.
- Coupon یکبارمصرف باشد.
- خطای ساخت Coupon امتیاز را برگرداند.
- Endpoint Loyalty فقط برای کاربر واردشده قابل استفاده باشد.
- تاریخچه Ledger با Balance نهایی تطبیق داشته باشد.
- Action Scheduler بدون Failed Action اجرا شود.
- قوانین انقضا قبل از انتشار به مشتری اعلام شوند.
پرسشهای متداول
آیا WooCommerce خودش باشگاه مشتریان دارد؟
هسته WooCommerce سیستم کامل Loyalty امتیازی ندارد، اما افزونه رسمی WooCommerce Points and Rewards برای امتیاز خرید، ثبتنام، Review و تبدیل امتیاز به تخفیف وجود دارد.
امتیاز را هنگام ثبت سفارش بدهیم یا بعد از پرداخت؟
بهتر است زمانی امتیاز ثبت شود که Order به وضعیتی رسیده که طبق فرایند فروشگاه خرید معتبر محسوب میشود. برای بسیاری از فروشگاهها Processing یا Completed مناسب است، اما فروشگاههای پرداخت در محل باید سیاست خودشان را مشخص کنند.
آیا مشتری Guest میتواند امتیاز بگیرد؟
بله، اما برای اتصال امن امتیاز به حساب آینده باید مالکیت ایمیل تأیید شود. نسخه پایه بهتر است امتیاز را به کاربران عضو باشگاه اختصاص دهد.
اگر سفارش Refund شود چه اتفاقی برای امتیاز میافتد؟
امتیاز متناظر با بخش Refundشده باید از حساب مشتری کم شود. اگر مشتری قبلاً امتیاز را خرج کرده باشد، موجودی حتی میتواند منفی شود تا بدهی امتیازی جبران شود.
Tier مشتری را از Balance بسازیم؟
معمولاً خیر. Balance مقدار قابل خرج است و با Redemption پایین میآید. Tier بهتر است از Lifetime Earned، مجموع خرید معتبر یا معیار مستقل دیگری ساخته شود.
بهترین روش مصرف امتیاز چیست؟
برای نسخه ساده و سازگار، تبدیل امتیاز به Coupon محدود به همان مشتری گزینه قابل فهمی است. برای سیستم پیشرفته میتوان Redemption مستقیم Checkout ساخت، اما مدیریت رزرو و بازگشت امتیاز پیچیدهتر میشود.
آیا امتیاز باید منقضی شود؟
این تصمیم تجاری است. اگر Expiry دارید، قانون آن باید برای مشتری شفاف باشد و سیستم قبل از انقضا اعلان مناسب داشته باشد.
منابع رسمی این آموزش
🔹 WooCommerce Points and Rewards Documentation — رفتار رسمی امتیاز خرید، ثبتنام، Review، Redemption، Refund و My Account.
🔹 WooCommerce Points and Rewards Marketplace — قابلیتها و وضعیت فعلی افزونه رسمی Loyalty ووکامرس.
🔹 WooCommerce Coupon Management — ساخت Coupon، تاریخ انقضا، Usage Limit و محدودیت کاربر.
🔹 WooCommerce REST API v3 — نسخه توصیهشده API برای Integrationهای جدید.
🔹 WooCommerce Customers API — ساختار فعلی اطلاعات مشتری در REST API.
🔹 WooCommerce Orders API — ساختار سفارش و وضعیتهای Order.
🔹 Action Scheduler API — اجرای Jobهای دورهای و پردازش پسزمینه.
🔹 WooCommerce 11.2.1 Release Notes — نسخه پایدار بررسیشده هنگام نگارش.
جمعبندی
باشگاه مشتریان WooCommerce را به یک عدد points داخل حساب کاربر محدود نکنید. موجودی فعلی، دفتر تغییرات، امتیاز تاریخی و Tier باید از هم جدا باشند. Order فقط یک بار امتیاز تولید کند، Refund همان امتیاز را اصلاح کند، Redemption بهصورت اتمیک انجام شود و ساخت پاداش در صورت خطا Rollback داشته باشد. My Account نیز باید هم موجودی و هم تاریخچه را به مشتری نشان دهد. بعد از پایدارشدن این هسته میتوانید Referral، Review، Birthday، CRM و POS را بدون خرابکردن منطق اصلی به سیستم اضافه کنید.
برای اجرای باشگاه مشتریان پرتراکنش کمک فنی میخواهید؟
اگر Loyalty شما به CRM، POS، Queue، گزارشهای سنگین یا چند فروشگاه متصل میشود، بهتر است قبل از افزایش امکانات، معماری دیتابیس و Workerها بررسی شود. میتوانید برای بررسی و کانفیگ زیرساخت درخواست ثبت کنید.
تماس با پشتیبانی ایرانیکاسرور: 021-91302467






دیدگاهها (۰)