800Website Loyalty Rewards for WooCommerce requires WooCommerce to be installed and active.

'; }); deactivate_plugins(plugin_basename(__FILE__)); } }); define('LRWC_VERSION', '1.0.0'); define('LRWC_PATH', plugin_dir_path(__FILE__)); define('LRWC_URL', plugin_dir_url(__FILE__)); class LRWC_Loyalty_Rewards { private static $instance = null; public static function instance() { if (null === self::$instance) { self::$instance = new self(); } return self::$instance; } public function __construct() { // Activation register_activation_hook(__FILE__, [$this, 'activate']); // Init add_action('init', [$this, 'init']); add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts']); add_action('admin_enqueue_scripts', [$this, 'admin_scripts']); // Admin menu add_action('admin_menu', [$this, 'admin_menu']); // Shortcodes add_shortcode('lrwc_loyalty_app', [$this, 'shortcode_customer_app']); add_shortcode('lrwc_loyalty_staff', [$this, 'shortcode_staff_scanner']); // REST API add_action('rest_api_init', [$this, 'register_api']); // AJAX for batch processing add_action('wp_ajax_lrwc_batch_generate', [$this, 'ajax_batch_generate']); // WooCommerce hooks add_action('woocommerce_order_status_completed', [$this, 'award_points_on_order']); // Checkout points redemption add_action('woocommerce_cart_calculate_fees', [$this, 'apply_points_discount_fee']); add_action('woocommerce_checkout_order_processed', [$this, 'process_checkout_points_redemption'], 10, 3); add_action('woocommerce_review_order_before_payment', [$this, 'display_points_redemption_option']); add_action('wp_ajax_lrwc_apply_checkout_points', [$this, 'ajax_apply_checkout_points']); add_action('wp_ajax_lrwc_remove_checkout_points', [$this, 'ajax_remove_checkout_points']); // AJAX add to cart for rewards add_action('wp_ajax_lrwc_add_to_cart', [$this, 'ajax_add_to_cart']); add_action('wp_ajax_nopriv_lrwc_add_to_cart', [$this, 'ajax_add_to_cart']); // Order admin display add_action('woocommerce_admin_order_data_after_order_details', [$this, 'display_rewards_redemption_badge']); add_filter('woocommerce_admin_order_preview_get_order_details', [$this, 'add_rewards_badge_to_preview'], 10, 2); // My Account tab add_filter('woocommerce_account_menu_items', [$this, 'add_account_menu_item']); add_action('woocommerce_account_lrwc-loyalty-points_endpoint', [$this, 'account_loyalty_content']); add_action('init', [$this, 'add_endpoint']); // Add points column to orders add_filter('manage_edit-shop_order_columns', [$this, 'add_order_column']); add_action('manage_shop_order_posts_custom_column', [$this, 'order_column_content'], 10, 2); // HPOS compatibility add_filter('manage_woocommerce_page_wc-orders_columns', [$this, 'add_order_column']); add_action('manage_woocommerce_page_wc-orders_custom_column', [$this, 'order_column_content_hpos'], 10, 2); } public function activate() { global $wpdb; $charset = $wpdb->get_charset_collate(); // Staff table $wpdb->query("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}lrwc_staff ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, pin VARCHAR(10), role VARCHAR(20) DEFAULT 'cashier', status VARCHAR(20) DEFAULT 'active', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY (user_id) ) $charset"); // Transactions table with BIGINT for points (handles large currency values) $wpdb->query("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}lrwc_transactions ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT UNSIGNED NOT NULL, staff_id BIGINT UNSIGNED, type VARCHAR(20) NOT NULL, points BIGINT NOT NULL, order_id BIGINT UNSIGNED, product_id BIGINT UNSIGNED, note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_customer (customer_id), INDEX idx_type (type), INDEX idx_created (created_at), INDEX idx_customer_type (customer_id, type) ) $charset"); // Redemptions table with BIGINT $wpdb->query("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}lrwc_redemptions ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT UNSIGNED NOT NULL, staff_id BIGINT UNSIGNED, product_id BIGINT UNSIGNED NOT NULL, points_used BIGINT NOT NULL, status VARCHAR(20) DEFAULT 'pending', code VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, redeemed_at DATETIME, INDEX idx_customer (customer_id), INDEX idx_status (status), INDEX idx_code (code) ) $charset"); // Pending points requests (staff submit, admin approves) $wpdb->query("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}lrwc_pending_points ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT UNSIGNED NOT NULL, staff_id BIGINT UNSIGNED NOT NULL, invoice_number VARCHAR(100) NOT NULL, bill_amount DECIMAL(15,2) NOT NULL, points BIGINT NOT NULL, status VARCHAR(20) DEFAULT 'pending', admin_id BIGINT UNSIGNED, admin_note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, processed_at DATETIME, INDEX idx_customer (customer_id), INDEX idx_staff (staff_id), INDEX idx_status (status), INDEX idx_invoice (invoice_number) ) $charset"); // Alter existing tables to BIGINT if they exist with INT $wpdb->query("ALTER TABLE {$wpdb->prefix}lrwc_transactions MODIFY COLUMN points BIGINT NOT NULL"); $wpdb->query("ALTER TABLE {$wpdb->prefix}lrwc_redemptions MODIFY COLUMN points_used BIGINT NOT NULL"); // Add index to usermeta for faster points lookups (if not exists) $index_exists = $wpdb->get_var(" SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = '{$wpdb->usermeta}' AND index_name = 'idx_lrwc_points' "); if (!$index_exists) { $wpdb->query("ALTER TABLE {$wpdb->usermeta} ADD INDEX idx_lrwc_points (meta_key, meta_value(20))"); } // Default settings $defaults = [ 'lrwc_points_mode' => 'currency_per_point', // 'points_per_currency' or 'currency_per_point' 'lrwc_points_per_currency' => 1, 'lrwc_currency_per_point' => 1, 'lrwc_points_value' => 1, 'lrwc_point_value' => 1, // Value of 1 point in currency for checkout redemption 'lrwc_min_order_total' => 0, 'lrwc_rewards_category' => '', 'lrwc_tier_silver' => 500, 'lrwc_tier_gold' => 1500, 'lrwc_tier_platinum' => 5000, 'lrwc_primary_color' => '#6366f1', 'lrwc_secondary_color' => '#4f46e5', 'lrwc_welcome_bonus' => 0, 'lrwc_auto_award_orders' => 'yes', 'lrwc_enable_checkout_redemption' => 'yes', ]; foreach ($defaults as $key => $value) { if (get_option($key) === false) { add_option($key, $value); } } flush_rewrite_rules(); } public function init() { // Translations are loaded automatically by WordPress.org for hosted plugins. } public function add_endpoint() { add_rewrite_endpoint('lrwc-loyalty-points', EP_ROOT | EP_PAGES); } public function enqueue_scripts() { if (is_page() || is_account_page()) { wp_enqueue_style('lrwc-customer', LRWC_URL . 'assets/css/customer.css', [], LRWC_VERSION); wp_enqueue_style('lrwc-staff', LRWC_URL . 'assets/css/staff.css', [], LRWC_VERSION); wp_enqueue_script('lrwc-qrcode', LRWC_URL . 'assets/js/vendor/qrcode.min.js', [], LRWC_VERSION, true); wp_enqueue_script('lrwc-jsqr', LRWC_URL . 'assets/js/vendor/jsqr.min.js', [], LRWC_VERSION, true); wp_enqueue_script('lrwc-html5-qrcode', LRWC_URL . 'assets/js/vendor/html5-qrcode.min.js', ['lrwc-jsqr'], LRWC_VERSION, true); wp_enqueue_script('lrwc-customer', LRWC_URL . 'assets/js/customer.js', ['jquery', 'lrwc-qrcode'], LRWC_VERSION, true); wp_enqueue_script('lrwc-staff', LRWC_URL . 'assets/js/staff.js', ['jquery', 'lrwc-html5-qrcode'], LRWC_VERSION, true); wp_localize_script('lrwc-customer', 'lrwc_ajax', [ 'rest_url' => rest_url('lrwc/v1/'), 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('wp_rest'), 'add_to_cart_nonce' => wp_create_nonce('lrwc_add_to_cart'), 'currency' => html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8'), 'primary_color' => get_option('lrwc_primary_color', '#6366f1'), 'point_value' => floatval(get_option('lrwc_point_value', 1)), ]); wp_localize_script('lrwc-staff', 'lrwc_staff_data', [ 'rest_url' => rest_url('lrwc/v1/'), 'nonce' => wp_create_nonce('wp_rest'), 'currency' => html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8'), 'points_mode' => get_option('lrwc_points_mode', 'currency_per_point'), 'currency_per_point' => get_option('lrwc_currency_per_point', 1), 'points_per_currency' => get_option('lrwc_points_per_currency', 1), 'point_value' => floatval(get_option('lrwc_point_value', 1)), 'currency_code' => get_woocommerce_currency(), ]); // Dynamic CSS custom properties $primary_color = get_option('lrwc_primary_color', '#6366f1'); $secondary_color = get_option('lrwc_secondary_color', '#4f46e5'); $dynamic_css = ':root { --lrwc-primary: ' . esc_attr($primary_color) . '; --lrwc-secondary: ' . esc_attr($secondary_color) . '; }'; wp_add_inline_style('lrwc-customer', $dynamic_css); } } public function admin_scripts($hook) { if (strpos($hook, 'lrwc-loyalty-rewards') !== false) { wp_enqueue_style('lrwc-admin', LRWC_URL . 'assets/css/admin.css', [], LRWC_VERSION); // Generator page if (strpos($hook, 'lrwc-loyalty-rewards-generator') !== false) { wp_enqueue_script('lrwc-admin-generator', LRWC_URL . 'assets/js/admin-generator.js', ['jquery'], LRWC_VERSION, true); wp_localize_script('lrwc-admin-generator', 'lrwc_generator', [ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('lrwc_batch_generate'), ]); } // Settings page if (strpos($hook, 'lrwc-loyalty-rewards-settings') !== false) { wp_enqueue_script('lrwc-admin-settings', LRWC_URL . 'assets/js/admin-settings.js', ['jquery'], LRWC_VERSION, true); } } } // ======================== // ADMIN MENU // ======================== public function admin_menu() { add_menu_page( 'Loyalty Rewards', 'Loyalty Rewards', 'manage_woocommerce', 'lrwc-loyalty-rewards', [$this, 'admin_dashboard'], 'dashicons-awards', 56 ); add_submenu_page('lrwc-loyalty-rewards', 'Dashboard', 'Dashboard', 'manage_woocommerce', 'lrwc-loyalty-rewards', [$this, 'admin_dashboard']); add_submenu_page('lrwc-loyalty-rewards', 'Customers', 'Customers', 'manage_woocommerce', 'lrwc-loyalty-rewards-customers', [$this, 'admin_customers']); add_submenu_page('lrwc-loyalty-rewards', 'Points Generator', 'Points Generator', 'manage_woocommerce', 'lrwc-loyalty-rewards-generator', [$this, 'admin_generator']); add_submenu_page('lrwc-loyalty-rewards', 'Pending Approvals', 'Pending Approvals', 'manage_woocommerce', 'lrwc-loyalty-rewards-pending', [$this, 'admin_pending_approvals']); add_submenu_page('lrwc-loyalty-rewards', 'Staff', 'Staff', 'manage_woocommerce', 'lrwc-loyalty-rewards-staff', [$this, 'admin_staff']); add_submenu_page('lrwc-loyalty-rewards', 'Transactions', 'Transactions', 'manage_woocommerce', 'lrwc-loyalty-rewards-transactions', [$this, 'admin_transactions']); add_submenu_page('lrwc-loyalty-rewards', 'Redemptions', 'Redemptions', 'manage_woocommerce', 'lrwc-loyalty-rewards-redemptions', [$this, 'admin_redemptions']); add_submenu_page('lrwc-loyalty-rewards', 'Settings', 'Settings', 'manage_woocommerce', 'lrwc-loyalty-rewards-settings', [$this, 'admin_settings']); } public function admin_dashboard() { global $wpdb; // Stats $total_customers = $wpdb->get_var("SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}lrwc_transactions"); $total_points_issued = $wpdb->get_var("SELECT COALESCE(SUM(points), 0) FROM {$wpdb->prefix}lrwc_transactions WHERE type = 'earn' OR type = 'bonus'"); $total_points_redeemed = $wpdb->get_var("SELECT COALESCE(SUM(points_used), 0) FROM {$wpdb->prefix}lrwc_redemptions WHERE status = 'completed'"); $pending_redemptions = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}lrwc_redemptions WHERE status = 'pending'"); // Recent transactions $recent = $wpdb->get_results(" SELECT t.*, u.display_name as customer_name FROM {$wpdb->prefix}lrwc_transactions t LEFT JOIN {$wpdb->users} u ON t.customer_id = u.ID ORDER BY t.created_at DESC LIMIT 10 "); include LRWC_PATH . 'templates/admin-dashboard.php'; } public function admin_customers() { global $wpdb; $search = isset($_GET['s']) ? sanitize_text_field($_GET['s']) : ''; $paged = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; $per_page = 30; $offset = ($paged - 1) * $per_page; // OPTIMIZED: Query only users who have points balance > 0 via usermeta // This is much faster than joining all 40k users if ($search) { // Search mode: find matching users who have points $user_ids = $wpdb->get_col($wpdb->prepare(" SELECT DISTINCT um.user_id FROM {$wpdb->usermeta} um INNER JOIN {$wpdb->users} u ON um.user_id = u.ID WHERE um.meta_key = 'lrwc_points_balance' AND CAST(um.meta_value AS SIGNED) > 0 AND (u.display_name LIKE %s OR u.user_email LIKE %s) ORDER BY CAST(um.meta_value AS SIGNED) DESC LIMIT %d OFFSET %d ", "%$search%", "%$search%", $per_page, $offset)); $total = $wpdb->get_var($wpdb->prepare(" SELECT COUNT(DISTINCT um.user_id) FROM {$wpdb->usermeta} um INNER JOIN {$wpdb->users} u ON um.user_id = u.ID WHERE um.meta_key = 'lrwc_points_balance' AND CAST(um.meta_value AS SIGNED) > 0 AND (u.display_name LIKE %s OR u.user_email LIKE %s) ", "%$search%", "%$search%")); } else { // Normal mode: get users with points, sorted by balance $user_ids = $wpdb->get_col($wpdb->prepare(" SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = 'lrwc_points_balance' AND CAST(meta_value AS SIGNED) > 0 ORDER BY CAST(meta_value AS SIGNED) DESC LIMIT %d OFFSET %d ", $per_page, $offset)); $total = $wpdb->get_var(" SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'lrwc_points_balance' AND CAST(meta_value AS SIGNED) > 0 "); } $customers = []; if (!empty($user_ids)) { $ids_placeholder = implode(',', array_map('intval', $user_ids)); // Get user data in one query $users_data = $wpdb->get_results(" SELECT ID, display_name, user_email FROM {$wpdb->users} WHERE ID IN ($ids_placeholder) ", OBJECT_K); // Get points balances in one query $balances = $wpdb->get_results(" SELECT user_id, meta_value as points_balance FROM {$wpdb->usermeta} WHERE user_id IN ($ids_placeholder) AND meta_key = 'lrwc_points_balance' ", OBJECT_K); // Get transaction summaries in one query $tx_summary = $wpdb->get_results(" SELECT customer_id, SUM(CASE WHEN type IN ('earn', 'bonus', 'manual') THEN points ELSE 0 END) as total_earned, SUM(CASE WHEN type = 'redeem' THEN ABS(points) ELSE 0 END) as total_redeemed FROM {$wpdb->prefix}lrwc_transactions WHERE customer_id IN ($ids_placeholder) GROUP BY customer_id ", OBJECT_K); // Build customer objects maintaining the sorted order foreach ($user_ids as $uid) { if (!isset($users_data[$uid])) continue; $c = new stdClass(); $c->ID = $uid; $c->display_name = $users_data[$uid]->display_name; $c->user_email = $users_data[$uid]->user_email; $c->points_balance = isset($balances[$uid]) ? $balances[$uid]->points_balance : 0; $c->total_earned = isset($tx_summary[$uid]) ? $tx_summary[$uid]->total_earned : 0; $c->total_redeemed = isset($tx_summary[$uid]) ? $tx_summary[$uid]->total_redeemed : 0; $customers[] = $c; } } $total_pages = ceil($total / $per_page); include LRWC_PATH . 'templates/admin-customers.php'; } public function admin_generator() { global $wpdb; $message = ''; $message_type = ''; $preview_data = null; // Handle preview request if (isset($_POST['lrwc_preview_orders']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'lrwc_generator')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized access.', '800website-loyalty-rewards')); } $date_from = sanitize_text_field($_POST['date_from']); $date_to = sanitize_text_field($_POST['date_to']); $order_status = array_map('sanitize_text_field', (array)$_POST['order_status']); $preview_data = $this->get_orders_for_points($date_from, $date_to, $order_status, true); } // Handle generate request if (isset($_POST['lrwc_generate_points']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'lrwc_generator')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized access.', '800website-loyalty-rewards')); } $date_from = sanitize_text_field($_POST['date_from']); $date_to = sanitize_text_field($_POST['date_to']); $order_status = array_map('sanitize_text_field', (array)$_POST['order_status']); $result = $this->generate_points_for_orders($date_from, $date_to, $order_status); $message = sprintf( 'Successfully processed %d orders. Awarded %s points to %d customers.', $result['orders_processed'], number_format($result['total_points']), $result['customers_affected'] ); $message_type = 'success'; } include LRWC_PATH . 'templates/admin-generator.php'; } private function get_orders_for_points($date_from, $date_to, $statuses, $preview_only = false) { global $wpdb; // Build status clause $status_placeholders = implode(',', array_fill(0, count($statuses), '%s')); $wc_statuses = array_map(function($s) { return 'wc-' . $s; }, $statuses); // Check if using HPOS $using_hpos = class_exists('Automattic\WooCommerce\Utilities\OrderUtil') && Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); if ($using_hpos) { // HPOS query - check wc_orders_meta for awarded flag $query = $wpdb->prepare(" SELECT o.id as order_id, o.customer_id, o.total_amount as order_total, o.date_created_gmt as order_date FROM {$wpdb->prefix}wc_orders o LEFT JOIN {$wpdb->prefix}wc_orders_meta pm ON o.id = pm.order_id AND pm.meta_key = '_lrwc_points_awarded' WHERE o.status IN ($status_placeholders) AND o.date_created_gmt >= %s AND o.date_created_gmt <= %s AND o.customer_id > 0 AND pm.id IS NULL ORDER BY o.date_created_gmt DESC ", ...array_merge($wc_statuses, [$date_from . ' 00:00:00', $date_to . ' 23:59:59'])); } else { // Legacy post meta query $query = $wpdb->prepare(" SELECT p.ID as order_id, pm_customer.meta_value as customer_id, pm_total.meta_value as order_total, p.post_date as order_date FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm_customer ON p.ID = pm_customer.post_id AND pm_customer.meta_key = '_customer_user' LEFT JOIN {$wpdb->postmeta} pm_total ON p.ID = pm_total.post_id AND pm_total.meta_key = '_order_total' LEFT JOIN {$wpdb->postmeta} pm_awarded ON p.ID = pm_awarded.post_id AND pm_awarded.meta_key = '_lrwc_points_awarded' WHERE p.post_type = 'shop_order' AND p.post_status IN ($status_placeholders) AND p.post_date >= %s AND p.post_date <= %s AND pm_customer.meta_value > 0 AND pm_awarded.meta_id IS NULL ORDER BY p.post_date DESC ", ...array_merge($wc_statuses, [$date_from . ' 00:00:00', $date_to . ' 23:59:59'])); } $orders = $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above with $wpdb->prepare() // Calculate points using the helper $min_total = floatval(get_option('lrwc_min_order_total', 0)); $summary = [ 'total_orders' => 0, 'total_points' => 0, 'total_revenue' => 0, 'customers' => [], 'orders' => [] ]; foreach ($orders as $order) { $order_total = floatval($order->order_total); if ($order_total < $min_total) continue; $points = $this->calculate_points($order_total); $customer_id = intval($order->customer_id); $summary['total_orders']++; $summary['total_points'] += $points; $summary['total_revenue'] += $order_total; if (!isset($summary['customers'][$customer_id])) { $summary['customers'][$customer_id] = [ 'points' => 0, 'orders' => 0 ]; } $summary['customers'][$customer_id]['points'] += $points; $summary['customers'][$customer_id]['orders']++; if ($preview_only && count($summary['orders']) < 100) { $summary['orders'][] = [ 'order_id' => $order->order_id, 'customer_id' => $customer_id, 'order_total' => $order_total, 'points' => $points, 'date' => $order->order_date ]; } } $summary['customer_count'] = count($summary['customers']); return $summary; } private function generate_points_for_orders($date_from, $date_to, $statuses) { global $wpdb; // Build status clause $status_placeholders = implode(',', array_fill(0, count($statuses), '%s')); $wc_statuses = array_map(function($s) { return 'wc-' . $s; }, $statuses); // Check if using HPOS $using_hpos = class_exists('Automattic\WooCommerce\Utilities\OrderUtil') && Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); if ($using_hpos) { // HPOS query - check wc_orders_meta for awarded flag $query = $wpdb->prepare(" SELECT o.id as order_id, o.customer_id, o.total_amount as order_total FROM {$wpdb->prefix}wc_orders o LEFT JOIN {$wpdb->prefix}wc_orders_meta pm ON o.id = pm.order_id AND pm.meta_key = '_lrwc_points_awarded' WHERE o.status IN ($status_placeholders) AND o.date_created_gmt >= %s AND o.date_created_gmt <= %s AND o.customer_id > 0 AND pm.id IS NULL ", ...array_merge($wc_statuses, [$date_from . ' 00:00:00', $date_to . ' 23:59:59'])); } else { $query = $wpdb->prepare(" SELECT p.ID as order_id, pm_customer.meta_value as customer_id, pm_total.meta_value as order_total FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm_customer ON p.ID = pm_customer.post_id AND pm_customer.meta_key = '_customer_user' LEFT JOIN {$wpdb->postmeta} pm_total ON p.ID = pm_total.post_id AND pm_total.meta_key = '_order_total' LEFT JOIN {$wpdb->postmeta} pm_awarded ON p.ID = pm_awarded.post_id AND pm_awarded.meta_key = '_lrwc_points_awarded' WHERE p.post_type = 'shop_order' AND p.post_status IN ($status_placeholders) AND p.post_date >= %s AND p.post_date <= %s AND pm_customer.meta_value > 0 AND pm_awarded.meta_id IS NULL ", ...array_merge($wc_statuses, [$date_from . ' 00:00:00', $date_to . ' 23:59:59'])); } $orders = $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above with $wpdb->prepare() $min_total = floatval(get_option('lrwc_min_order_total', 0)); $result = [ 'orders_processed' => 0, 'total_points' => 0, 'customers_affected' => [] ]; foreach ($orders as $order) { $order_total = floatval($order->order_total); if ($order_total < $min_total) continue; $points = $this->calculate_points($order_total); $customer_id = intval($order->customer_id); if ($points > 0) { // Add points $this->add_points( $customer_id, $points, 'earn', null, $order->order_id, null, 'Retroactive: Order #' . $order->order_id ); // Mark order as processed $wc_order = wc_get_order($order->order_id); if ($wc_order) { $wc_order->update_meta_data('_lrwc_points_awarded', $points); $wc_order->save(); } $result['orders_processed']++; $result['total_points'] += $points; $result['customers_affected'][$customer_id] = true; } } $result['customers_affected'] = count($result['customers_affected']); return $result; } public function ajax_batch_generate() { check_ajax_referer('lrwc_batch_generate', 'nonce'); if (!current_user_can('manage_woocommerce')) { wp_send_json_error(['message' => 'Unauthorized']); } global $wpdb; $date_from = sanitize_text_field($_POST['date_from']); $date_to = sanitize_text_field($_POST['date_to']); $statuses = array_map('sanitize_text_field', (array)$_POST['statuses']); $offset = intval($_POST['offset']); $batch_size = 100; // Build status clause $status_placeholders = implode(',', array_fill(0, count($statuses), '%s')); $wc_statuses = array_map(function($s) { return 'wc-' . $s; }, $statuses); // Check if using HPOS $using_hpos = class_exists('Automattic\WooCommerce\Utilities\OrderUtil') && Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); if ($using_hpos) { // HPOS query - check wc_orders_meta for awarded flag $query = $wpdb->prepare(" SELECT o.id as order_id, o.customer_id, o.total_amount as order_total FROM {$wpdb->prefix}wc_orders o LEFT JOIN {$wpdb->prefix}wc_orders_meta pm ON o.id = pm.order_id AND pm.meta_key = '_lrwc_points_awarded' WHERE o.status IN ($status_placeholders) AND o.date_created_gmt >= %s AND o.date_created_gmt <= %s AND o.customer_id > 0 AND pm.id IS NULL LIMIT %d OFFSET %d ", ...array_merge($wc_statuses, [$date_from . ' 00:00:00', $date_to . ' 23:59:59', $batch_size, $offset])); } else { $query = $wpdb->prepare(" SELECT p.ID as order_id, pm_customer.meta_value as customer_id, pm_total.meta_value as order_total FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm_customer ON p.ID = pm_customer.post_id AND pm_customer.meta_key = '_customer_user' LEFT JOIN {$wpdb->postmeta} pm_total ON p.ID = pm_total.post_id AND pm_total.meta_key = '_order_total' LEFT JOIN {$wpdb->postmeta} pm_awarded ON p.ID = pm_awarded.post_id AND pm_awarded.meta_key = '_lrwc_points_awarded' WHERE p.post_type = 'shop_order' AND p.post_status IN ($status_placeholders) AND p.post_date >= %s AND p.post_date <= %s AND pm_customer.meta_value > 0 AND pm_awarded.meta_id IS NULL LIMIT %d OFFSET %d ", ...array_merge($wc_statuses, [$date_from . ' 00:00:00', $date_to . ' 23:59:59', $batch_size, $offset])); } $orders = $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above with $wpdb->prepare() $min_total = floatval(get_option('lrwc_min_order_total', 0)); $batch_result = [ 'processed' => 0, 'points' => 0, 'has_more' => count($orders) === $batch_size ]; foreach ($orders as $order) { $order_total = floatval($order->order_total); if ($order_total < $min_total) continue; $points = $this->calculate_points($order_total); $customer_id = intval($order->customer_id); if ($points > 0) { $this->add_points( $customer_id, $points, 'earn', null, $order->order_id, null, 'Retroactive: Order #' . $order->order_id ); $wc_order = wc_get_order($order->order_id); if ($wc_order) { $wc_order->update_meta_data('_lrwc_points_awarded', $points); $wc_order->save(); } $batch_result['processed']++; $batch_result['points'] += $points; } } wp_send_json_success($batch_result); } public function admin_staff() { global $wpdb; // Handle actions if (isset($_POST['lrwc_add_staff']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'lrwc_add_staff')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized access.', '800website-loyalty-rewards')); } $user_id = intval($_POST['user_id']); $pin = sanitize_text_field($_POST['pin']); $role = sanitize_text_field($_POST['role']); $wpdb->replace($wpdb->prefix . 'lrwc_staff', [ 'user_id' => $user_id, 'pin' => $pin, 'role' => $role, 'status' => 'active' ]); echo '

Staff member added successfully.

'; } if (isset($_GET['delete_staff']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'delete_staff')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized access.', '800website-loyalty-rewards')); } $wpdb->delete($wpdb->prefix . 'lrwc_staff', ['id' => intval($_GET['delete_staff'])]); echo '

Staff member removed.

'; } // Get staff $staff = $wpdb->get_results(" SELECT s.*, u.display_name, u.user_email FROM {$wpdb->prefix}lrwc_staff s JOIN {$wpdb->users} u ON s.user_id = u.ID ORDER BY s.created_at DESC "); // Get users for dropdown (exclude existing staff) $staff_ids = wp_list_pluck($staff, 'user_id'); $staff_ids_str = !empty($staff_ids) ? implode(',', array_map('intval', $staff_ids)) : '0'; $users = get_users([ 'role__in' => ['administrator', 'shop_manager', 'editor'], 'exclude' => $staff_ids, ]); include LRWC_PATH . 'templates/admin-staff.php'; } public function admin_transactions() { global $wpdb; $paged = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; $per_page = 30; $offset = ($paged - 1) * $per_page; $type = isset($_GET['type']) ? sanitize_text_field($_GET['type']) : ''; $where = "WHERE 1=1"; if ($type) { $where .= $wpdb->prepare(" AND t.type = %s", $type); } $transactions = $wpdb->get_results(" SELECT t.*, u.display_name as customer_name, s.display_name as staff_name FROM {$wpdb->prefix}lrwc_transactions t LEFT JOIN {$wpdb->users} u ON t.customer_id = u.ID LEFT JOIN {$wpdb->users} s ON t.staff_id = s.ID $where ORDER BY t.created_at DESC LIMIT $per_page OFFSET $offset "); $total = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}lrwc_transactions t $where"); $total_pages = ceil($total / $per_page); include LRWC_PATH . 'templates/admin-transactions.php'; } public function admin_redemptions() { global $wpdb; // Handle status update if (isset($_POST['update_redemption']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'update_redemption')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized access.', '800website-loyalty-rewards')); } $id = intval($_POST['redemption_id']); $status = sanitize_text_field($_POST['status']); $wpdb->update( $wpdb->prefix . 'lrwc_redemptions', [ 'status' => $status, 'redeemed_at' => $status === 'completed' ? current_time('mysql') : null ], ['id' => $id] ); echo '

Redemption updated.

'; } $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : ''; $where = "WHERE 1=1"; if ($status_filter) { $where .= $wpdb->prepare(" AND r.status = %s", $status_filter); } $redemptions = $wpdb->get_results(" SELECT r.*, u.display_name as customer_name, u.user_email as customer_email, p.post_title as product_name FROM {$wpdb->prefix}lrwc_redemptions r LEFT JOIN {$wpdb->users} u ON r.customer_id = u.ID LEFT JOIN {$wpdb->posts} p ON r.product_id = p.ID $where ORDER BY r.created_at DESC LIMIT 50 "); include LRWC_PATH . 'templates/admin-redemptions.php'; } /** * Admin Pending Points Approvals Page */ public function admin_pending_approvals() { global $wpdb; $currency = html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8'); // Ensure table exists $table_name = $wpdb->prefix . 'lrwc_pending_points'; $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$table_name'"); if (!$table_exists) { $charset = $wpdb->get_charset_collate(); $wpdb->query("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}lrwc_pending_points ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT UNSIGNED NOT NULL, staff_id BIGINT UNSIGNED NOT NULL, invoice_number VARCHAR(100) NOT NULL, bill_amount DECIMAL(15,2) NOT NULL, points BIGINT NOT NULL, status VARCHAR(20) DEFAULT 'pending', admin_id BIGINT UNSIGNED, admin_note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, processed_at DATETIME, INDEX idx_customer (customer_id), INDEX idx_staff (staff_id), INDEX idx_status (status), INDEX idx_invoice (invoice_number) ) $charset"); } // Handle approve/reject if (isset($_POST['approve_points']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'lrwc_pending_action')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized', '800website-loyalty-rewards')); } $id = intval($_POST['request_id']); $pending = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}lrwc_pending_points WHERE id = %d AND status = 'pending'", $id )); if ($pending) { // Approve - add points to customer $this->add_points( $pending->customer_id, $pending->points, 'earn', $pending->staff_id, null, null, 'Invoice #' . $pending->invoice_number . ' - ' . $currency . number_format($pending->bill_amount, 0) ); // Update status $wpdb->update($wpdb->prefix . 'lrwc_pending_points', [ 'status' => 'approved', 'admin_id' => get_current_user_id(), 'processed_at' => current_time('mysql') ], ['id' => $id]); echo '

✓ Points approved and credited to customer!

'; } } if (isset($_POST['reject_points']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'lrwc_pending_action')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized', '800website-loyalty-rewards')); } $id = intval($_POST['request_id']); $note = sanitize_text_field($_POST['admin_note'] ?? ''); $wpdb->update($wpdb->prefix . 'lrwc_pending_points', [ 'status' => 'rejected', 'admin_id' => get_current_user_id(), 'admin_note' => $note, 'processed_at' => current_time('mysql') ], ['id' => $id]); echo '

Request rejected.

'; } // Get pending count $pending_count = intval($wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}lrwc_pending_points WHERE status = 'pending'")); // Get status filter - default to ALL (empty) $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : ''; // Get requests - no filter by default shows all if (!empty($status_filter)) { $where = $wpdb->prepare("WHERE p.status = %s", $status_filter); } else { $where = "WHERE 1=1"; } $requests = $wpdb->get_results(" SELECT p.*, c.display_name as customer_name, c.user_email as customer_email, su.display_name as staff_name, a.display_name as admin_name FROM {$wpdb->prefix}lrwc_pending_points p LEFT JOIN {$wpdb->users} c ON p.customer_id = c.ID LEFT JOIN {$wpdb->prefix}lrwc_staff st ON p.staff_id = st.id LEFT JOIN {$wpdb->users} su ON st.user_id = su.ID LEFT JOIN {$wpdb->users} a ON p.admin_id = a.ID $where ORDER BY p.created_at DESC LIMIT 100 "); include LRWC_PATH . 'templates/admin-pending.php'; } public function admin_settings() { global $wpdb; // Handle full reset if (isset($_POST['lrwc_full_reset']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'lrwc_full_reset')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized', '800website-loyalty-rewards')); } $stats = []; // 1. Reset all points balances in usermeta $result1 = $wpdb->query("DELETE FROM {$wpdb->usermeta} WHERE meta_key = 'lrwc_points_balance'"); $stats[] = "Points balances deleted: " . ($result1 !== false ? $result1 : 'ERROR'); // 2. Delete all transactions $result2 = $wpdb->query("DELETE FROM {$wpdb->prefix}lrwc_transactions"); $stats[] = "Transactions deleted: " . ($result2 !== false ? $result2 : 'ERROR'); // 3. Delete all redemptions $result3 = $wpdb->query("DELETE FROM {$wpdb->prefix}lrwc_redemptions"); $stats[] = "Redemptions deleted: " . ($result3 !== false ? $result3 : 'ERROR'); // 4. Clear order flags from postmeta (legacy storage) $result4 = $wpdb->query("DELETE FROM {$wpdb->postmeta} WHERE meta_key = '_lrwc_points_awarded'"); $stats[] = "Order flags (postmeta) deleted: " . ($result4 !== false ? $result4 : 'ERROR'); // 5. Clear from HPOS orders meta if table exists $hpos_table = $wpdb->prefix . 'wc_orders_meta'; $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$hpos_table'"); if ($table_exists) { $result5 = $wpdb->query("DELETE FROM {$hpos_table} WHERE meta_key = '_lrwc_points_awarded'"); $stats[] = "Order flags (HPOS) deleted: " . ($result5 !== false ? $result5 : 'ERROR'); } // Clear WordPress object cache wp_cache_flush(); // Clear WooCommerce session cache if (function_exists('wc_delete_shop_order_transients')) { wc_delete_shop_order_transients(); } echo '

FULL RESET COMPLETE!

All loyalty data has been wiped. You can now start fresh.

'; } // Handle settings save if (isset($_POST['lrwc_save_settings']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'lrwc_settings')) { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('Unauthorized', '800website-loyalty-rewards')); } update_option('lrwc_points_mode', sanitize_text_field($_POST['points_mode'])); update_option('lrwc_currency_per_point', floatval($_POST['currency_per_point'])); update_option('lrwc_points_per_currency', floatval($_POST['points_per_currency'])); update_option('lrwc_point_value', floatval($_POST['point_value'])); update_option('lrwc_min_order_total', floatval($_POST['min_order_total'])); update_option('lrwc_rewards_category', intval($_POST['rewards_category'])); update_option('lrwc_tier_silver', intval($_POST['tier_silver'])); update_option('lrwc_tier_gold', intval($_POST['tier_gold'])); update_option('lrwc_tier_platinum', intval($_POST['tier_platinum'])); update_option('lrwc_primary_color', sanitize_hex_color($_POST['primary_color'])); update_option('lrwc_secondary_color', sanitize_hex_color($_POST['secondary_color'])); update_option('lrwc_welcome_bonus', intval($_POST['welcome_bonus'])); update_option('lrwc_auto_award_orders', isset($_POST['auto_award_orders']) ? 'yes' : 'no'); update_option('lrwc_enable_checkout_redemption', isset($_POST['enable_checkout_redemption']) ? 'yes' : 'no'); echo '

Settings saved.

'; } // Get product categories $categories = get_terms([ 'taxonomy' => 'product_cat', 'hide_empty' => false, ]); include LRWC_PATH . 'templates/admin-settings.php'; } // ======================== // WOOCOMMERCE INTEGRATION // ======================== public function award_points_on_order($order_id) { if (get_option('lrwc_auto_award_orders') !== 'yes') { return; } $order = wc_get_order($order_id); if (!$order) return; // Skip rewards redemption orders (staff scanner orders paid fully with points) if ($order->get_meta('_lrwc_rewards_redemption') === 'yes') { return; } // Check if already awarded if ($order->get_meta('_lrwc_points_awarded')) { return; } $customer_id = $order->get_customer_id(); if (!$customer_id) return; $min_total = floatval(get_option('lrwc_min_order_total', 0)); $order_total = floatval($order->get_total()); // Get the actual amount paid (excluding points discount) // Check if points were used at checkout $checkout_points_used = intval($order->get_meta('_lrwc_checkout_points_used')); $checkout_discount = floatval($order->get_meta('_lrwc_checkout_discount')); // Also check for staff redemption points discount $staff_points_discount = floatval($order->get_meta('_lrwc_points_discount')); // Calculate the amount that was actually paid with money (not points) // Order total already has the discount applied, so we use the order total // But we should only award points on the actual cash paid, not the discounted amount $amount_paid_with_cash = $order_total; // This is already the discounted total // If the entire order was paid with points, don't award any points if ($amount_paid_with_cash <= 0) { return; } if ($amount_paid_with_cash < $min_total) return; $points = $this->calculate_points($amount_paid_with_cash); if ($points > 0) { $note = 'Order #' . $order_id; if ($checkout_points_used > 0 || $staff_points_discount > 0) { $note .= ' (cash portion only)'; } $this->add_points($customer_id, $points, 'earn', null, $order_id, null, $note); $order->update_meta_data('_lrwc_points_awarded', $points); $order->save(); } } /** * Apply points discount as a negative fee in cart */ public function apply_points_discount_fee($cart) { if (is_admin() && !defined('DOING_AJAX')) return; if (!WC()->session) return; $points_data = WC()->session->get('lrwc_points_discount'); if (!$points_data || empty($points_data['discount'])) return; $discount = floatval($points_data['discount']); $cart_total = $cart->get_subtotal(); // Don't discount more than cart total if ($discount > $cart_total) { $discount = $cart_total; } if ($discount > 0) { $cart->add_fee(__('Points Redemption', '800website-loyalty-rewards'), -$discount, false); } } /** * Process points redemption when order is placed */ public function process_checkout_points_redemption($order_id, $posted_data, $order) { if (!WC()->session) return; $points_data = WC()->session->get('lrwc_points_discount'); if (!$points_data || empty($points_data['points'])) return; $customer_id = $order->get_customer_id(); if (!$customer_id) return; $points_used = intval($points_data['points']); $discount_amount = floatval($points_data['discount']); // Verify customer still has enough points $available = $this->get_points_balance($customer_id); if ($points_used > $available) { $points_used = $available; } if ($points_used > 0) { // Deduct points $this->add_points( $customer_id, -$points_used, 'redeem', null, $order_id, null, 'Checkout discount - Order #' . $order_id ); // Store on order $order->update_meta_data('_lrwc_checkout_points_used', $points_used); $order->update_meta_data('_lrwc_checkout_discount', $discount_amount); $order->save(); $order->add_order_note(sprintf( '💳 %s points redeemed for %s discount', number_format($points_used), wc_price($discount_amount) )); } // Clear session WC()->session->set('lrwc_points_discount', null); } /** * Display points redemption option on checkout */ public function display_points_redemption_option() { if (get_option('lrwc_enable_checkout_redemption', 'yes') !== 'yes') return; if (!is_user_logged_in()) return; $customer_id = get_current_user_id(); $points = $this->get_points_balance($customer_id); if ($points <= 0) return; $point_value = floatval(get_option('lrwc_point_value', 1)); $max_discount = $points * $point_value; $cart_total = WC()->cart->get_subtotal(); // Already applied? $points_data = WC()->session ? WC()->session->get('lrwc_points_discount') : null; $applied_points = $points_data ? intval($points_data['points']) : 0; $currency = get_woocommerce_currency_symbol(); ?>

🎁 Redeem Your Points

You have points available (worth up to )

0): ?>
✓ Using points ( discount)
= 0 discount

1 point =

get_points_balance($customer_id); if ($points > $available) { wp_send_json_error('Not enough points'); } $point_value = floatval(get_option('lrwc_point_value', 1)); $discount = $points * $point_value; WC()->session->set('lrwc_points_discount', [ 'points' => $points, 'discount' => $discount ]); wp_send_json_success(['points' => $points, 'discount' => $discount]); } /** * AJAX handler to remove points at checkout */ public function ajax_remove_checkout_points() { check_ajax_referer('lrwc_checkout_points', 'nonce'); WC()->session->set('lrwc_points_discount', null); wp_send_json_success(); } /** * AJAX add to cart handler for rewards */ public function ajax_add_to_cart() { $product_id = absint($_POST['product_id']); $quantity = empty($_POST['quantity']) ? 1 : wc_stock_amount($_POST['quantity']); // Ensure WC is loaded if (!function_exists('WC') || !WC()->cart) { wp_send_json(['error' => true, 'message' => 'WooCommerce not available']); return; } $product = wc_get_product($product_id); if (!$product) { wp_send_json(['error' => true, 'message' => 'Product not found']); return; } // Check if product is purchasable if (!$product->is_purchasable()) { wp_send_json(['error' => true, 'message' => 'This product cannot be purchased']); return; } // Check stock if (!$product->is_in_stock()) { wp_send_json(['error' => true, 'message' => 'Product is out of stock']); return; } // Add to cart $cart_item_key = WC()->cart->add_to_cart($product_id, $quantity); if ($cart_item_key) { do_action('woocommerce_ajax_added_to_cart', $product_id); // Get mini cart HTML ob_start(); woocommerce_mini_cart(); $mini_cart = ob_get_clean(); // Get cart count $cart_count = WC()->cart->get_cart_contents_count(); // Get fragments $fragments = apply_filters('woocommerce_add_to_cart_fragments', [ 'div.widget_shopping_cart_content' => '
' . $mini_cart . '
', ]); wp_send_json([ 'success' => true, 'fragments' => $fragments, 'cart_hash' => WC()->cart->get_cart_hash(), 'cart_count' => $cart_count, 'message' => $product->get_name() . ' added to cart' ]); } else { // Get WooCommerce notices $notices = wc_get_notices('error'); wc_clear_notices(); $error_msg = !empty($notices) ? wp_strip_all_tags($notices[0]['notice']) : 'Could not add to cart'; wp_send_json([ 'error' => true, 'message' => $error_msg ]); } wp_die(); } /** * Display REWARDS REDEMPTION badge on order admin page */ public function display_rewards_redemption_badge($order) { if ($order->get_meta('_lrwc_rewards_redemption') === 'yes') { $points_used = $order->get_meta('_lrwc_points_used'); ?>
🎁 REWARDS REDEMPTION

This order was paid with loyalty points

get_meta('_lrwc_checkout_points_used'); if ($checkout_points) { $checkout_discount = $order->get_meta('_lrwc_checkout_discount'); ?>
💳 Points Used at Checkout

points = discount

get_meta('_lrwc_rewards_redemption') === 'yes') { $data['status_name'] = '🎁 ' . $data['status_name'] . ' (REWARDS)'; } return $data; } public function add_account_menu_item($items) { $new_items = []; foreach ($items as $key => $value) { $new_items[$key] = $value; if ($key === 'orders') { $new_items['lrwc-loyalty-points'] = 'Loyalty Points'; } } return $new_items; } public function account_loyalty_content() { $customer_id = get_current_user_id(); $points = $this->get_points_balance($customer_id); $tier = $this->get_tier($points); $transactions = $this->get_transactions($customer_id, 20); $rewards = $this->get_available_rewards(); include LRWC_PATH . 'templates/myaccount-loyalty.php'; } public function add_order_column($columns) { $new_columns = []; foreach ($columns as $key => $value) { $new_columns[$key] = $value; if ($key === 'order_total') { $new_columns['lrwc_points'] = 'Points'; } } return $new_columns; } public function order_column_content($column, $post_id) { if ($column === 'lrwc_points') { $order = wc_get_order($post_id); $points = $order ? $order->get_meta('_lrwc_points_awarded') : 0; echo $points ? '+' . intval($points) : '-'; } } public function order_column_content_hpos($column, $order) { if ($column === 'lrwc_points') { $points = $order->get_meta('_lrwc_points_awarded'); echo $points ? '+' . intval($points) : '-'; } } // ======================== // POINTS HELPERS // ======================== public function get_points_balance($customer_id) { return intval(get_user_meta($customer_id, 'lrwc_points_balance', true)); } public function update_points_balance($customer_id, $points) { update_user_meta($customer_id, 'lrwc_points_balance', max(0, $points)); } public function add_points($customer_id, $points, $type, $staff_id = null, $order_id = null, $product_id = null, $note = '') { global $wpdb; $current = $this->get_points_balance($customer_id); if ($type === 'redeem') { $points = -abs($points); } $new_balance = max(0, $current + $points); $this->update_points_balance($customer_id, $new_balance); // Log transaction $wpdb->insert($wpdb->prefix . 'lrwc_transactions', [ 'customer_id' => $customer_id, 'staff_id' => $staff_id, 'type' => $type, 'points' => $points, 'order_id' => $order_id, 'product_id' => $product_id, 'note' => $note, 'created_at' => current_time('mysql') ]); return $new_balance; } public function get_transactions($customer_id, $limit = 50) { global $wpdb; return $wpdb->get_results($wpdb->prepare(" SELECT * FROM {$wpdb->prefix}lrwc_transactions WHERE customer_id = %d ORDER BY created_at DESC LIMIT %d ", $customer_id, $limit)); } public function get_tier($points) { $platinum = intval(get_option('lrwc_tier_platinum', 5000)); $gold = intval(get_option('lrwc_tier_gold', 1500)); $silver = intval(get_option('lrwc_tier_silver', 500)); if ($points >= $platinum) return 'Platinum'; if ($points >= $gold) return 'Gold'; if ($points >= $silver) return 'Silver'; return 'Bronze'; } /** * Calculate points for a given order amount * Supports two modes: * - currency_per_point: e.g., 200 J.J = 1 point * - points_per_currency: e.g., 0.005 points per 1 J.J */ public function calculate_points($order_total) { $mode = get_option('lrwc_points_mode', 'currency_per_point'); $order_total = floatval($order_total); if ($mode === 'currency_per_point') { // e.g., 200 currency = 1 point $currency_per_point = floatval(get_option('lrwc_currency_per_point', 1)); if ($currency_per_point <= 0) $currency_per_point = 1; $points = floor($order_total / $currency_per_point); } else { // e.g., 0.005 points per 1 currency $points_rate = floatval(get_option('lrwc_points_per_currency', 1)); $points = floor($order_total * $points_rate); } // Ensure we return a reasonable number (prevent overflow) return max(0, min($points, PHP_INT_MAX)); } public function get_available_rewards() { $category_id = get_option('lrwc_rewards_category'); if (!$category_id) return []; $args = [ 'post_type' => 'product', 'posts_per_page' => -1, 'post_status' => 'publish', 'tax_query' => [ [ 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => $category_id, ] ] ]; $products = get_posts($args); $rewards = []; foreach ($products as $product) { $wc_product = wc_get_product($product->ID); if (!$wc_product) continue; // Points cost = custom meta or product price $points_cost = get_post_meta($product->ID, '_lrwc_points_cost', true); if (!$points_cost) { $points_cost = intval($wc_product->get_price()); } $rewards[] = [ 'id' => $product->ID, 'name' => $product->post_title, 'description' => $wc_product->get_short_description(), 'image' => get_the_post_thumbnail_url($product->ID, 'thumbnail'), 'points_cost' => intval($points_cost), 'price' => floatval($wc_product->get_price()), 'price_html' => $wc_product->get_price_html(), 'in_stock' => $wc_product->is_in_stock(), 'stock' => $wc_product->is_in_stock(), // backwards compatibility ]; } return $rewards; } public function generate_qr_code_data($customer_id) { $user = get_user_by('ID', $customer_id); if (!$user) return ''; // Create a unique hash for this customer $hash = wp_hash($customer_id . $user->user_email . 'lrwc_salt'); return json_encode([ 'id' => $customer_id, 'hash' => substr($hash, 0, 16), 'site' => get_site_url() ]); } // ======================== // REST API // ======================== /** * Permission callback for staff endpoints - validates staff token. */ public function check_staff_permission($request) { return (bool) $this->verify_staff_token($request); } /** * Permission callback for logged-in customer endpoints. */ public function check_logged_in_permission() { return is_user_logged_in(); } public function register_api() { // Customer endpoints register_rest_route('lrwc/v1', '/customer/profile', [ 'methods' => 'GET', 'callback' => [$this, 'api_customer_profile'], 'permission_callback' => function() { return is_user_logged_in(); } ]); register_rest_route('lrwc/v1', '/customer/transactions', [ 'methods' => 'GET', 'callback' => [$this, 'api_customer_transactions'], 'permission_callback' => function() { return is_user_logged_in(); } ]); // Public endpoint: rewards catalog is publicly viewable. register_rest_route('lrwc/v1', '/rewards', [ 'methods' => 'GET', 'callback' => [$this, 'api_rewards'], 'permission_callback' => '__return_true' // Intentionally public - displays reward catalog. ]); register_rest_route('lrwc/v1', '/redeem', [ 'methods' => 'POST', 'callback' => [$this, 'api_redeem'], 'permission_callback' => function() { return is_user_logged_in(); } ]); // Staff login endpoint: must be public; authentication is handled in the callback. register_rest_route('lrwc/v1', '/staff/login', [ 'methods' => 'POST', 'callback' => [$this, 'api_staff_login'], 'permission_callback' => '__return_true' // Intentionally public - credentials verified in callback. ]); register_rest_route('lrwc/v1', '/staff/scan', [ 'methods' => 'POST', 'callback' => [$this, 'api_staff_scan'], 'permission_callback' => [$this, 'check_staff_permission'] ]); register_rest_route('lrwc/v1', '/staff/add-points', [ 'methods' => 'POST', 'callback' => [$this, 'api_staff_add_points'], 'permission_callback' => [$this, 'check_staff_permission'] ]); register_rest_route('lrwc/v1', '/staff/process-redemption', [ 'methods' => 'POST', 'callback' => [$this, 'api_staff_process_redemption'], 'permission_callback' => [$this, 'check_staff_permission'] ]); register_rest_route('lrwc/v1', '/staff/pending-redemptions', [ 'methods' => 'GET', 'callback' => [$this, 'api_staff_pending_redemptions'], 'permission_callback' => [$this, 'check_staff_permission'] ]); // Staff rewards list for redemption register_rest_route('lrwc/v1', '/staff/rewards', [ 'methods' => 'GET', 'callback' => [$this, 'api_staff_rewards'], 'permission_callback' => [$this, 'check_staff_permission'] ]); // Staff redeem product for customer register_rest_route('lrwc/v1', '/staff/redeem-product', [ 'methods' => 'POST', 'callback' => [$this, 'api_staff_redeem_product'], 'permission_callback' => [$this, 'check_staff_permission'] ]); // Staff lookup customer by phone/email/username register_rest_route('lrwc/v1', '/staff/lookup', [ 'methods' => 'POST', 'callback' => [$this, 'api_staff_lookup_customer'], 'permission_callback' => [$this, 'check_staff_permission'] ]); // Staff create order with multiple products register_rest_route('lrwc/v1', '/staff/create-order', [ 'methods' => 'POST', 'callback' => [$this, 'api_staff_create_order'], 'permission_callback' => [$this, 'check_staff_permission'] ]); // Customer checkout points register_rest_route('lrwc/v1', '/checkout/apply-points', [ 'methods' => 'POST', 'callback' => [$this, 'api_apply_points_to_checkout'], 'permission_callback' => [$this, 'check_logged_in_permission'] ]); register_rest_route('lrwc/v1', '/checkout/remove-points', [ 'methods' => 'POST', 'callback' => [$this, 'api_remove_points_from_checkout'], 'permission_callback' => [$this, 'check_logged_in_permission'] ]); } public function api_customer_profile($request) { $customer_id = get_current_user_id(); $user = get_user_by('ID', $customer_id); $points = $this->get_points_balance($customer_id); // Get total earned global $wpdb; $total_earned = $wpdb->get_var($wpdb->prepare( "SELECT COALESCE(SUM(points), 0) FROM {$wpdb->prefix}lrwc_transactions WHERE customer_id = %d AND type IN ('earn', 'bonus', 'manual')", $customer_id )); $order_count = wc_get_customer_order_count($customer_id); return [ 'success' => true, 'customer' => [ 'id' => $customer_id, 'name' => $user->display_name, 'email' => $user->user_email, 'points' => $points, 'tier' => $this->get_tier($points), 'total_earned' => intval($total_earned), 'order_count' => $order_count, 'qr_data' => $this->generate_qr_code_data($customer_id) ] ]; } public function api_customer_transactions($request) { $customer_id = get_current_user_id(); $transactions = $this->get_transactions($customer_id, 50); return [ 'success' => true, 'transactions' => array_map(function($t) { return [ 'id' => $t->id, 'type' => $t->type, 'points' => intval($t->points), 'note' => $t->note, 'created_at' => $t->created_at ]; }, $transactions) ]; } public function api_rewards($request) { return [ 'success' => true, 'rewards' => $this->get_available_rewards() ]; } public function api_redeem($request) { global $wpdb; $customer_id = get_current_user_id(); $product_id = intval($request->get_param('product_id')); $rewards = $this->get_available_rewards(); $reward = null; foreach ($rewards as $r) { if ($r['id'] == $product_id) { $reward = $r; break; } } if (!$reward) { return new WP_Error('invalid_reward', 'Reward not found', ['status' => 400]); } if (!$reward['stock']) { return new WP_Error('out_of_stock', 'Reward is out of stock', ['status' => 400]); } $balance = $this->get_points_balance($customer_id); if ($balance < $reward['points_cost']) { return new WP_Error('insufficient_points', 'Not enough points', ['status' => 400]); } // Create redemption code $code = 'LRW-' . strtoupper(wp_generate_password(8, false)); // Create redemption record $wpdb->insert($wpdb->prefix . 'lrwc_redemptions', [ 'customer_id' => $customer_id, 'product_id' => $product_id, 'points_used' => $reward['points_cost'], 'status' => 'pending', 'code' => $code, 'created_at' => current_time('mysql') ]); // Deduct points $this->add_points($customer_id, $reward['points_cost'], 'redeem', null, null, $product_id, 'Redeemed: ' . $reward['name']); return [ 'success' => true, 'message' => 'Reward redeemed successfully!', 'code' => $code, 'new_balance' => $this->get_points_balance($customer_id) ]; } public function api_staff_login($request) { global $wpdb; $email = sanitize_email($request->get_param('email')); $pin = sanitize_text_field($request->get_param('pin')); $user = get_user_by('email', $email); if (!$user) { return new WP_Error('invalid_credentials', 'Invalid email or PIN', ['status' => 401]); } $staff = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}lrwc_staff WHERE user_id = %d AND pin = %s AND status = 'active'", $user->ID, $pin )); if (!$staff) { return new WP_Error('invalid_credentials', 'Invalid email or PIN', ['status' => 401]); } // Generate token with staff ID embedded for easy lookup $token = 'lrwc_' . $staff->id . '_' . md5($user->ID . time() . wp_rand()); // Store token in user meta (more reliable than transients) update_user_meta($user->ID, '_lrwc_staff_token', $token); update_user_meta($user->ID, '_lrwc_staff_token_time', time()); return [ 'success' => true, 'staff' => [ 'id' => $staff->id, 'user_id' => $user->ID, 'name' => $user->display_name, 'role' => $staff->role ], 'token' => $token ]; } private function verify_staff_token($request) { global $wpdb; // Try multiple ways to get the token $token = $request->get_header('X-Staff-Token'); if (!$token) { $token = $request->get_param('staff_token'); } if (!$token && isset($_SERVER['HTTP_X_STAFF_TOKEN'])) { $token = sanitize_text_field($_SERVER['HTTP_X_STAFF_TOKEN']); } if (!$token && isset($_POST['staff_token'])) { $token = sanitize_text_field($_POST['staff_token']); } if (!$token && isset($_GET['staff_token'])) { $token = sanitize_text_field($_GET['staff_token']); } if (!$token || strpos($token, 'lrwc_') !== 0) { return false; } // Extract staff_id from token (format: lrwc_STAFFID_HASH) $parts = explode('_', $token); if (count($parts) < 3) { return false; } $staff_id = intval($parts[1]); if (!$staff_id) { return false; } // Get staff record $staff = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}lrwc_staff WHERE id = %d AND status = 'active'", $staff_id )); if (!$staff) { return false; } // Verify token matches what's stored for this user $stored_token = get_user_meta($staff->user_id, '_lrwc_staff_token', true); $token_time = get_user_meta($staff->user_id, '_lrwc_staff_token_time', true); if ($stored_token !== $token) { return false; } // Check if token is expired (12 hours) if (time() - intval($token_time) > 12 * HOUR_IN_SECONDS) { delete_user_meta($staff->user_id, '_lrwc_staff_token'); delete_user_meta($staff->user_id, '_lrwc_staff_token_time'); return false; } return [ 'staff_id' => $staff->id, 'user_id' => $staff->user_id ]; } public function api_staff_scan($request) { global $wpdb; // Verify staff token $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } $qr_data = json_decode($request->get_param('qr_data'), true); if (!$qr_data || !isset($qr_data['id']) || !isset($qr_data['hash'])) { return new WP_Error('invalid_qr', 'Invalid QR code', ['status' => 400]); } $customer_id = intval($qr_data['id']); $user = get_user_by('ID', $customer_id); if (!$user) { return new WP_Error('customer_not_found', 'Customer not found', ['status' => 404]); } // Verify hash $expected_hash = substr(wp_hash($customer_id . $user->user_email . 'lrwc_salt'), 0, 16); if ($qr_data['hash'] !== $expected_hash) { return new WP_Error('invalid_qr', 'Invalid QR code', ['status' => 400]); } $points = $this->get_points_balance($customer_id); // Get display name $first_name = get_user_meta($customer_id, 'billing_first_name', true) ?: get_user_meta($customer_id, 'first_name', true); $last_name = get_user_meta($customer_id, 'billing_last_name', true) ?: get_user_meta($customer_id, 'last_name', true); $display_name = trim($first_name . ' ' . $last_name); if (empty($display_name)) { $display_name = $user->display_name; } // Get pending redemptions for this customer $pending = $wpdb->get_results($wpdb->prepare( "SELECT r.*, p.post_title as product_name FROM {$wpdb->prefix}lrwc_redemptions r LEFT JOIN {$wpdb->posts} p ON r.product_id = p.ID WHERE r.customer_id = %d AND r.status = 'pending' ORDER BY r.created_at DESC", $customer_id )); return [ 'success' => true, 'customer' => [ 'id' => $customer_id, 'name' => $display_name, 'username' => $user->user_login, 'email' => $user->user_email, 'points' => $points, 'tier' => $this->get_tier($points) ], 'pending_redemptions' => array_map(function($r) { return [ 'id' => $r->id, 'product_name' => $r->product_name, 'points_used' => $r->points_used, 'code' => $r->code, 'created_at' => $r->created_at ]; }, $pending) ]; } public function api_staff_add_points($request) { global $wpdb; // Verify staff token $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } $customer_id = intval($request->get_param('customer_id')); $amount = floatval($request->get_param('amount')); $invoice_number = sanitize_text_field($request->get_param('invoice_number')); if (empty($invoice_number)) { return new WP_Error('missing_invoice', 'Invoice number is required', ['status' => 400]); } if ($amount <= 0) { return new WP_Error('invalid_amount', 'Amount must be positive', ['status' => 400]); } // Ensure table exists $table_name = $wpdb->prefix . 'lrwc_pending_points'; $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$table_name'"); if (!$table_exists) { $charset = $wpdb->get_charset_collate(); $wpdb->query("CREATE TABLE IF NOT EXISTS {$table_name} ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT UNSIGNED NOT NULL, staff_id BIGINT UNSIGNED NOT NULL, invoice_number VARCHAR(100) NOT NULL, bill_amount DECIMAL(15,2) NOT NULL, points BIGINT NOT NULL, status VARCHAR(20) DEFAULT 'pending', admin_id BIGINT UNSIGNED, admin_note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, processed_at DATETIME, INDEX idx_customer (customer_id), INDEX idx_staff (staff_id), INDEX idx_status (status), INDEX idx_invoice (invoice_number) ) $charset"); } // Check for duplicate invoice $existing = $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$wpdb->prefix}lrwc_pending_points WHERE invoice_number = %s", $invoice_number )); if ($existing) { return new WP_Error('duplicate_invoice', 'This invoice number has already been submitted', ['status' => 400]); } $points = $this->calculate_points($amount); // Create pending request (admin must approve) $result = $wpdb->insert($wpdb->prefix . 'lrwc_pending_points', [ 'customer_id' => $customer_id, 'staff_id' => $session['staff_id'], 'invoice_number' => $invoice_number, 'bill_amount' => $amount, 'points' => $points, 'status' => 'pending', 'created_at' => current_time('mysql') ]); if ($result === false) { return new WP_Error('db_error', 'Failed to save request: ' . $wpdb->last_error, ['status' => 500]); } $request_id = $wpdb->insert_id; // Get customer info for response $customer = get_user_by('ID', $customer_id); $current_balance = $this->get_points_balance($customer_id); return [ 'success' => true, 'request_id' => $request_id, 'points_requested' => $points, 'current_balance' => $current_balance, 'message' => 'Request submitted! ' . number_format($points) . ' points pending admin approval for invoice #' . $invoice_number ]; } public function api_staff_process_redemption($request) { global $wpdb; // Verify staff token $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } $redemption_id = intval($request->get_param('redemption_id')); $redemption = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}lrwc_redemptions WHERE id = %d AND status = 'pending'", $redemption_id )); if (!$redemption) { return new WP_Error('not_found', 'Redemption not found or already processed', ['status' => 404]); } $wpdb->update( $wpdb->prefix . 'lrwc_redemptions', [ 'status' => 'completed', 'staff_id' => $session['user_id'], 'redeemed_at' => current_time('mysql') ], ['id' => $redemption_id] ); return [ 'success' => true, 'message' => 'Redemption completed successfully' ]; } public function api_staff_pending_redemptions($request) { global $wpdb; // Verify staff token $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } $pending = $wpdb->get_results(" SELECT r.*, u.display_name as customer_name, p.post_title as product_name FROM {$wpdb->prefix}lrwc_redemptions r LEFT JOIN {$wpdb->users} u ON r.customer_id = u.ID LEFT JOIN {$wpdb->posts} p ON r.product_id = p.ID WHERE r.status = 'pending' ORDER BY r.created_at DESC "); return [ 'success' => true, 'redemptions' => $pending ]; } /** * Get rewards products for staff to select */ public function api_staff_rewards($request) { $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } $rewards = $this->get_available_rewards(); return [ 'success' => true, 'rewards' => $rewards ]; } /** * Staff redeems a product for a customer - creates WooCommerce order */ public function api_staff_redeem_product($request) { global $wpdb; $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } $customer_id = intval($request->get_param('customer_id')); $product_id = intval($request->get_param('product_id')); $points_to_use = intval($request->get_param('points_to_use')); // Optional: specific points to use if (!$customer_id || !$product_id) { return new WP_Error('invalid_params', 'Missing customer or product ID', ['status' => 400]); } // Get product $product = wc_get_product($product_id); if (!$product) { return new WP_Error('invalid_product', 'Product not found', ['status' => 404]); } // Get product price and points cost $product_price = floatval($product->get_price()); $points_cost = get_post_meta($product_id, '_lrwc_points_cost', true); if (!$points_cost) { $points_cost = intval($product_price); } $points_cost = intval($points_cost); // Get customer points $customer_points = $this->get_points_balance($customer_id); // Determine points to use if ($points_to_use > 0) { $points_to_use = min($points_to_use, $customer_points, $points_cost); } else { // Use as many points as needed/available $points_to_use = min($customer_points, $points_cost); } // Calculate point value for discount $point_value = floatval(get_option('lrwc_point_value', 1)); $points_discount = $points_to_use * $point_value; // If using points_cost mode, full redemption means $0 $order_total = max(0, $product_price - $points_discount); $full_redemption = ($points_to_use >= $points_cost); // Check stock if (!$product->is_in_stock()) { return new WP_Error('out_of_stock', 'Product is out of stock', ['status' => 400]); } // Get customer data $customer = get_user_by('ID', $customer_id); if (!$customer) { return new WP_Error('invalid_customer', 'Customer not found', ['status' => 404]); } // Create WooCommerce order $order = wc_create_order([ 'customer_id' => $customer_id, 'status' => 'processing' ]); if (is_wp_error($order)) { return new WP_Error('order_failed', 'Failed to create order: ' . $order->get_error_message(), ['status' => 500]); } // Add product to order at full price $order->add_product($product, 1); // Add points discount as fee if ($points_discount > 0) { $item = new WC_Order_Item_Fee(); $item->set_name('Points Redemption (' . number_format($points_to_use) . ' points)'); $item->set_amount(-$points_discount); $item->set_total(-$points_discount); $order->add_item($item); } // Recalculate totals $order->calculate_totals(); // Add order meta $order->update_meta_data('_lrwc_rewards_redemption', 'yes'); $order->update_meta_data('_lrwc_points_used', $points_to_use); $order->update_meta_data('_lrwc_points_discount', $points_discount); $order->update_meta_data('_lrwc_staff_id', $session['staff_id']); // Set billing details from customer $order->set_billing_first_name(get_user_meta($customer_id, 'billing_first_name', true) ?: $customer->first_name ?: $customer->display_name); $order->set_billing_last_name(get_user_meta($customer_id, 'billing_last_name', true) ?: $customer->last_name ?: ''); $order->set_billing_email($customer->user_email); $order->set_billing_phone(get_user_meta($customer_id, 'billing_phone', true)); // Set payment method $order->set_payment_method('lrwc_points'); $order->set_payment_method_title($full_redemption ? 'Points Redemption' : 'Points + Cash'); // Add order note $note = $full_redemption ? sprintf('🎁 REWARDS REDEMPTION - Fully paid with %s points', number_format($points_to_use)) : sprintf('🎁 REWARDS REDEMPTION - %s points used (%s discount), remaining %s paid', number_format($points_to_use), wc_price($points_discount), wc_price($order->get_total()) ); $order->add_order_note($note . ' - Staff processed'); // Set status based on payment if ($full_redemption || $order->get_total() <= 0) { $order->set_status('completed'); } $order->save(); // Deduct points from customer if ($points_to_use > 0) { $new_balance = $this->add_points( $customer_id, -$points_to_use, 'redeem', $session['staff_id'], $order->get_id(), $product_id, 'Redeemed: ' . $product->get_name() . ' (Order #' . $order->get_id() . ')' ); } else { $new_balance = $customer_points; } // Record redemption $wpdb->insert($wpdb->prefix . 'lrwc_redemptions', [ 'customer_id' => $customer_id, 'staff_id' => $session['staff_id'], 'product_id' => $product_id, 'points_used' => $points_to_use, 'status' => 'completed', 'code' => 'ORDER-' . $order->get_id(), 'redeemed_at' => current_time('mysql') ]); // Reduce stock wc_reduce_stock_levels($order->get_id()); return [ 'success' => true, 'message' => sprintf('Order #%d created! %s points used.', $order->get_id(), number_format($points_to_use)), 'order_id' => $order->get_id(), 'order_total' => $order->get_total(), 'points_used' => $points_to_use, 'points_discount' => $points_discount, 'new_balance' => $new_balance, 'full_redemption' => $full_redemption ]; } /** * Lookup customer by phone, email, or username */ public function api_staff_lookup_customer($request) { global $wpdb; $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } $search = sanitize_text_field($request->get_param('search')); if (empty($search) || strlen($search) < 2) { return new WP_Error('invalid_search', 'Please enter at least 2 characters', ['status' => 400]); } $search_like = '%' . $wpdb->esc_like($search) . '%'; // Search users by display_name, user_email, user_login, or billing_phone $user_ids = $wpdb->get_col($wpdb->prepare(" SELECT DISTINCT u.ID FROM {$wpdb->users} u LEFT JOIN {$wpdb->usermeta} um_phone ON u.ID = um_phone.user_id AND um_phone.meta_key = 'billing_phone' LEFT JOIN {$wpdb->usermeta} um_fname ON u.ID = um_fname.user_id AND um_fname.meta_key = 'first_name' LEFT JOIN {$wpdb->usermeta} um_lname ON u.ID = um_lname.user_id AND um_lname.meta_key = 'last_name' LEFT JOIN {$wpdb->usermeta} um_bfname ON u.ID = um_bfname.user_id AND um_bfname.meta_key = 'billing_first_name' LEFT JOIN {$wpdb->usermeta} um_blname ON u.ID = um_blname.user_id AND um_blname.meta_key = 'billing_last_name' WHERE u.display_name LIKE %s OR u.user_email LIKE %s OR u.user_login LIKE %s OR um_phone.meta_value LIKE %s OR um_fname.meta_value LIKE %s OR um_lname.meta_value LIKE %s OR um_bfname.meta_value LIKE %s OR um_blname.meta_value LIKE %s OR CONCAT(um_fname.meta_value, ' ', um_lname.meta_value) LIKE %s OR CONCAT(um_bfname.meta_value, ' ', um_blname.meta_value) LIKE %s LIMIT 20 ", $search_like, $search_like, $search_like, $search_like, $search_like, $search_like, $search_like, $search_like, $search_like, $search_like)); if (empty($user_ids)) { return new WP_Error('not_found', 'No customers found for "' . $search . '"', ['status' => 404]); } $results = []; foreach ($user_ids as $user_id) { $user = get_user_by('ID', $user_id); if (!$user) continue; $points = $this->get_points_balance($user_id); $phone = get_user_meta($user_id, 'billing_phone', true); $first_name = get_user_meta($user_id, 'billing_first_name', true) ?: get_user_meta($user_id, 'first_name', true); $last_name = get_user_meta($user_id, 'billing_last_name', true) ?: get_user_meta($user_id, 'last_name', true); $display_name = trim($first_name . ' ' . $last_name); if (empty($display_name)) { $display_name = $user->display_name; } $results[] = [ 'id' => intval($user_id), 'name' => $display_name, 'username' => $user->user_login, 'email' => $user->user_email, 'phone' => $phone, 'points' => $points, 'tier' => $this->get_tier($points) ]; } return [ 'success' => true, 'customers' => $results ]; } /** * Staff creates order with multiple products and points payment */ public function api_staff_create_order($request) { global $wpdb; $session = $this->verify_staff_token($request); if (!$session) { return new WP_Error('unauthorized', 'Invalid or expired session. Please login again.', ['status' => 401]); } // Parse JSON body if sent as JSON $body = $request->get_json_params(); if (!empty($body)) { $customer_id = intval($body['customer_id'] ?? 0); $products = $body['products'] ?? []; $points_to_use = intval($body['points_to_use'] ?? 0); } else { $customer_id = intval($request->get_param('customer_id')); $products = $request->get_param('products'); $points_to_use = intval($request->get_param('points_to_use')); // Legacy support for product_ids if (empty($products)) { $product_ids = $request->get_param('product_ids'); if (!empty($product_ids)) { $products = array_map(function($id) { return ['id' => intval($id), 'qty' => 1]; }, $product_ids); } } } if (!$customer_id) { return new WP_Error('invalid_params', 'Missing customer ID', ['status' => 400]); } if (empty($products) || !is_array($products)) { return new WP_Error('invalid_params', 'No products selected', ['status' => 400]); } // Get customer $customer = get_user_by('ID', $customer_id); if (!$customer) { return new WP_Error('invalid_customer', 'Customer not found', ['status' => 404]); } // Check customer has enough points $customer_points = $this->get_points_balance($customer_id); if ($points_to_use > $customer_points) { $points_to_use = $customer_points; } // Calculate point value for discount $point_value = floatval(get_option('lrwc_point_value', 1)); $max_points_discount = $points_to_use * $point_value; // Create WooCommerce order $order = wc_create_order([ 'customer_id' => $customer_id, 'status' => 'processing' ]); if (is_wp_error($order)) { return new WP_Error('order_failed', 'Failed to create order: ' . $order->get_error_message(), ['status' => 500]); } // Add products to order with quantities $subtotal = 0; $product_names = []; $first_product_id = null; foreach ($products as $item) { $product_id = intval($item['id'] ?? 0); $qty = intval($item['qty'] ?? 1); if (!$product_id || $qty < 1) continue; $product = wc_get_product($product_id); if (!$product) continue; if ($first_product_id === null) { $first_product_id = $product_id; } $order->add_product($product, $qty); $subtotal += floatval($product->get_price()) * $qty; $product_names[] = $product->get_name() . ($qty > 1 ? ' × ' . $qty : ''); } // Calculate actual points discount (can't exceed subtotal) $points_discount = min($max_points_discount, $subtotal); $actual_points_used = ceil($points_discount / $point_value); $to_pay = max(0, $subtotal - $points_discount); // Add points discount as fee if ($points_discount > 0) { $item = new WC_Order_Item_Fee(); $item->set_name('Points Redemption (' . number_format($actual_points_used) . ' points)'); $item->set_amount(-$points_discount); $item->set_total(-$points_discount); $order->add_item($item); } // Set billing details $order->set_billing_first_name(get_user_meta($customer_id, 'billing_first_name', true) ?: $customer->first_name ?: $customer->display_name); $order->set_billing_last_name(get_user_meta($customer_id, 'billing_last_name', true) ?: $customer->last_name ?: ''); $order->set_billing_email($customer->user_email); $order->set_billing_phone(get_user_meta($customer_id, 'billing_phone', true)); // Set payment method if ($to_pay <= 0) { $order->set_payment_method('lrwc_points'); $order->set_payment_method_title('Points Redemption'); $order->set_status('completed'); } else { $order->set_payment_method('lrwc_points_cash'); $order->set_payment_method_title('Points + Cash'); } // Add order meta $order->update_meta_data('_lrwc_rewards_redemption', 'yes'); $order->update_meta_data('_lrwc_points_used', $actual_points_used); $order->update_meta_data('_lrwc_points_discount', $points_discount); $order->update_meta_data('_lrwc_staff_id', $session['staff_id']); $order->update_meta_data('_lrwc_to_pay', $to_pay); // Calculate totals $order->calculate_totals(); // Add order note $note = sprintf( '🎁 REWARDS ORDER - Staff processed\nItems: %s\nPoints used: %s\nDiscount: %s\nTo collect: %s', implode(', ', $product_names), number_format($actual_points_used), wc_price($points_discount), wc_price($to_pay) ); $order->add_order_note($note); $order->save(); // Deduct points from customer if ($actual_points_used > 0) { $new_balance = $this->add_points( $customer_id, -$actual_points_used, 'redeem', $session['staff_id'], $order->get_id(), null, 'Order #' . $order->get_id() . ' - ' . implode(', ', array_slice($product_names, 0, 2)) ); } else { $new_balance = $customer_points; } // Record redemption $wpdb->insert($wpdb->prefix . 'lrwc_redemptions', [ 'customer_id' => $customer_id, 'staff_id' => $session['staff_id'], 'product_id' => $first_product_id ?: 0, 'points_used' => $actual_points_used, 'status' => 'completed', 'code' => 'ORDER-' . $order->get_id(), 'redeemed_at' => current_time('mysql') ]); // Reduce stock wc_reduce_stock_levels($order->get_id()); return [ 'success' => true, 'message' => 'Order created successfully', 'order_id' => $order->get_id(), 'subtotal' => $subtotal, 'points_used' => $actual_points_used, 'points_discount' => $points_discount, 'to_pay' => $to_pay, 'new_balance' => $new_balance ]; } /** * Apply points to checkout */ public function api_apply_points_to_checkout($request) { if (!is_user_logged_in()) { return new WP_Error('not_logged_in', 'Please log in to use points', ['status' => 401]); } $customer_id = get_current_user_id(); $points_to_use = intval($request->get_param('points')); if ($points_to_use <= 0) { return new WP_Error('invalid_points', 'Invalid points amount', ['status' => 400]); } $available_points = $this->get_points_balance($customer_id); if ($points_to_use > $available_points) { return new WP_Error('insufficient_points', 'Not enough points', ['status' => 400]); } // Calculate discount value $point_value = floatval(get_option('lrwc_point_value', 1)); $discount_amount = $points_to_use * $point_value; // Store in session WC()->session->set('lrwc_points_discount', [ 'points' => $points_to_use, 'discount' => $discount_amount ]); return [ 'success' => true, 'points_applied' => $points_to_use, 'discount_amount' => $discount_amount, 'message' => sprintf('Applied %s points for %s discount', number_format($points_to_use), wc_price($discount_amount)) ]; } /** * Remove points from checkout */ public function api_remove_points_from_checkout($request) { WC()->session->set('lrwc_points_discount', null); return [ 'success' => true, 'message' => 'Points discount removed' ]; } // ======================== // SHORTCODES // ======================== public function shortcode_customer_app($atts) { if (!is_user_logged_in()) { return '

Please log in to access your loyalty rewards.

'; } ob_start(); include LRWC_PATH . 'templates/customer-app.php'; return ob_get_clean(); } public function shortcode_staff_scanner($atts) { ob_start(); include LRWC_PATH . 'templates/staff-scanner.php'; return ob_get_clean(); } } // Initialize LRWC_Loyalty_Rewards::instance();