From fec786daa6ab3cfeb99d5ca5385245a2b10112f1 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Tue, 2 Jun 2026 00:37:20 +0700 Subject: [PATCH] Affiliate module: fix referral approval lifecycle and settings reads --- .../routes/Marketing/Affiliates/Referrals.tsx | 2 +- .../src/pages/Account/AffiliateDashboard.tsx | 59 +++-- .../src/pages/Account/AffiliateReferrals.tsx | 206 ++++++++++++++++++ customer-spa/src/pages/Account/index.tsx | 2 + .../AffiliateCustomerController.php | 62 +++++- .../Modules/Affiliate/AffiliateLifecycle.php | 23 +- .../Modules/Affiliate/AffiliateSettings.php | 24 ++ .../Modules/Affiliate/AffiliateTracker.php | 2 +- 8 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 customer-spa/src/pages/Account/AffiliateReferrals.tsx diff --git a/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx b/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx index 70bdf4d..1786f52 100644 --- a/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx +++ b/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx @@ -135,7 +135,7 @@ export default function AffiliatesReferrals() { }; // Stats - const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || 0), 0); + const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || '0'), 0); const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length; const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length; diff --git a/customer-spa/src/pages/Account/AffiliateDashboard.tsx b/customer-spa/src/pages/Account/AffiliateDashboard.tsx index 9a72878..3db870f 100644 --- a/customer-spa/src/pages/Account/AffiliateDashboard.tsx +++ b/customer-spa/src/pages/Account/AffiliateDashboard.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react'; import { toast } from 'sonner'; +import { Link } from 'react-router-dom'; import { formatPrice, getCurrencySettings } from '@/lib/currency'; // Affiliate types @@ -14,6 +15,16 @@ interface AffiliateProfile { commission_rate: number; custom_commission_rate: number | null; global_commission_rate: number; + total_earnings: number; + pending_earnings: number; +} + +interface PaginatedReferrals { + referrals: AffiliateReferral[]; + total: number; + page: number; + limit: number; + total_pages: number; } interface AffiliateReferral { @@ -130,13 +141,14 @@ export default function AffiliateDashboard() { }); // Fetch referrals - const { data: referrals, isLoading: isLoadingReferrals } = useQuery({ + const { data: referralsResponse, isLoading: isLoadingReferrals } = useQuery({ queryKey: ['affiliate-referrals'], queryFn: async () => { - return await api.get('/account/affiliate/referrals'); + return await api.get('/account/affiliate/referrals?limit=5'); }, enabled: !!profile && profile.status === 'active' }); + const referrals = referralsResponse?.referrals || []; // Fetch payout history const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery({ @@ -248,11 +260,8 @@ export default function AffiliateDashboard() { setTimeout(() => setCopied(false), 2000); }; - const approvedReferrals = (referrals || []).filter((r: any) => r.status === 'approved'); - const pendingReferrals = (referrals || []).filter((r: any) => r.status === 'pending'); - - const totalEarnings = approvedReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0); - const pendingEarnings = pendingReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0); + const totalEarnings = profile.total_earnings || 0; + const pendingEarnings = profile.pending_earnings || 0; const handleSavePayment = () => { if (!selectedMethod) { @@ -443,7 +452,17 @@ export default function AffiliateDashboard() { {/* Referrals */}
-

Recent Referrals

+
+

Recent Referrals

+ {referralsResponse && referralsResponse.total > 5 && ( + + View All + + )} +
{isLoadingReferrals ? (
Loading referrals...
@@ -463,13 +482,12 @@ export default function AffiliateDashboard() {
Order #{ref.order_id} - + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`}> {ref.status}
@@ -527,11 +545,10 @@ export default function AffiliateDashboard() {
-
+ }`}>
@@ -544,11 +561,10 @@ export default function AffiliateDashboard() {
- + }`}> {payout.status}
@@ -605,11 +621,10 @@ export default function AffiliateDashboard() { setSelectedMethod(method); setPaymentFormData({}); }} - className={`px-4 py-2 rounded-lg border text-sm transition-colors ${ - selectedMethod === method + className={`px-4 py-2 rounded-lg border text-sm transition-colors ${selectedMethod === method ? 'bg-purple-100 border-purple-500 text-purple-700' : 'bg-white border-gray-200 hover:bg-gray-50' - }`} + }`} > {PAYMENT_METHOD_LABELS[method] || method} diff --git a/customer-spa/src/pages/Account/AffiliateReferrals.tsx b/customer-spa/src/pages/Account/AffiliateReferrals.tsx new file mode 100644 index 0000000..8643916 --- /dev/null +++ b/customer-spa/src/pages/Account/AffiliateReferrals.tsx @@ -0,0 +1,206 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { api } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { CheckCircle, Clock, Info, ArrowLeft, ChevronLeft, ChevronRight, Search, User } from 'lucide-react'; +import { getCurrencySettings } from '@/lib/currency'; + +interface AffiliateReferral { + id: number; + status: 'pending' | 'approved' | 'rejected'; + commission_amount: string; + created_at: string; + order_id: number; + currency: string; + approved_at?: string; + cancelled_reason?: string; + customer_name?: string; + customer_email?: string; +} + +interface PaginatedReferrals { + referrals: AffiliateReferral[]; + total: number; + page: number; + limit: number; + total_pages: number; +} + +function formatAmount(amount: number | string, currency?: string): string { + const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount; + const settings = getCurrencySettings(); + const decimals = currency === 'IDR' ? 0 : settings.decimals; + const rounded = amountNum.toFixed(decimals); + const [integerPart, decimalPart] = rounded.split('.'); + const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, settings.thousandSeparator); + + const formattedNum = (decimals > 0 && decimalPart) + ? `${formattedInteger}${settings.decimalSeparator}${decimalPart}` + : formattedInteger; + + return `${settings.symbol}${formattedNum}`; +} + +export default function AffiliateReferrals() { + const [page, setPage] = useState(1); + const [orderIdSearch, setOrderIdSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const limit = 20; + + // Simple debounce for search + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(orderIdSearch); + setPage(1); // Reset page on new search + }, 500); + return () => clearTimeout(timer); + }, [orderIdSearch]); + + const { data: response, isLoading } = useQuery({ + queryKey: ['affiliate-referrals-full', page, limit, debouncedSearch], + queryFn: async () => { + const searchParam = debouncedSearch ? `&order_id=${encodeURIComponent(debouncedSearch)}` : ''; + return await api.get(`/account/affiliate/referrals?limit=${limit}&page=${page}${searchParam}`); + }, + }); + + const referrals = response?.referrals || []; + const totalPages = response?.total_pages || 1; + + return ( +
+
+
+ + + +
+

All Referrals

+

+ {response ? `Showing ${referrals.length} of ${response.total} referrals` : 'Loading referrals...'} +

+
+
+ +
+ + setOrderIdSearch(e.target.value)} + /> +
+
+ + {isLoading ? ( +
Loading referrals...
+ ) : referrals.length === 0 ? ( +
+ No referrals found. +
+ ) : ( +
+ {referrals.map((ref: AffiliateReferral) => { + const createdDate = new Date(ref.created_at); + const approvedDate = ref.approved_at ? new Date(ref.approved_at) : null; + + return ( +
+
+
+
+ Order #{ref.order_id} + + {ref.status} + +
+ {ref.customer_name && ( +
+ + {ref.customer_name} + {ref.customer_email && ({ref.customer_email})} +
+ )} +
+ + + {createdDate.toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + {ref.status === 'approved' && approvedDate && ( + + + Approved {approvedDate.toLocaleDateString('id-ID', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} + + )} +
+
+
+
+ {formatAmount(ref.commission_amount, ref.currency)} +
+
+
+ {ref.status === 'rejected' && ref.cancelled_reason && ( +
+
+ +
+ Reason: {ref.cancelled_reason} +
+
+
+ )} +
+ ); + })} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {page} of {totalPages} +
+
+ + +
+
+ )} +
+ )} +
+ ); +} diff --git a/customer-spa/src/pages/Account/index.tsx b/customer-spa/src/pages/Account/index.tsx index 2ab1073..9aecb8c 100644 --- a/customer-spa/src/pages/Account/index.tsx +++ b/customer-spa/src/pages/Account/index.tsx @@ -14,6 +14,7 @@ import LicenseConnect from './LicenseConnect'; import Subscriptions from './Subscriptions'; import SubscriptionDetail from './SubscriptionDetail'; import AffiliateDashboard from './AffiliateDashboard'; +import AffiliateReferrals from './AffiliateReferrals'; export default function Account() { const user = (window as any).woonoowCustomer?.user; @@ -44,6 +45,7 @@ export default function Account() { } /> } /> } /> + } /> } /> } /> diff --git a/includes/Api/Controllers/AffiliateCustomerController.php b/includes/Api/Controllers/AffiliateCustomerController.php index 5ffae2c..46386cf 100644 --- a/includes/Api/Controllers/AffiliateCustomerController.php +++ b/includes/Api/Controllers/AffiliateCustomerController.php @@ -5,6 +5,7 @@ namespace WooNooW\Api\Controllers; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; +use WooNooW\Modules\Affiliate\AffiliateSettings; class AffiliateCustomerController { @@ -74,8 +75,20 @@ class AffiliateCustomerController ? (float) $affiliate['custom_commission_rate'] : $global_rate; + $referrals_table = $wpdb->prefix . 'woonoow_referrals'; + $earnings = $wpdb->get_row($wpdb->prepare( + "SELECT + SUM(CASE WHEN status = 'approved' THEN commission_amount ELSE 0 END) as total_earnings, + SUM(CASE WHEN status = 'pending' THEN commission_amount ELSE 0 END) as pending_earnings + FROM $referrals_table + WHERE affiliate_id = %d", + $affiliate['id'] + )); + $affiliate['global_commission_rate'] = $global_rate; $affiliate['commission_rate'] = $effective_rate; + $affiliate['total_earnings'] = $earnings->total_earnings ?: 0; + $affiliate['pending_earnings'] = $earnings->pending_earnings ?: 0; return rest_ensure_response($affiliate); } @@ -136,16 +149,51 @@ class AffiliateCustomerController return rest_ensure_response([]); } - $referrals = $wpdb->get_results($wpdb->prepare( - "SELECT r.*, + $limit = (int) $request->get_param('limit'); + $page = max(1, (int) $request->get_param('page')); + $order_id = $request->get_param('order_id') ? (int) $request->get_param('order_id') : null; + + $where = $wpdb->prepare("WHERE r.affiliate_id = %d", $affiliate->id); + if ($order_id) { + $where .= $wpdb->prepare(" AND r.order_id = %d", $order_id); + } + + $sql = "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); + $where + ORDER BY r.created_at DESC"; + + if ($limit > 0) { + $offset = ($page - 1) * $limit; + $sql .= $wpdb->prepare(" LIMIT %d OFFSET %d", $limit, $offset); + } + + $referrals = $wpdb->get_results($sql, ARRAY_A); + + $total = $wpdb->get_var("SELECT COUNT(r.id) FROM $referrals_table r $where"); + + // Attach customer data if enabled + if (!empty($referrals) && AffiliateSettings::get_setting('woonoow_affiliate_share_customer_data', false)) { + foreach ($referrals as &$ref) { + if (!empty($ref['order_id'])) { + $order = wc_get_order($ref['order_id']); + if ($order) { + $ref['customer_name'] = trim($order->get_billing_first_name() . ' ' . $order->get_billing_last_name()); + $ref['customer_email'] = $order->get_billing_email(); + } + } + } + } + + return rest_ensure_response([ + 'referrals' => $referrals, + 'total' => (int) $total, + 'page' => $page, + 'limit' => $limit > 0 ? $limit : (int) $total, + 'total_pages' => $limit > 0 ? ceil($total / $limit) : 1 + ]); } public function get_payouts(WP_REST_Request $request) diff --git a/includes/Modules/Affiliate/AffiliateLifecycle.php b/includes/Modules/Affiliate/AffiliateLifecycle.php index 073fc38..1575989 100644 --- a/includes/Modules/Affiliate/AffiliateLifecycle.php +++ b/includes/Modules/Affiliate/AffiliateLifecycle.php @@ -107,11 +107,13 @@ class AffiliateLifecycle if (!$referral) return; // Check if holding period is 0 (immediate approval on completion) - $holding_period = (int) get_option('woonoow_affiliate_holding_period', 14); + $holding_period = (int) AffiliateSettings::get_setting('woonoow_affiliate_holding_period', 14); + $handled_now = false; if ($holding_period === 0) { // Immediate approval self::auto_approve_referral($referral->id); + $handled_now = true; } else { // If order was completed BEFORE the scheduled action time, approve now // Otherwise, the scheduled action will approve later @@ -120,12 +122,13 @@ class AffiliateLifecycle if (time() >= $approval_time) { self::auto_approve_referral($referral->id); + $handled_now = true; } // 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')) { + // Only unschedule if we actually approved now. + if ($handled_now && function_exists('as_unschedule_all_actions')) { as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate'); } } @@ -222,13 +225,23 @@ class AffiliateLifecycle if (!$referral) return; // Already processed or deleted - // Double check order status + // Double check order status. + // Referrals must never be approved before the order is completed. $order = wc_get_order($referral->order_id); - if (!$order || in_array($order->get_status(), ['refunded', 'cancelled', 'failed'])) { + if (!$order) { + return; + } + + $order_status = $order->get_status(); + if (in_array($order_status, ['refunded', 'cancelled', 'failed'])) { self::handle_order_cancelled($referral->order_id); return; } + if ($order_status !== 'completed') { + return; + } + // Approve referral $wpdb->update( $referrals_table, diff --git a/includes/Modules/Affiliate/AffiliateSettings.php b/includes/Modules/Affiliate/AffiliateSettings.php index 0bc9fae..e2096fc 100644 --- a/includes/Modules/Affiliate/AffiliateSettings.php +++ b/includes/Modules/Affiliate/AffiliateSettings.php @@ -64,8 +64,32 @@ class AffiliateSettings { 'description' => __('Allow affiliates to earn commission when their own user account places an order.', 'woonoow'), 'default' => false, ], + 'woonoow_affiliate_share_customer_data' => [ + 'type' => 'toggle', + 'label' => __('Share Customer Data with Affiliates', 'woonoow'), + 'description' => __('Allow affiliates to see the name and email of the customers they refer.', 'woonoow'), + 'default' => false, + ], ]; return $schemas; } + + /** + * Read Affiliate module setting from module settings storage with legacy fallback. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public static function get_setting($key, $default = null) + { + $module_settings = get_option('woonoow_module_affiliate_settings', []); + if (is_array($module_settings) && array_key_exists($key, $module_settings)) { + return $module_settings[$key]; + } + + // Legacy fallback for older installs that may store direct option keys. + return get_option($key, $default); + } } diff --git a/includes/Modules/Affiliate/AffiliateTracker.php b/includes/Modules/Affiliate/AffiliateTracker.php index 95e58d3..c7f1f6a 100644 --- a/includes/Modules/Affiliate/AffiliateTracker.php +++ b/includes/Modules/Affiliate/AffiliateTracker.php @@ -363,7 +363,7 @@ class AffiliateTracker // 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); + $approval_days = (int) AffiliateSettings::get_setting('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'); }