| @@ -7,9 +7,9 @@ | ||
| 7 | 7 | use FluentCart\App\Models\Concerns\CanSearch; |
| 8 | 8 | use FluentCart\App\Models\Concerns\CanUpdateBatch; |
| 9 | 9 | use FluentCart\App\Services\Localization\LocalizationManager; |
| 10 | 10 | use FluentCart\Framework\Database\Orm\Relations\BelongsTo; |
| 11 | -use FluentCart\Framework\Database\Orm\Relations\hasOne; | |
| 11 | +use FluentCart\Framework\Database\Orm\Relations\HasOne; | |
| 12 | 12 | use FluentCart\Framework\Database\Orm\Relations\MorphMany; |
| 13 | 13 | use FluentCart\Framework\Support\Arr; |
| 14 | 14 | use FluentCart\App\Helpers\Helper; |
| 15 | 15 | |
| @@ -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() |
| @@ -125,14 +135,14 @@ | ||
| 125 | 135 | { |
| 126 | 136 | return $this->hasMany(CustomerAddresses::class, 'customer_id', 'id')->where('type', 'billing'); |
| 127 | 137 | } |
| 128 | 138 | |
| 129 | - public function primary_shipping_address(): hasOne | |
| 139 | + public function primary_shipping_address(): HasOne | |
| 130 | 140 | { |
| 131 | 141 | return $this->hasOne(CustomerAddresses::class, 'customer_id', 'id')->where('type', 'shipping')->where('is_primary', 1); |
| 132 | 142 | } |
| 133 | 143 | |
| 134 | - public function primary_billing_address(): hasOne | |
| 144 | + public function primary_billing_address(): HasOne | |
| 135 | 145 | { |
| 136 | 146 | return $this->hasOne(CustomerAddresses::class, 'customer_id', 'id')->where('type', 'billing')->where('is_primary', 1); |
| 137 | 147 | } |
| 138 | 148 | |
| @@ -150,28 +160,16 @@ | ||
| 150 | 160 | return trim("{$fname} {$lname}"); |
| 151 | 161 | } |
| 152 | 162 | |
| 153 | 163 | /** |
| 154 | - * Accessor method to get the user's avatar URL using their email, | |
| 155 | - * with a size of 100x100 pixels. | |
| 156 | - * | |
| 157 | - * @return string | |
| 164 | + * The customer's avatar URL: their uploaded photo when one is set, | |
| 165 | + * otherwise the WordPress avatar by user id or email. Empty when the | |
| 166 | + * customer has no avatar of their own — every display site falls back | |
| 167 | + * to a placeholder icon rather than Gravatar's stock image. | |
| 158 | 168 | */ |
| 159 | - public function getPhotoAttribute() | |
| 169 | + public function getPhotoAttribute(): string | |
| 160 | 170 | { |
| 161 | - // Get the custom photo URL from user meta using the user_id of this instance | |
| 162 | - $customPhotoUrl = get_user_meta($this->user_id, 'fc_customer_photo_url', true); | |
| 163 | - | |
| 164 | - // Sanitize the customer photo URL | |
| 165 | - $customPhotoUrl = esc_url($customPhotoUrl ?? ''); | |
| 166 | - | |
| 167 | - // Return the custom photo URL if it exists, otherwise fallback to Gravatar | |
| 168 | - if (!empty($customPhotoUrl)) { | |
| 169 | - return $customPhotoUrl; | |
| 170 | - } | |
| 171 | - | |
| 172 | - // Fallback to Gravatar if no customer avatar is set and sanitize the Gravatar URL | |
| 173 | - return esc_url(get_avatar_url($this->email, ['size' => 100])); | |
| 171 | + return Helper::getUserAvatarUrl($this->user_id, $this->email); | |
| 174 | 172 | } |
| 175 | 173 | |
| 176 | 174 | /** |
| 177 | 175 | * Accessor method to get the country's name with country code, |
| @@ -200,26 +198,20 @@ | ||
| 200 | 198 | |
| 201 | 199 | public function recountStat() |
| 202 | 200 | { |
| 203 | 201 | |
| 204 | - $orders = \FluentCart\App\Models\Order::query()->where('customer_id', $this->id) | |
| 202 | + $stats = Order::query()->where('customer_id', $this->id) | |
| 205 | 203 | ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses()) |
| 206 | - ->get(); | |
| 204 | + ->selectRaw('COUNT(*) AS purchase_count, MIN(created_at) AS first_purchase_date, MAX(created_at) AS last_purchase_date') | |
| 205 | + // Check before subtracting: payment columns can be unsigned in MySQL. | |
| 206 | + ->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') | |
| 207 | + ->toBase()->first(); | |
| 207 | 208 | |
| 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; | |
| 209 | + $this->purchase_count = (int) $stats->purchase_count; | |
| 210 | + $this->first_purchase_date = $stats->first_purchase_date; | |
| 211 | + $this->last_purchase_date = $stats->last_purchase_date; | |
| 212 | + $this->ltv = (int) $stats->ltv; | |
| 213 | + $this->aov = $this->purchase_count ? $this->ltv / $this->purchase_count : 0; | |
| 222 | 214 | $this->save(); |
| 223 | 215 | |
| 224 | 216 | |
| 225 | 217 | return $this; |
| @@ -392,21 +384,20 @@ | ||
| 392 | 384 | { |
| 393 | 385 | return $this->belongsTo(User::class, 'user_id'); |
| 394 | 386 | } |
| 395 | 387 | |
| 388 | + /** | |
| 389 | + * The WordPress user this customer is linked to, or empty when unlinked. | |
| 390 | + * | |
| 391 | + * A read never rewrites identity. The old $recheck path looked the user up | |
| 392 | + * by email and saved that ID onto the row — a rebinding path every caller | |
| 393 | + * inherited, trusting an address its holder can change with no | |
| 394 | + * confirmation. The link is written where identity is established | |
| 395 | + * (explicit creation, verified claims, admin). $recheck is kept so existing | |
| 396 | + * callers and integrations need no change. | |
| 397 | + */ | |
| 396 | 398 | public function getWpUserId($recheck = false) |
| 397 | 399 | { |
| 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 | 400 | return $this->user_id; |
| 410 | 401 | } |
| 411 | 402 | |
| 412 | 403 | public function getFormattedAddressAttribute(): array |
| @@ -466,28 +457,20 @@ | ||
| 466 | 457 | return $exist; |
| 467 | 458 | } |
| 468 | 459 | |
| 469 | 460 | |
| 461 | + /** | |
| 462 | + * @return \WP_User|false The linked WordPress user; false when unlinked or | |
| 463 | + * the linked account no longer exists. See getWpUserId() | |
| 464 | + * for why there is no email fallback. | |
| 465 | + */ | |
| 470 | 466 | public function getWpUser() |
| 471 | 467 | { |
| 472 | - if ($this->user_id) { | |
| 473 | - $user = get_user_by('ID', $this->user_id); | |
| 474 | - if ($user) { | |
| 475 | - return $user; | |
| 476 | - } | |
| 468 | + if (!$this->user_id) { | |
| 469 | + return false; | |
| 477 | 470 | } |
| 478 | - | |
| 479 | - $user = get_user_by('email', $this->email); | |
| 480 | 471 | |
| 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; | |
| 472 | + return get_user_by('ID', $this->user_id); | |
| 490 | 473 | } |
| 491 | 474 | |
| 492 | 475 | public function scopeSearchByFullName ($query, $data) { |
| 493 | 476 | |