From 53209c438195c0b70994b57c694f57ba5be5ca62 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Mon, 1 Jun 2026 00:57:42 +0700 Subject: [PATCH] feat(affiliate): add core module, controllers, and route registration --- .../Controllers/AffiliateAdminController.php | 364 +++++++++++++++ .../AffiliateCustomerController.php | 273 ++++++++++++ includes/Api/Routes.php | 8 + includes/Core/ModuleRegistry.php | 1 + .../Modules/Affiliate/AffiliateLifecycle.php | 252 +++++++++++ .../Modules/Affiliate/AffiliateManager.php | 156 +++++++ .../Modules/Affiliate/AffiliateModule.php | 136 ++++++ .../Modules/Affiliate/AffiliateSettings.php | 71 +++ .../Modules/Affiliate/AffiliateTracker.php | 416 ++++++++++++++++++ woonoow.php | 1 + 10 files changed, 1678 insertions(+) create mode 100644 includes/Api/Controllers/AffiliateAdminController.php create mode 100644 includes/Api/Controllers/AffiliateCustomerController.php create mode 100644 includes/Modules/Affiliate/AffiliateLifecycle.php create mode 100644 includes/Modules/Affiliate/AffiliateManager.php create mode 100644 includes/Modules/Affiliate/AffiliateModule.php create mode 100644 includes/Modules/Affiliate/AffiliateSettings.php create mode 100644 includes/Modules/Affiliate/AffiliateTracker.php diff --git a/includes/Api/Controllers/AffiliateAdminController.php b/includes/Api/Controllers/AffiliateAdminController.php new file mode 100644 index 0000000..6a8d560 --- /dev/null +++ b/includes/Api/Controllers/AffiliateAdminController.php @@ -0,0 +1,364 @@ +namespace, '/admin/affiliates', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_affiliates'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Get Affiliate Balance (payable amount) + register_rest_route($this->namespace, '/admin/affiliates/(?P\d+)/balance', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_affiliate_balance'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Approve Affiliate + register_rest_route($this->namespace, '/admin/affiliates/(?P\d+)/approve', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'approve_affiliate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Update Affiliate (commission rate) + register_rest_route($this->namespace, '/admin/affiliates/(?P\d+)/update', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'update_affiliate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // List Referrals + register_rest_route($this->namespace, '/admin/affiliates/referrals', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_referrals'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // List Payouts (for all affiliates) + register_rest_route($this->namespace, '/admin/affiliates/payouts', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_payouts'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Create Payout + register_rest_route($this->namespace, '/admin/affiliates/payouts', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'create_payout'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + public function check_permission() + { + return current_user_can('manage_woocommerce'); + } + + public function get_affiliates(WP_REST_Request $request) + { + global $wpdb; + $table = $wpdb->prefix . 'woonoow_affiliates'; + $affiliates = $wpdb->get_results("SELECT * FROM $table ORDER BY created_at DESC", ARRAY_A); + + // Add payable_balance to each affiliate + foreach ($affiliates as &$affiliate) { + $affiliate['payable_balance'] = (float) ($affiliate['total_earnings'] ?? 0) - (float) ($affiliate['paid_earnings'] ?? 0); + // Get user info + $user = get_userdata($affiliate['user_id']); + if ($user) { + $affiliate['user_email'] = $user->user_email; + $affiliate['user_name'] = $user->display_name; + } + } + + return rest_ensure_response($affiliates); + } + + public function get_affiliate_balance(WP_REST_Request $request) + { + global $wpdb; + $id = $request->get_param('id'); + $table = $wpdb->prefix . 'woonoow_affiliates'; + + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT id, user_id, referral_code, total_earnings, paid_earnings, total_referrals + FROM $table WHERE id = %d", + $id + )); + + if (!$affiliate) { + return new WP_REST_Response(['error' => 'Affiliate not found'], 404); + } + + $user = get_userdata($affiliate->user_id); + + return rest_ensure_response([ + 'id' => (int) $affiliate->id, + 'user_id' => (int) $affiliate->user_id, + 'user_name' => $user ? $user->display_name : 'Unknown', + 'user_email' => $user ? $user->user_email : '', + 'referral_code' => $affiliate->referral_code, + 'total_earnings' => (float) $affiliate->total_earnings, + 'paid_earnings' => (float) $affiliate->paid_earnings, + 'payable_balance' => (float) $affiliate->total_earnings - (float) $affiliate->paid_earnings, + 'total_referrals' => (int) $affiliate->total_referrals, + 'approved_referrals' => $this->get_approved_referral_count($affiliate->id), + 'pending_referrals' => $this->get_pending_referral_count($affiliate->id), + ]); + } + + private function get_approved_referral_count($affiliate_id) + { + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}woonoow_referrals WHERE affiliate_id = %d AND status = 'approved'", + $affiliate_id + )); + } + + private function get_pending_referral_count($affiliate_id) + { + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}woonoow_referrals WHERE affiliate_id = %d AND status = 'pending'", + $affiliate_id + )); + } + + public function approve_affiliate(WP_REST_Request $request) + { + global $wpdb; + $id = $request->get_param('id'); + $table = $wpdb->prefix . 'woonoow_affiliates'; + + $wpdb->update( + $table, + ['status' => 'active'], + ['id' => $id] + ); + + // Trigger email notification for approval + $affiliate = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id)); + if ($affiliate) { + $user = get_userdata($affiliate->user_id); + if ($user) { + do_action('woonoow/email/trigger', 'affiliate_application_approved', $user->user_email, [ + 'affiliate_name' => $user->display_name, + 'referral_code' => $affiliate->referral_code + ]); + } + } + + return rest_ensure_response(['success' => true]); + } + + public function update_affiliate(WP_REST_Request $request) + { + global $wpdb; + $id = $request->get_param('id'); + $table = $wpdb->prefix . 'woonoow_affiliates'; + + $custom_rate = $request->get_param('custom_commission_rate'); + + // If rate is empty string or not provided, clear custom rate + if ($custom_rate === '' || $custom_rate === null || $custom_rate === false) { + $data = ['custom_commission_rate' => null]; + } else { + $custom_rate = floatval($custom_rate); + if ($custom_rate < 0 || $custom_rate > 100) { + return new WP_REST_Response(['error' => 'Commission rate must be between 0 and 100'], 400); + } + $data = ['custom_commission_rate' => $custom_rate]; + } + + $result = $wpdb->update( + $table, + $data, + ['id' => $id] + ); + + if ($result === false) { + return new WP_REST_Response(['error' => 'Failed to update affiliate'], 500); + } + + return rest_ensure_response(['success' => true, 'custom_commission_rate' => $data['custom_commission_rate']]); + } + + public function get_referrals(WP_REST_Request $request) + { + global $wpdb; + $table = $wpdb->prefix . 'woonoow_referrals'; + + // Add filter support + $where = "1=1"; + + $affiliate_id = $request->get_param('affiliate_id'); + if ($affiliate_id) { + $where .= $wpdb->prepare(" AND affiliate_id = %d", $affiliate_id); + } + + $status = $request->get_param('status'); + if ($status) { + $where .= $wpdb->prepare(" AND status = %s", $status); + } + + $date_start = $request->get_param('date_start'); + if ($date_start) { + $where .= $wpdb->prepare(" AND created_at >= %s", $date_start . ' 00:00:00'); + } + + $date_end = $request->get_param('date_end'); + if ($date_end) { + $where .= $wpdb->prepare(" AND created_at <= %s", $date_end . ' 23:59:59'); + } + + $order_id = $request->get_param('order_id'); + if ($order_id) { + $where .= $wpdb->prepare(" AND order_id = %d", $order_id); + } + + $referrals = $wpdb->get_results("SELECT * FROM $table WHERE $where ORDER BY created_at DESC", ARRAY_A); + + // Enrich with affiliate info + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + foreach ($referrals as &$referral) { + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT a.*, u.display_name as affiliate_name, u.user_email as affiliate_email + FROM $affiliates_table a + LEFT JOIN $wpdb->users u ON a.user_id = u.ID + WHERE a.id = %d", + $referral['affiliate_id'] + )); + if ($affiliate) { + $referral['affiliate_name'] = $affiliate->affiliate_name; + $referral['affiliate_email'] = $affiliate->affiliate_email; + } + } + + return rest_ensure_response($referrals); + } + + public function get_payouts(WP_REST_Request $request) + { + global $wpdb; + $table = $wpdb->prefix . 'woonoow_affiliate_payouts'; + + $affiliate_id = $request->get_param('affiliate_id'); + $where = $affiliate_id ? $wpdb->prepare("WHERE affiliate_id = %d", $affiliate_id) : ""; + + $payouts = $wpdb->get_results("SELECT * FROM $table $where ORDER BY created_at DESC", ARRAY_A); + + // Enrich with affiliate info + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + foreach ($payouts as &$payout) { + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT a.*, u.display_name as affiliate_name, u.user_email as affiliate_email + FROM $affiliates_table a + LEFT JOIN $wpdb->users u ON a.user_id = u.ID + WHERE a.id = %d", + $payout['affiliate_id'] + )); + if ($affiliate) { + $payout['affiliate_name'] = $affiliate->affiliate_name; + $payout['affiliate_email'] = $affiliate->affiliate_email; + } + } + + return rest_ensure_response($payouts); + } + + public function create_payout(WP_REST_Request $request) + { + global $wpdb; + $payouts_table = $wpdb->prefix . 'woonoow_affiliate_payouts'; + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + + $affiliate_id = absint($request->get_param('affiliate_id')); + $amount = floatval($request->get_param('amount')); + $method = sanitize_text_field($request->get_param('method') ?: 'bank_transfer'); + $notes = ''; + + // Validate affiliate exists and get balance + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $affiliates_table WHERE id = %d", + $affiliate_id + )); + + if (!$affiliate) { + return new WP_REST_Response(['error' => 'Affiliate not found'], 404); + } + + $payable_balance = (float) $affiliate->total_earnings - (float) $affiliate->paid_earnings; + + if ($amount <= 0) { + return new WP_REST_Response(['error' => 'Amount must be greater than 0'], 400); + } + + if ($amount > $payable_balance) { + return new WP_REST_Response([ + 'error' => 'Amount exceeds payable balance', + 'payable_balance' => $payable_balance + ], 400); + } + + // Generate coupon for store_credit method + if ($method === 'store_credit') { + $user = get_userdata($affiliate->user_id); + if ($user) { + $coupon_code = 'CREDIT-' . strtoupper(wp_generate_password(8, false)); + $coupon = new \WC_Coupon(); + $coupon->set_code($coupon_code); + $coupon->set_discount_type('fixed_cart'); + $coupon->set_amount($amount); + $coupon->set_email_restrictions([$user->user_email]); + $coupon->set_usage_limit(1); + $coupon->set_description('Store Credit for Affiliate Payout'); + $coupon->save(); + + $notes = 'Generated Store Credit Coupon: ' . $coupon_code; + } + } + + // Create payout record + $wpdb->insert($payouts_table, [ + 'affiliate_id' => $affiliate_id, + 'amount' => $amount, + 'currency' => get_woocommerce_currency(), + 'method' => $method, + 'status' => 'completed', + 'notes' => $notes, + 'completed_at' => current_time('mysql') + ]); + + $payout_id = $wpdb->insert_id; + + // Update affiliate's paid_earnings + $wpdb->query($wpdb->prepare( + "UPDATE $affiliates_table SET paid_earnings = paid_earnings + %f WHERE id = %d", + $amount, + $affiliate_id + )); + + return rest_ensure_response([ + 'success' => true, + 'id' => $payout_id, + 'new_paid_earnings' => (float) $affiliate->paid_earnings + $amount, + 'new_payable_balance' => $payable_balance - $amount, + 'coupon_code' => $method === 'store_credit' ? $coupon_code : null + ]); + } +} diff --git a/includes/Api/Controllers/AffiliateCustomerController.php b/includes/Api/Controllers/AffiliateCustomerController.php new file mode 100644 index 0000000..5ffae2c --- /dev/null +++ b/includes/Api/Controllers/AffiliateCustomerController.php @@ -0,0 +1,273 @@ +namespace, '/account/affiliate', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_dashboard'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route($this->namespace, '/account/affiliate/apply', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'apply_affiliate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route($this->namespace, '/account/affiliate/referrals', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_referrals'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route($this->namespace, '/account/affiliate/payouts', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_payouts'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route($this->namespace, '/account/affiliate/payment-details', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'get_payment_details'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route($this->namespace, '/account/affiliate/payment-details', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'update_payment_details'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + public function check_permission() + { + return is_user_logged_in(); + } + + public function get_dashboard(WP_REST_Request $request) + { + global $wpdb; + $user_id = get_current_user_id(); + $table = $wpdb->prefix . 'woonoow_affiliates'; + + $affiliate = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE user_id = %d", $user_id), ARRAY_A); + + if (!$affiliate) { + return new \WP_Error('not_found', 'Affiliate profile not found', ['status' => 404]); + } + + // Get global default rate + $global_rate = (float) get_option('woonoow_affiliate_default_rate', 10); + + // Use custom rate if set, otherwise global + $effective_rate = !empty($affiliate['custom_commission_rate']) + ? (float) $affiliate['custom_commission_rate'] + : $global_rate; + + $affiliate['global_commission_rate'] = $global_rate; + $affiliate['commission_rate'] = $effective_rate; + + return rest_ensure_response($affiliate); + } + + public function apply_affiliate(WP_REST_Request $request) + { + global $wpdb; + $user_id = get_current_user_id(); + $table = $wpdb->prefix . 'woonoow_affiliates'; + + // Check if already applied + $exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM $table WHERE user_id = %d", $user_id)); + if ($exists) { + return new \WP_Error('exists', 'You have already applied.', ['status' => 400]); + } + + // Generate simple code + $user = wp_get_current_user(); + $referral_code = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login)) . wp_generate_password(4, false); + + $auto_approve = get_option('woonoow_affiliate_auto_approve', false); + $status = $auto_approve ? 'active' : 'pending'; + + $wpdb->insert($table, [ + 'user_id' => $user_id, + 'referral_code' => $referral_code, + 'commission_rate' => get_option('woonoow_affiliate_default_rate', 10), // 10% default + 'status' => $status + ]); + + // Trigger email notification for admin + $admin_email = get_option('admin_email'); + do_action('woonoow/email/trigger', 'affiliate_application_received', $admin_email, [ + 'affiliate_name' => $user->display_name, + 'customer_email' => $user->user_email + ]); + + if ($auto_approve) { + do_action('woonoow/email/trigger', 'affiliate_application_approved', $user->user_email, [ + 'affiliate_name' => $user->display_name, + 'customer_email' => $user->user_email, + 'referral_code' => $referral_code + ]); + } + + return rest_ensure_response(['success' => true, 'status' => $status, 'referral_code' => $referral_code]); + } + + public function get_referrals(WP_REST_Request $request) + { + global $wpdb; + $user_id = get_current_user_id(); + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + + $affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliates_table WHERE user_id = %d", $user_id)); + if (!$affiliate) { + return rest_ensure_response([]); + } + + $referrals = $wpdb->get_results($wpdb->prepare( + "SELECT r.*, + COALESCE(NULLIF(r.cancelled_reason, ''), NULL) as cancelled_reason, + COALESCE(r.approved_at, r.created_at) as approved_at + FROM $referrals_table r + WHERE r.affiliate_id = %d + ORDER BY r.created_at DESC", + $affiliate->id + ), ARRAY_A); + return rest_ensure_response($referrals); + } + + public function get_payouts(WP_REST_Request $request) + { + global $wpdb; + $user_id = get_current_user_id(); + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + $payouts_table = $wpdb->prefix . 'woonoow_affiliate_payouts'; + + $affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliates_table WHERE user_id = %d", $user_id)); + if (!$affiliate) { + return rest_ensure_response([]); + } + + $payouts = $wpdb->get_results($wpdb->prepare( + "SELECT id, amount, currency, method, status, notes, created_at, completed_at + FROM $payouts_table + WHERE affiliate_id = %d + ORDER BY created_at DESC", + $affiliate->id + ), ARRAY_A); + + return rest_ensure_response($payouts); + } + + public function get_payment_details(WP_REST_Request $request) + { + global $wpdb; + $user_id = get_current_user_id(); + $table = $wpdb->prefix . 'woonoow_affiliates'; + + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT payment_method, payment_details FROM $table WHERE user_id = %d", + $user_id + )); + + if (!$affiliate) { + return new \WP_Error('not_found', 'Affiliate not found', ['status' => 404]); + } + + $payment_details = $affiliate->payment_details ? json_decode($affiliate->payment_details, true) : []; + + return rest_ensure_response([ + 'payment_method' => $affiliate->payment_method ?: '', + 'payment_details' => $payment_details ?: new \stdClass() + ]); + } + + public function update_payment_details(WP_REST_Request $request) + { + global $wpdb; + $user_id = get_current_user_id(); + $table = $wpdb->prefix . 'woonoow_affiliates'; + + // Get allowed payment methods from settings + $settings = get_option('woonoow_module_affiliate_settings', []); + $allowed_methods = $settings['woonoow_affiliate_payment_methods'] ?? ['bank_transfer']; + + $payment_method = sanitize_text_field($request->get_param('payment_method') ?: ''); + $payment_details_raw = $request->get_param('payment_details') ?: []; + + // Validate payment method is allowed + if (!in_array($payment_method, $allowed_methods)) { + return new \WP_Error( + 'invalid_payment_method', + 'This payment method is not available. Please contact admin.', + ['status' => 400] + ); + } + + // Sanitize payment details based on method + $sanitized_details = self::sanitize_payment_details($payment_method, $payment_details_raw); + + $result = $wpdb->update( + $table, + [ + 'payment_method' => $payment_method, + 'payment_details' => json_encode($sanitized_details) + ], + ['user_id' => $user_id] + ); + + if ($result === false) { + return new \WP_Error('update_failed', 'Failed to update payment details', ['status' => 500]); + } + + return rest_ensure_response([ + 'success' => true, + 'payment_method' => $payment_method, + 'payment_details' => $sanitized_details + ]); + } + + /** + * Sanitize payment details based on payment method + */ + private static function sanitize_payment_details($method, $details) + { + $sanitized = []; + + switch ($method) { + case 'bank_transfer': + $sanitized['bank_name'] = sanitize_text_field($details['bank_name'] ?? ''); + $sanitized['account_number'] = sanitize_text_field($details['account_number'] ?? ''); + $sanitized['account_holder'] = sanitize_text_field($details['account_holder'] ?? ''); + $sanitized['swift_code'] = sanitize_text_field($details['swift_code'] ?? ''); + $sanitized['bank_address'] = sanitize_text_field($details['bank_address'] ?? ''); + break; + + case 'paypal': + case 'wise': + case 'skrill': + case 'payoneer': + $sanitized['email'] = sanitize_email($details['email'] ?? ''); + $sanitized['name'] = sanitize_text_field($details['name'] ?? ''); + break; + + case 'custom': + $sanitized['notes'] = sanitize_textarea_field($details['notes'] ?? ''); + break; + } + + return $sanitized; + } +} diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index 189cdb0..f8331bf 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -191,6 +191,14 @@ class Routes $onboarding_controller = new OnboardingController(); $onboarding_controller->register_routes(); + // Affiliate Admin controller + $affiliate_admin_controller = new \WooNooW\Api\Controllers\AffiliateAdminController(); + $affiliate_admin_controller->register_routes(); + + // Affiliate Customer controller + $affiliate_customer_controller = new \WooNooW\Api\Controllers\AffiliateCustomerController(); + $affiliate_customer_controller->register_routes(); + // Frontend controllers (customer-facing) ShopController::register_routes(); FrontendCartController::register_routes(); diff --git a/includes/Core/ModuleRegistry.php b/includes/Core/ModuleRegistry.php index e57858d..67982ba 100644 --- a/includes/Core/ModuleRegistry.php +++ b/includes/Core/ModuleRegistry.php @@ -57,6 +57,7 @@ class ModuleRegistry 'category' => 'marketing', 'icon' => 'users', 'default_enabled' => false, + 'has_settings' => true, 'features' => [ __('Referral tracking', 'woonoow'), __('Commission management', 'woonoow'), diff --git a/includes/Modules/Affiliate/AffiliateLifecycle.php b/includes/Modules/Affiliate/AffiliateLifecycle.php new file mode 100644 index 0000000..073fc38 --- /dev/null +++ b/includes/Modules/Affiliate/AffiliateLifecycle.php @@ -0,0 +1,252 @@ +prefix . 'woonoow_referrals'; + + // Get the order to determine the reason + $order = wc_get_order($order_id); + $reason = 'order_cancelled'; + if ($order) { + $status = $order->get_status(); + $reason = 'order_' . $status; + } + + // Find pending or approved referral + $referral = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $referrals_table WHERE order_id = %d AND status IN ('pending', 'approved')", + $order_id + )); + + if ($referral) { + // If was already approved, this is a clawback - decrease affiliate earnings + if ($referral->status === 'approved') { + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + $wpdb->query($wpdb->prepare( + "UPDATE $affiliates_table SET total_earnings = total_earnings - %f WHERE id = %d", + $referral->commission_amount, + $referral->affiliate_id + )); + } + + // Update status to rejected with reason + $wpdb->update( + $referrals_table, + [ + 'status' => 'rejected', + 'cancelled_reason' => $reason, + 'cancelled_at' => current_time('mysql') + ], + ['id' => $referral->id] + ); + + // Unschedule action if action scheduler exists + if (function_exists('as_unschedule_all_actions')) { + as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate'); + } + } + } + + /** + * Handle order completion - check if referral should be approved + */ + public static function handle_order_completed($order_id) + { + global $wpdb; + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + + // Find pending referral for this order + $referral = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $referrals_table WHERE order_id = %d AND status = 'pending'", + $order_id + )); + + if (!$referral) return; + + // Check if holding period is 0 (immediate approval on completion) + $holding_period = (int) get_option('woonoow_affiliate_holding_period', 14); + + if ($holding_period === 0) { + // Immediate approval + self::auto_approve_referral($referral->id); + } else { + // If order was completed BEFORE the scheduled action time, approve now + // Otherwise, the scheduled action will approve later + // Check if the scheduled action time has already passed + $approval_time = strtotime($referral->created_at) + ($holding_period * DAY_IN_SECONDS); + + if (time() >= $approval_time) { + self::auto_approve_referral($referral->id); + } + // If not, the scheduled Action Scheduler job will handle it + } + + // Cancel the scheduled auto-approval since we're handling it now + if (function_exists('as_unschedule_all_actions')) { + as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate'); + } + } + + /** + * Handle HPOS order status change + */ + public static function handle_hpos_status_changed($order_id, $from_status, $to_status, $order) + { + if ($to_status === 'completed') { + self::handle_order_completed($order_id); + } + } + + /** + * Handle order update (HPOS compatible) + */ + public static function handle_order_updated($order_id, $order) + { + if (!$order) return; + + // Check if order was just completed + $status = $order->get_status(); + if ($status === 'completed') { + self::handle_order_completed($order_id); + } + } + + /** + * Handle order deletion (permanent delete or trash) + */ + public static function handle_order_deleted($order_id) + { + // Check if this is a WooCommerce order + $order = wc_get_order($order_id); + if (!$order) return; + + // Only process shop orders + $post_type = get_post_type($order_id); + if ($post_type !== 'shop_order') return; + + global $wpdb; + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + + // Find any referral for this order + $referral = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $referrals_table WHERE order_id = %d", + $order_id + )); + + if ($referral) { + // If was already approved, this is a clawback - decrease affiliate earnings + if ($referral->status === 'approved') { + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + $wpdb->query($wpdb->prepare( + "UPDATE $affiliates_table SET total_earnings = total_earnings - %f WHERE id = %d", + $referral->commission_amount, + $referral->affiliate_id + )); + } + + // Mark as rejected with "order_deleted" reason + $wpdb->update( + $referrals_table, + [ + 'status' => 'rejected', + 'cancelled_reason' => 'order_deleted', + 'cancelled_at' => current_time('mysql') + ], + ['id' => $referral->id] + ); + + // Unschedule any pending approval action + if (function_exists('as_unschedule_all_actions')) { + as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate'); + } + } + } + + /** + * Action Scheduler callback for auto-approving a referral after the holding period + */ + public static function auto_approve_referral($referral_id) + { + global $wpdb; + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + $affiliates_table = $wpdb->prefix . 'woonoow_affiliates'; + + // Find pending referral + $referral = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $referrals_table WHERE id = %d AND status = 'pending'", + $referral_id + )); + + if (!$referral) return; // Already processed or deleted + + // Double check order status + $order = wc_get_order($referral->order_id); + if (!$order || in_array($order->get_status(), ['refunded', 'cancelled', 'failed'])) { + self::handle_order_cancelled($referral->order_id); + return; + } + + // Approve referral + $wpdb->update( + $referrals_table, + [ + 'status' => 'approved', + 'approved_at' => current_time('mysql') + ], + ['id' => $referral_id] + ); + + // Update Affiliate totals + $wpdb->query($wpdb->prepare( + "UPDATE $affiliates_table SET + total_referrals = total_referrals + 1, + total_earnings = total_earnings + %f + WHERE id = %d", + $referral->commission_amount, + $referral->affiliate_id + )); + } +} diff --git a/includes/Modules/Affiliate/AffiliateManager.php b/includes/Modules/Affiliate/AffiliateManager.php new file mode 100644 index 0000000..a231b18 --- /dev/null +++ b/includes/Modules/Affiliate/AffiliateManager.php @@ -0,0 +1,156 @@ +get_charset_collate(); + + $affiliates_table = $wpdb->prefix . self::$affiliates_table; + $referrals_table = $wpdb->prefix . self::$referrals_table; + $payouts_table = $wpdb->prefix . self::$payouts_table; + + require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); + + // Affiliates Table + $sql_affiliates = "CREATE TABLE $affiliates_table ( + id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id bigint(20) UNSIGNED NOT NULL, + referral_code varchar(50) NOT NULL, + coupon_id bigint(20) UNSIGNED DEFAULT NULL, + commission_rate decimal(10,2) NOT NULL DEFAULT '0.00', + custom_commission_rate decimal(10,2) DEFAULT NULL, + status varchar(20) NOT NULL DEFAULT 'pending', + total_referrals int(11) NOT NULL DEFAULT 0, + total_earnings decimal(19,4) NOT NULL DEFAULT '0.0000', + paid_earnings decimal(19,4) NOT NULL DEFAULT '0.0000', + payment_bank_name varchar(100) DEFAULT NULL, + payment_bank_account varchar(100) DEFAULT NULL, + payment_email varchar(100) DEFAULT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY user_id (user_id), + UNIQUE KEY referral_code (referral_code), + KEY status (status) + ) $charset_collate;"; + + dbDelta($sql_affiliates); + + // Add custom_commission_rate column if it doesn't exist (for existing installations) + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = 'custom_commission_rate'") == 0) { + $wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN custom_commission_rate decimal(10,2) DEFAULT NULL AFTER commission_rate"); + } + + // Add payment details columns if they don't exist (for existing installations) + $payment_columns = ['payment_bank_name', 'payment_bank_account', 'payment_email']; + foreach ($payment_columns as $col) { + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = '$col'") == 0) { + $col_def = $col === 'payment_email' ? "varchar(100) DEFAULT NULL" : "varchar(100) DEFAULT NULL"; + $wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN $col $col_def AFTER paid_earnings"); + } + } + + // Add flexible payment_details JSON column + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = 'payment_details'") == 0) { + $wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN payment_details longtext DEFAULT NULL AFTER payment_email"); + } + + // Add payment_method column + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = 'payment_method'") == 0) { + $wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN payment_method varchar(50) DEFAULT NULL AFTER payment_details"); + } + + // Referrals Table + $sql_referrals = "CREATE TABLE $referrals_table ( + id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + affiliate_id bigint(20) UNSIGNED NOT NULL, + order_id bigint(20) UNSIGNED NOT NULL, + customer_id bigint(20) UNSIGNED DEFAULT NULL, + commission_amount decimal(19,4) NOT NULL DEFAULT '0.0000', + currency varchar(10) NOT NULL DEFAULT 'USD', + status varchar(20) NOT NULL DEFAULT 'pending', + cancelled_reason varchar(100) DEFAULT NULL, + cancelled_at datetime DEFAULT NULL, + utm_source varchar(100) DEFAULT NULL, + utm_medium varchar(100) DEFAULT NULL, + utm_campaign varchar(255) DEFAULT NULL, + utm_content varchar(255) DEFAULT NULL, + utm_term varchar(255) DEFAULT NULL, + referrer_url varchar(500) DEFAULT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + approved_at datetime DEFAULT NULL, + paid_at datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY affiliate_id (affiliate_id), + KEY order_id (order_id), + KEY status (status) + ) $charset_collate;"; + + dbDelta($sql_referrals); + + // Add cancelled columns if they don't exist (for existing installations) + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = 'cancelled_reason'") == 0) { + $wpdb->query("ALTER TABLE $referrals_table ADD COLUMN cancelled_reason varchar(100) DEFAULT NULL AFTER status"); + } + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = 'cancelled_at'") == 0) { + $wpdb->query("ALTER TABLE $referrals_table ADD COLUMN cancelled_at datetime DEFAULT NULL AFTER cancelled_reason"); + } + + // Add UTM columns if they don't exist (for existing installations) + $utm_columns = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'referrer_url']; + foreach ($utm_columns as $col) { + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = '$col'") == 0) { + $col_def = $col === 'referrer_url' ? "varchar(500) DEFAULT NULL" : ($col === 'utm_campaign' || $col === 'utm_content' || $col === 'utm_term' ? "varchar(255) DEFAULT NULL" : "varchar(100) DEFAULT NULL"); + $after_col = $col === 'utm_source' ? 'cancelled_at' : ($col === 'utm_medium' ? 'utm_source' : ($col === 'utm_campaign' ? 'utm_medium' : ($col === 'utm_content' ? 'utm_campaign' : ($col === 'utm_term' ? 'utm_content' : 'utm_term')))); + $wpdb->query("ALTER TABLE $referrals_table ADD COLUMN $col $col_def AFTER cancelled_at"); + } + } + + // Payouts Table + $sql_payouts = "CREATE TABLE $payouts_table ( + id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + affiliate_id bigint(20) UNSIGNED NOT NULL, + amount decimal(19,4) NOT NULL, + currency varchar(10) NOT NULL DEFAULT 'USD', + method varchar(50) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'pending', + notes text DEFAULT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY affiliate_id (affiliate_id), + KEY status (status) + ) $charset_collate;"; + + dbDelta($sql_payouts); + } +} diff --git a/includes/Modules/Affiliate/AffiliateModule.php b/includes/Modules/Affiliate/AffiliateModule.php new file mode 100644 index 0000000..4776fd0 --- /dev/null +++ b/includes/Modules/Affiliate/AffiliateModule.php @@ -0,0 +1,136 @@ +prefix . 'woonoow_affiliates'; + + // Check if table exists + if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) { + AffiliateManager::create_tables(); + } else { + // Run migrations for existing tables + self::migrate_existing_tables(); + } + } + + /** + * Migrate existing tables to add missing columns + */ + private static function migrate_existing_tables() + { + global $wpdb; + $table = $wpdb->prefix . 'woonoow_affiliates'; + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + + // Add custom_commission_rate column if missing + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = 'custom_commission_rate'") == 0) { + $wpdb->query("ALTER TABLE $table ADD COLUMN custom_commission_rate decimal(10,2) DEFAULT NULL AFTER commission_rate"); + } + + // Add payment detail columns if missing + $payment_columns = ['payment_bank_name', 'payment_bank_account', 'payment_email']; + foreach ($payment_columns as $col) { + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = '$col'") == 0) { + $wpdb->query("ALTER TABLE $table ADD COLUMN $col varchar(100) DEFAULT NULL AFTER paid_earnings"); + } + } + + // Add flexible payment_details and payment_method columns + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = 'payment_details'") == 0) { + $wpdb->query("ALTER TABLE $table ADD COLUMN payment_details longtext DEFAULT NULL AFTER payment_email"); + } + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = 'payment_method'") == 0) { + $wpdb->query("ALTER TABLE $table ADD COLUMN payment_method varchar(50) DEFAULT NULL AFTER payment_details"); + } + + // Add referral attribution columns if missing. + if ($wpdb->get_var("SHOW TABLES LIKE '$referrals_table'") === $referrals_table) { + $referral_columns = [ + 'cancelled_reason' => ['definition' => 'varchar(100) DEFAULT NULL', 'after' => 'status'], + 'cancelled_at' => ['definition' => 'datetime DEFAULT NULL', 'after' => 'cancelled_reason'], + 'utm_source' => ['definition' => 'varchar(100) DEFAULT NULL', 'after' => 'cancelled_at'], + 'utm_medium' => ['definition' => 'varchar(100) DEFAULT NULL', 'after' => 'utm_source'], + 'utm_campaign' => ['definition' => 'varchar(255) DEFAULT NULL', 'after' => 'utm_medium'], + 'utm_content' => ['definition' => 'varchar(255) DEFAULT NULL', 'after' => 'utm_campaign'], + 'utm_term' => ['definition' => 'varchar(255) DEFAULT NULL', 'after' => 'utm_content'], + 'referrer_url' => ['definition' => 'varchar(500) DEFAULT NULL', 'after' => 'utm_term'], + ]; + + foreach ($referral_columns as $column => $schema) { + if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = '$column'") == 0) { + $wpdb->query("ALTER TABLE $referrals_table ADD COLUMN $column {$schema['definition']} AFTER {$schema['after']}"); + } + } + } + } + + /** + * Handle module enable + */ + public static function on_module_enabled($module_id) + { + if ($module_id === 'affiliate') { + AffiliateManager::create_tables(); + } + } + + /** + * Run migrations (called on admin_init) + */ + public static function run_migrations() + { + // Only run once per session (check transient) + if (get_transient('woonoow_affiliate_migrated')) { + return; + } + + self::migrate_existing_tables(); + set_transient('woonoow_affiliate_migrated', true, DAY_IN_SECONDS); + } +} diff --git a/includes/Modules/Affiliate/AffiliateSettings.php b/includes/Modules/Affiliate/AffiliateSettings.php new file mode 100644 index 0000000..0bc9fae --- /dev/null +++ b/includes/Modules/Affiliate/AffiliateSettings.php @@ -0,0 +1,71 @@ + [ + 'type' => 'number', + 'label' => __('Default Commission Rate (%)', 'woonoow'), + 'description' => __('The default commission rate percentage for affiliates.', 'woonoow'), + 'placeholder' => '10', + 'default' => 10, + 'min' => 0, + 'max' => 100, + ], + 'woonoow_affiliate_holding_period' => [ + 'type' => 'number', + 'label' => __('Holding Period (Days)', 'woonoow'), + 'description' => __('Number of days before a referral becomes eligible for payout (e.g. to account for refunds). Set to 0 for immediate approval on order completion.', 'woonoow'), + 'placeholder' => '14', + 'default' => 14, + 'min' => 0, + ], + 'woonoow_affiliate_payment_methods' => [ + 'type' => 'multiselect', + 'label' => __('Available Payment Methods', 'woonoow'), + 'description' => __('Select which payment methods affiliates can use to receive payouts.', 'woonoow'), + 'options' => [ + 'bank_transfer' => __('Bank Transfer', 'woonoow'), + 'paypal' => __('PayPal', 'woonoow'), + 'wise' => __('Wise', 'woonoow'), + 'skrill' => __('Skrill', 'woonoow'), + 'payoneer' => __('Payoneer', 'woonoow'), + 'custom' => __('Custom (Notes)', 'woonoow'), + ], + 'default' => ['bank_transfer'], + ], + 'woonoow_affiliate_auto_approve' => [ + 'type' => 'toggle', + 'label' => __('Auto-Approve Affiliates', 'woonoow'), + 'description' => __('Automatically approve new affiliate applications.', 'woonoow'), + 'default' => false, + ], + 'woonoow_affiliate_allow_self_referral' => [ + 'type' => 'toggle', + 'label' => __('Allow Self-Referrals', 'woonoow'), + 'description' => __('Allow affiliates to earn commission when their own user account places an order.', 'woonoow'), + 'default' => false, + ], + ]; + + return $schemas; + } +} diff --git a/includes/Modules/Affiliate/AffiliateTracker.php b/includes/Modules/Affiliate/AffiliateTracker.php new file mode 100644 index 0000000..95e58d3 --- /dev/null +++ b/includes/Modules/Affiliate/AffiliateTracker.php @@ -0,0 +1,416 @@ +get_id()); + } + } + + /** + * Record referral from block checkout (Store API) + */ + public static function record_referral_block($order) + { + if (is_numeric($order)) { + $order = wc_get_order($order); + } + if ($order) { + self::process_order_for_referral($order->get_id()); + } + } + + /** + * Record referral from woocommerce_new_order hook + */ + public static function record_referral_new_order($order_id, $order) + { + error_log('[AffiliateTracker] woocommerce_new_order hook fired for order: ' . $order_id); + self::process_order_for_referral($order_id); + } + + /** + * Capture ?ref=CODE from URL and set cookie + * Also captures UTM parameters for referral attribution + */ + public static function capture_referral_link() + { + error_log('[AffiliateTracker] capture_referral_link called, ref=' . ($_GET['ref'] ?? 'none')); + + $options = [ + 'expires' => time() + (self::COOKIE_EXPIRES * DAY_IN_SECONDS), + 'path' => '/', + 'secure' => is_ssl(), + 'samesite' => 'Lax' + ]; + + // Capture referral code + if (isset($_GET['ref']) && !empty($_GET['ref'])) { + $referral_code = sanitize_text_field($_GET['ref']); + $result = setcookie(self::COOKIE_NAME, $referral_code, $options); + $_COOKIE[self::COOKIE_NAME] = $referral_code; + error_log('[AffiliateTracker] Set woonoow_ref cookie: ' . $referral_code . ', result=' . ($result ? 'true' : 'false')); + error_log('[AffiliateTracker] Cookie options: ' . json_encode($options)); + } else { + // Check if cookie already exists from previous visit + if (isset($_COOKIE[self::COOKIE_NAME])) { + error_log('[AffiliateTracker] No ref param, but existing cookie: ' . $_COOKIE[self::COOKIE_NAME]); + } else { + error_log('[AffiliateTracker] No ref param and no existing cookie'); + } + } + + // Capture UTM parameters + $utm_params = []; + $utm_keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']; + + foreach ($utm_keys as $key) { + if (isset($_GET[$key]) && !empty($_GET[$key])) { + $utm_params[$key] = sanitize_text_field($_GET[$key]); + } + } + + // Capture referrer URL + if (isset($_SERVER['HTTP_REFERER']) && !empty($_SERVER['HTTP_REFERER'])) { + $utm_params['referrer_url'] = esc_url_raw($_SERVER['HTTP_REFERER']); + } + + // Store UTM params in cookie if any captured + if (!empty($utm_params)) { + $utm_json = json_encode($utm_params); + setcookie(self::COOKIE_NAME . '_utm', $utm_json, $options); + $_COOKIE[self::COOKIE_NAME . '_utm'] = $utm_json; + error_log('[AffiliateTracker] Set woonoow_ref_utm cookie'); + } + } + + /** + * Get active affiliate for an order + * Checks both coupons and cookies. + */ + public static function get_affiliate_for_order($order) + { + global $wpdb; + $table = $wpdb->prefix . 'woonoow_affiliates'; + + // 1. Check if a coupon is used that maps to an affiliate + $used_coupons = $order->get_coupon_codes(); + if (!empty($used_coupons)) { + foreach ($used_coupons as $code) { + $coupon = new \WC_Coupon($code); + if ($coupon->get_id()) { + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $table WHERE coupon_id = %d AND status = 'active'", + $coupon->get_id() + )); + if ($affiliate) return $affiliate; + } + } + } + + // 2. Check referral code stored on the order by SPA checkout + $order_referral_code = $order->get_meta('_woonoow_referral_code'); + if (!empty($order_referral_code)) { + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $table WHERE referral_code = %s AND status = 'active'", + sanitize_text_field($order_referral_code) + )); + if ($affiliate) return $affiliate; + } + + // 3. Fallback to cookie + if (isset($_COOKIE[self::COOKIE_NAME])) { + $referral_code = sanitize_text_field($_COOKIE[self::COOKIE_NAME]); + $affiliate = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $table WHERE referral_code = %s AND status = 'active'", + $referral_code + )); + if ($affiliate) return $affiliate; + } + + return null; + } + + /** + * Get referral record for a specific order + * Used by admin API to display affiliate info on order details + */ + public static function get_referral_for_order($order_id) + { + global $wpdb; + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + + return $wpdb->get_row($wpdb->prepare( + "SELECT * FROM $referrals_table WHERE order_id = %d", + $order_id + )); + } + + /** + * Record referral when order is processed (Checkout) + */ + public static function record_referral($order_id, $posted_data, $order) + { + self::process_order_for_referral($order_id); + } + + /** + * Record referral when order is created via Admin + */ + public static function record_referral_admin($post_id) + { + $order = wc_get_order($post_id); + if ($order) { + self::process_order_for_referral($post_id); + } + } + + /** + * Process order and create pending referral record + */ + private static function process_order_for_referral($order_id) + { + error_log('[AffiliateTracker] process_order_for_referral() called for order: ' . $order_id); + + global $wpdb; + $order = wc_get_order($order_id); + if (!$order) { + error_log('[AffiliateTracker] Order not found: ' . $order_id); + return; + } + + // Check if referral already exists for this order + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + $exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM $referrals_table WHERE order_id = %d", $order_id)); + if ($exists) { + error_log('[AffiliateTracker] Referral already exists for order: ' . $order_id); + return; + } + + $affiliate = self::get_affiliate_for_order($order); + if (!$affiliate) { + error_log('[AffiliateTracker] No affiliate found for order: ' . $order_id); + return; + } + + error_log('[AffiliateTracker] Found affiliate: ' . $affiliate->referral_code . ' (rate: ' . ($affiliate->custom_commission_rate ?? 'null') . ')'); + + // Prevent self-referrals (unless admin override is enabled) + $order_user_id = $order->get_user_id(); + if ((int)$order_user_id === (int)$affiliate->user_id) { + error_log('[AffiliateTracker] Self-referral detected, blocking'); + $allow_self_referral = get_option('woonoow_affiliate_allow_self_referral', false); + if (!$allow_self_referral) return; + } + + // Calculate commission + // Priority: Product rate > Affiliate custom rate > Global default + $global_default_rate = (float) get_option('woonoow_affiliate_default_rate', 10); + $affiliate_custom_rate = !empty($affiliate->custom_commission_rate) ? (float) $affiliate->custom_commission_rate : null; + + // Get order items - try WC method first, fallback to direct DB query + $items = self::get_order_items($order_id); + error_log('[AffiliateTracker] Order items: ' . count($items)); + $total_commission = 0; + + if (!empty($items)) { + foreach ($items as $item) { + $product_id = (int) $item['product_id']; + $line_total = (float) $item['line_total']; + + // Check for product-specific affiliate rate + $product_rate = get_post_meta($product_id, '_woonoow_affiliate_commission_rate', true); + $product_enabled = get_post_meta($product_id, '_woonoow_affiliate_enabled', true) === 'yes'; + error_log('[AffiliateTracker] Product ' . $product_id . ': enabled=' . ($product_enabled ? 'yes' : 'no') . ', rate=' . $product_rate . ', line_total=' . $line_total); + + // Determine rate for this item (only if product affiliate is enabled) + $item_rate = null; + if ($product_enabled && $product_rate !== '') { + $item_rate = (float) $product_rate; + error_log('[AffiliateTracker] Using product rate: ' . $item_rate); + } elseif ($affiliate_custom_rate !== null) { + $item_rate = $affiliate_custom_rate; + error_log('[AffiliateTracker] Using affiliate rate: ' . $item_rate); + } else { + $item_rate = $global_default_rate; + error_log('[AffiliateTracker] Using global rate: ' . $item_rate); + } + + if ($item_rate > 0 && $line_total > 0) { + $total_commission += ($line_total * $item_rate) / 100; + error_log('[AffiliateTracker] Added: ' . ($line_total * $item_rate / 100)); + } + } + } else { + // Fallback to subtotal if no items (legacy) + $subtotal = (float)$order->get_subtotal(); + $commission_rate = $affiliate_custom_rate ?? $global_default_rate; + if ($commission_rate <= 0) return; + $total_commission = ($subtotal * $commission_rate) / 100; + } + + if ($total_commission <= 0) { + error_log('[AffiliateTracker] Commission is 0, skipping'); + return; + } + + error_log('[AffiliateTracker] Total commission: ' . $total_commission); + + // Get UTM parameters from cookie + $utm_data = []; + if (isset($_COOKIE[self::COOKIE_NAME . '_utm'])) { + $utm_data = json_decode($_COOKIE[self::COOKIE_NAME . '_utm'], true) ?: []; + } + + // Insert pending referral + $insert_data = [ + 'affiliate_id' => $affiliate->id, + 'order_id' => $order_id, + 'customer_id' => $order->get_user_id() ?: null, + 'commission_amount' => $total_commission, + 'currency' => $order->get_currency(), + 'status' => 'pending' + ]; + + // Add UTM data if available + if (!empty($utm_data)) { + foreach (['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'referrer_url'] as $utm_key) { + if (isset($utm_data[$utm_key])) { + $insert_data[$utm_key] = $utm_data[$utm_key]; + } + } + } + + $inserted = $wpdb->insert($referrals_table, $insert_data); + if (!$inserted) { + error_log('[AffiliateTracker] Failed to insert referral for order ' . $order_id . ': ' . $wpdb->last_error); + return; + } + + $referral_id = $wpdb->insert_id; + + // Fire event for notifications + do_action('woonoow/affiliate/referral_received', $referral_id, $affiliate, $order); + + // Trigger email notification to affiliate + $user = get_userdata($affiliate->user_id); + if ($user) { + do_action('woonoow/email/trigger', 'affiliate_new_referral', $user->user_email, [ + 'affiliate_name' => $user->display_name, + 'commission_amount' => $total_commission, + 'currency' => $order->get_currency(), + 'order_number' => $order->get_order_number() + ]); + } + + // Schedule auto-approval (e.g., 14 days) via Action Scheduler + if (function_exists('as_schedule_single_action')) { + $approval_days = get_option('woonoow_affiliate_holding_period', 14); + $timestamp = time() + ($approval_days * DAY_IN_SECONDS); + as_schedule_single_action($timestamp, 'woonoow_approve_referral', ['referral_id' => $referral_id], 'woonoow_affiliate'); + } + } + + /** + * Get order items - direct DB query for HPOS compatibility + */ + private static function get_order_items($order_id) + { + global $wpdb; + + // First try HPOS order_product_lookup table (more reliable with HPOS) + // Force fresh query by using wpdb::query with direct table + $order_id_int = (int) $order_id; + $items = $wpdb->get_results($wpdb->prepare( + "SELECT product_id, product_qty, product_net_revenue as line_total + FROM {$wpdb->prefix}wc_order_product_lookup + WHERE order_id = %d", + $order_id_int + ), ARRAY_A); + + error_log('[AffiliateTracker] get_order_items() HPOS query returned: ' . count($items) . ' items for order ' . $order_id_int); + + // If no results from HPOS table, try legacy order_items table + if (empty($items)) { + $items = $wpdb->get_results($wpdb->prepare( + "SELECT oim_product.meta_value as product_id, oim_total.meta_value as line_total + FROM {$wpdb->prefix}woocommerce_order_items oi + LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta oim_product ON oi.order_item_id = oim_product.order_item_id AND oim_product.meta_key = '_product_id' + LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta oim_total ON oi.order_item_id = oim_total.order_item_id AND oim_total.meta_key = '_line_total' + WHERE oi.order_id = %d AND oi.order_item_type = 'line_item'", + $order_id + ), ARRAY_A); + } + + // Convert to standard format + $result = []; + foreach ($items as $item) { + $product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0; + if ($product_id > 0) { + $result[] = [ + 'product_id' => $product_id, + 'line_total' => (float) ($item['line_total'] ?? 0) + ]; + } + } + return $result; + } +} diff --git a/woonoow.php b/woonoow.php index 0235af1..4d91570 100644 --- a/woonoow.php +++ b/woonoow.php @@ -45,6 +45,7 @@ add_action('plugins_loaded', function () { WooNooW\Modules\Licensing\LicensingModule::init(); WooNooW\Modules\Subscription\SubscriptionModule::init(); WooNooW\Modules\Software\SoftwareModule::init(); + WooNooW\Modules\Affiliate\AffiliateModule::init(); }); // Activation/Deactivation hooks