| @@ -93,8 +93,18 @@ | ||
| 93 | 93 | return $query->where('status', 'archived'); |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | 96 | /** |
| 97 | + * Customers not linked to any WordPress account. Legacy rows carry 0 as well as NULL. | |
| 98 | + */ | |
| 99 | + public function scopeUnclaimed($query) | |
| 100 | + { | |
| 101 | + return $query->where(function ($query) { | |
| 102 | + $query->whereNull('user_id')->orWhere('user_id', 0); | |
| 103 | + }); | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** | |
| 97 | 107 | * todo - contact_id ? - do we need it anymore? |
| 98 | 108 | */ |
| 99 | 109 | |
| 100 | 110 | public function orders() |
| @@ -200,26 +210,20 @@ | ||
| 200 | 210 | |
| 201 | 211 | public function recountStat() |
| 202 | 212 | { |
| 203 | 213 | |
| 204 | - $orders = \FluentCart\App\Models\Order::query()->where('customer_id', $this->id) | |
| 214 | + $stats = Order::query()->where('customer_id', $this->id) | |
| 205 | 215 | ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses()) |
| 206 | - ->get(); | |
| 216 | + ->selectRaw('COUNT(*) AS purchase_count, MIN(created_at) AS first_purchase_date, MAX(created_at) AS last_purchase_date') | |
| 217 | + // Check before subtracting: payment columns can be unsigned in MySQL. | |
| 218 | + ->selectRaw('COALESCE(SUM(CASE WHEN COALESCE(total_paid, 0) > COALESCE(total_refund, 0) THEN COALESCE(total_paid, 0) - COALESCE(total_refund, 0) ELSE 0 END), 0) AS ltv') | |
| 219 | + ->toBase()->first(); | |
| 207 | 220 | |
| 208 | - $totalPayments = []; | |
| 209 | - $ltv = 0; | |
| 210 | - foreach ($orders as $order) { | |
| 211 | - $netPaid = $order->total_paid - $order->total_refund; | |
| 212 | - if ($netPaid > 0) { | |
| 213 | - $ltv += $netPaid; | |
| 214 | - } | |
| 215 | - } | |
| 216 | - | |
| 217 | - $this->purchase_count = $orders->count(); | |
| 218 | - $this->first_purchase_date = $orders->min('created_at') ?? null; | |
| 219 | - $this->last_purchase_date = $orders->max('created_at') ?? null; | |
| 220 | - $this->ltv = $ltv; | |
| 221 | - $this->aov = $this->purchase_count ? $ltv / $this->purchase_count : 0; | |
| 221 | + $this->purchase_count = (int) $stats->purchase_count; | |
| 222 | + $this->first_purchase_date = $stats->first_purchase_date; | |
| 223 | + $this->last_purchase_date = $stats->last_purchase_date; | |
| 224 | + $this->ltv = (int) $stats->ltv; | |
| 225 | + $this->aov = $this->purchase_count ? $this->ltv / $this->purchase_count : 0; | |
| 222 | 226 | $this->save(); |
| 223 | 227 | |
| 224 | 228 | |
| 225 | 229 | return $this; |
| @@ -392,21 +396,20 @@ | ||
| 392 | 396 | { |
| 393 | 397 | return $this->belongsTo(User::class, 'user_id'); |
| 394 | 398 | } |
| 395 | 399 | |
| 400 | + /** | |
| 401 | + * The WordPress user this customer is linked to, or empty when unlinked. | |
| 402 | + * | |
| 403 | + * A read never rewrites identity. The old $recheck path looked the user up | |
| 404 | + * by email and saved that ID onto the row — a rebinding path every caller | |
| 405 | + * inherited, trusting an address its holder can change with no | |
| 406 | + * confirmation. The link is written where identity is established | |
| 407 | + * (explicit creation, verified claims, admin). $recheck is kept so existing | |
| 408 | + * callers and integrations need no change. | |
| 409 | + */ | |
| 396 | 410 | public function getWpUserId($recheck = false) |
| 397 | 411 | { |
| 398 | - if ($recheck) { | |
| 399 | - $user = get_user_by('email', $this->email); | |
| 400 | - if ($user) { | |
| 401 | - if ($user->ID != $this->user_id) { | |
| 402 | - $this->user_id = $user->ID; | |
| 403 | - unset($this->preventsLazyLoading); | |
| 404 | - $this->save(); | |
| 405 | - } | |
| 406 | - } | |
| 407 | - } | |
| 408 | - | |
| 409 | 412 | return $this->user_id; |
| 410 | 413 | } |
| 411 | 414 | |
| 412 | 415 | public function getFormattedAddressAttribute(): array |
| @@ -466,28 +469,20 @@ | ||
| 466 | 469 | return $exist; |
| 467 | 470 | } |
| 468 | 471 | |
| 469 | 472 | |
| 473 | + /** | |
| 474 | + * @return \WP_User|false The linked WordPress user; false when unlinked or | |
| 475 | + * the linked account no longer exists. See getWpUserId() | |
| 476 | + * for why there is no email fallback. | |
| 477 | + */ | |
| 470 | 478 | public function getWpUser() |
| 471 | 479 | { |
| 472 | - if ($this->user_id) { | |
| 473 | - $user = get_user_by('ID', $this->user_id); | |
| 474 | - if ($user) { | |
| 475 | - return $user; | |
| 476 | - } | |
| 480 | + if (!$this->user_id) { | |
| 481 | + return false; | |
| 477 | 482 | } |
| 478 | - | |
| 479 | - $user = get_user_by('email', $this->email); | |
| 480 | 483 | |
| 481 | - if ($user) { | |
| 482 | - if ($user->ID != $this->user_id) { | |
| 483 | - $this->user_id = $user->ID; | |
| 484 | - unset($this->preventsLazyLoading); | |
| 485 | - $this->save(); | |
| 486 | - } | |
| 487 | - } | |
| 488 | - | |
| 489 | - return $user; | |
| 484 | + return get_user_by('ID', $this->user_id); | |
| 490 | 485 | } |
| 491 | 486 | |
| 492 | 487 | public function scopeSearchByFullName ($query, $data) { |
| 493 | 488 | |