PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Models / Customer.php

Customer.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Models/Customer.php

513 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Models;
4
5 use FluentCart\App\Helpers\AddressHelper;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Concerns\CanSearch;
8 use FluentCart\App\Models\Concerns\CanUpdateBatch;
9 use FluentCart\App\Services\Localization\LocalizationManager;
10 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
11 use FluentCart\Framework\Database\Orm\Relations\HasOne;
12 use FluentCart\Framework\Database\Orm\Relations\MorphMany;
13 use FluentCart\Framework\Support\Arr;
14 use FluentCart\App\Helpers\Helper;
15
16 /**
17 * Customer Model - DB Model for Customers
18 *
19 * Database Model
20 *
21 * @package FluentCart\App\Models
22 *
23 * @version 1.0.0
24 */
25 class Customer extends Model
26 {
27 use CanSearch, CanUpdateBatch;
28
29 protected $table = 'fct_customers';
30 protected $appends = ['full_name', 'photo', 'country_name', 'formatted_address', 'user_link'];
31
32 /**
33 * The attributes that are mass assignable.
34 *
35 * @var array
36 */
37 protected $fillable = [
38 'user_id',
39 'contact_id',
40 'email',
41 'first_name',
42 'last_name',
43 'status',
44 'purchase_value',
45 'purchase_count',
46 'ltv',
47 'first_purchase_date',
48 'last_purchase_date',
49 'aov',
50 'notes',
51 'uuid',
52 'country',
53 'city',
54 'state',
55 'postcode',
56 ];
57
58 protected $searchable = [
59 'first_name',
60 'last_name',
61 'email',
62 ];
63
64 public function setPurchaseValueAttribute($value)
65 {
66 if (is_array($value) || is_object($value)) {
67 $this->attributes['purchase_value'] = json_encode($value);
68 } else {
69 $this->attributes['purchase_value'] = $value;
70 }
71 }
72
73 public function getPurchaseValueAttribute($value)
74 {
75 return !empty($value) ? json_decode($value,true) : null;
76 }
77
78 public static function boot()
79 {
80 parent::boot();
81 static::creating(function ($model) {
82 $model->uuid = md5($model->email . '_' . wp_generate_uuid4());
83 });
84 }
85
86 public function scopeOfActive($query)
87 {
88 return $query->where('status', 'active');
89 }
90
91 public function scopeOfArchived($query)
92 {
93 return $query->where('status', 'archived');
94 }
95
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 /**
107 * todo - contact_id ? - do we need it anymore?
108 */
109
110 public function orders()
111 {
112 return $this->hasMany(Order::class, 'customer_id', 'id');
113 }
114
115 public function success_order_items()
116 {
117 return $this->hasManyThrough(OrderItem::class, Order::class, 'customer_id', 'order_id', 'id', 'id')
118 ->whereHas('order', function ($q) {
119 $q->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses());
120 });
121 }
122
123
124 public function subscriptions()
125 {
126 return $this->hasMany(Subscription::class, 'customer_id', 'id');
127 }
128
129 public function shipping_address()
130 {
131 return $this->hasMany(CustomerAddresses::class, 'customer_id', 'id')->where('type', 'shipping');
132 }
133
134 public function billing_address()
135 {
136 return $this->hasMany(CustomerAddresses::class, 'customer_id', 'id')->where('type', 'billing');
137 }
138
139 public function primary_shipping_address(): HasOne
140 {
141 return $this->hasOne(CustomerAddresses::class, 'customer_id', 'id')->where('type', 'shipping')->where('is_primary', 1);
142 }
143
144 public function primary_billing_address(): HasOne
145 {
146 return $this->hasOne(CustomerAddresses::class, 'customer_id', 'id')->where('type', 'billing')->where('is_primary', 1);
147 }
148
149
150 /**
151 * Accessor to get dynamic full_name attribute
152 *
153 * @return string
154 */
155 public function getFullNameAttribute()
156 {
157 $fname = isset($this->attributes['first_name']) ? $this->attributes['first_name'] : '';
158 $lname = isset($this->attributes['last_name']) ? $this->attributes['last_name'] : '';
159
160 return trim("{$fname} {$lname}");
161 }
162
163 /**
164 * Accessor method to get the user's avatar URL using their email,
165 * with a size of 100x100 pixels.
166 *
167 * @return string
168 */
169 public function getPhotoAttribute()
170 {
171 // Get the custom photo URL from user meta using the user_id of this instance
172 $customPhotoUrl = get_user_meta($this->user_id, 'fc_customer_photo_url', true);
173
174 // Sanitize the customer photo URL
175 $customPhotoUrl = esc_url($customPhotoUrl ?? '');
176
177 // Return the custom photo URL if it exists, otherwise fallback to Gravatar
178 if (!empty($customPhotoUrl)) {
179 return $customPhotoUrl;
180 }
181
182 // Fallback to Gravatar if no customer avatar is set and sanitize the Gravatar URL
183 return esc_url(get_avatar_url($this->email, ['size' => 100]));
184 }
185
186 /**
187 * Accessor method to get the country's name with country code,
188 *
189 * @return string
190 */
191 public function getCountryNameAttribute(): string
192 {
193 return Helper::getCountryName($this->country);
194 }
195
196 public function recountStats()
197 {
198 $this->total_order_count = Order::query()->where('customer_id', $this->id)
199 // ->whereIn('order_status', Status::getOrderSuccessStatuses())
200 ->count();
201
202 $this->total_order_value = Order::query()->where('customer_id', $this->id)
203 // ->whereIn('order_status', Status::getOrderSuccessStatuses())
204 ->sum('total_amount');
205
206 $this->save();
207
208 return $this;
209 }
210
211 public function recountStat()
212 {
213
214 $stats = Order::query()->where('customer_id', $this->id)
215 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
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();
220
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;
226 $this->save();
227
228
229 return $this;
230 }
231
232 /**
233 * Local scope to filter subscribers by search/query string
234 *
235 * @param \FluentCart\Framework\Database\Query\Builder $query
236 * @param string $search
237 *
238 * @return \FluentCart\Framework\Database\Query\Builder $query
239 */
240 public function scopeSearchBy($query, $search)
241 {
242 if ($search) {
243
244 $fields = $this->searchable;
245
246 // maybe operator based search
247 $operators = ['=', '!=', '>', '<'];
248
249 // check if search has an operator with regexp
250 $operatorPattern = '/\s*(' . implode('|', $operators) . ')\s*/';
251
252 $search = trim($search);
253 if (preg_match($operatorPattern, $search, $matches)) {
254 $operator = $matches[1];
255 $searchParts = explode($operator, $search);
256 if (count($searchParts) >= 2) {
257 $column = trim($searchParts[0]);
258 $value = trim($searchParts[1]);
259
260 // Check if the column is valid
261 $validColumns = $this->fillable;
262 $validColumns[] = 'id';
263
264 if (in_array($column, $validColumns)) {
265 return $query->where($column, $operator, $value);
266 }
267 }
268 }
269
270 $maybeColumnSearch = explode(':', $search);
271
272 if (count($maybeColumnSearch) >= 2) {
273 $column = $maybeColumnSearch[0];
274 $validColumns = $this->fillable;
275 $validColumns[] = 'id';
276 if (in_array($column, $validColumns)) {
277 return $query->where($column, 'LIKE', '%%' . trim($maybeColumnSearch[1]) . '%%');
278 }
279 }
280
281 $maybeExactSearch = explode('=', $search);
282 if (count($maybeExactSearch) >= 2) {
283 $column = $maybeExactSearch[0];
284 $validColumns = $this->fillable;
285 $validColumns[] = 'id';
286 if (in_array($column, $validColumns)) {
287 return $query->where($column, trim($maybeExactSearch[1]));
288 }
289 }
290
291 $query->where(function ($query) use ($fields, $search) {
292 $query->where(array_shift($fields), 'LIKE', "%$search%");
293
294 $nameArray = explode(' ', $search);
295 if (count($nameArray) >= 2) {
296 $query->orWhere(function ($q) use ($nameArray) {
297 $fname = array_shift($nameArray);
298 $lastName = implode(' ', $nameArray);
299 $q->where('first_name', 'LIKE', "$fname%");
300 $q->where('last_name', 'LIKE', "$lastName%");
301 });
302 }
303
304 foreach ($fields as $field) {
305 $query->orWhere($field, 'LIKE', "%$search%");
306 }
307 });
308 }
309
310 return $query;
311 }
312
313 public function scopeApplyCustomFilters($query, $filters)
314 {
315 if (!$filters) {
316 return $query;
317 }
318
319 $acceptedKeys = $this->fillable;
320
321 foreach ($filters as $filterKey => $filter) {
322
323 if (!in_array($filterKey, $acceptedKeys)) {
324 continue;
325 }
326
327 $value = Arr::get($filter, 'value', '');
328 $operator = Arr::get($filter, 'operator', '');
329 if (!$value || !$operator || is_array($value)) {
330 continue;
331 }
332
333 switch (strtolower($operator)) {
334 case 'includes':
335 $operator = "like_all";
336 break;
337 case 'not_includes':
338 $operator = "not_like";
339 break;
340 case 'gt':
341 $operator = ">";
342 break;
343 case 'lt':
344 $operator = "<";
345 break;
346
347 default:
348
349 }
350 $param = [$filterKey => ["column" => $filterKey, "operator" => $operator, "value" => trim($value)]];
351 $query->when($param, function ($query) use ($param) {
352 return $query->search($param);
353 });
354 }
355
356 return $query;
357 }
358
359 public function updateCustomerStatus($newStatus)
360 {
361 $oldStatus = $this->status;
362
363 if ($newStatus == $oldStatus) {
364 return $this;
365 }
366
367 $this->status = $newStatus;
368 $this->save();
369
370 do_action('fluent_cart/customer_status_to_' . $newStatus, [
371 'customer' => $this,
372 'old_status' => $oldStatus,
373 'new_status' => $newStatus
374 ]);
375 do_action('fluent_cart/customer_status_updated', [
376 'customer' => $this,
377 'old_status' => $oldStatus,
378 'new_status' => $newStatus
379 ]);
380
381 return $this;
382 }
383
384 /**
385 * Get the customer's label.
386 */
387 public function labels(): MorphMany
388 {
389 return $this->morphMany(LabelRelationship::class, 'labelable');
390 }
391
392 /**
393 * Define the relationship with the User model.
394 */
395 public function wpUser(): BelongsTo
396 {
397 return $this->belongsTo(User::class, 'user_id');
398 }
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 */
410 public function getWpUserId($recheck = false)
411 {
412 return $this->user_id;
413 }
414
415 public function getFormattedAddressAttribute(): array
416 {
417
418 return [
419 'country' => $this->country ? AddressHelper::getCountryNameByCode($this->country): '',
420 'state' => AddressHelper::getStateNameByCode($this->state, $this->country),
421 'city' => $this->city,
422 'postcode' => $this->postcode,
423 'first_name' => $this->first_name,
424 'last_name' => $this->last_name,
425 'full_name' => $this->full_name
426 ];
427 }
428
429 public function getUserLinkAttribute()
430 {
431 if ($this->user_id) {
432 return admin_url('user-edit.php?user_id=' . $this->user_id);
433 }
434 return '';
435 }
436
437 public function getMeta($metaKey, $default = null)
438 {
439 $exist = CustomerMeta::query()->where('customer_id', $this->id)
440 ->where('meta_key', $metaKey)
441 ->first();
442
443 if ($exist) {
444 return $exist->meta_value;
445 }
446
447 return $default;
448 }
449
450 public function updateMeta($metaKey, $metaValue)
451 {
452 $exist = CustomerMeta::query()->where('customer_id', $this->id)
453 ->where('meta_key', $metaKey)
454 ->first();
455
456 if ($exist) {
457 $exist->meta_value = $metaValue;
458 $exist->save();
459 } else {
460 $exist = CustomerMeta::query()->create([
461 'customer_id' => $this->id,
462 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
463 'meta_key' => $metaKey,
464 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
465 'meta_value' => $metaValue
466 ]);
467 }
468
469 return $exist;
470 }
471
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 */
478 public function getWpUser()
479 {
480 if (!$this->user_id) {
481 return false;
482 }
483
484 return get_user_by('ID', $this->user_id);
485 }
486
487 public function scopeSearchByFullName ($query, $data) {
488
489 $operator = Arr::get($data, 'operator', 'like_all');
490
491 $search = Arr::get($data, 'value');
492 $search = sanitize_text_field(trim($search));
493
494 $fullName = \FluentCart\App\App::db()->raw("CONCAT(first_name, ' ', last_name)");
495
496 switch ($operator) {
497 case 'starts_with':
498 $pattern = "{$search}%";
499 break;
500 case 'ends_with':
501 $pattern = "%{$search}";
502 break;
503 case 'not_like':
504 return $query->where($fullName, 'NOT LIKE', "%{$search}%");
505 default: // contains
506 $pattern = "%{$search}%";
507 }
508
509 return $query->where($fullName, 'LIKE', $pattern);
510
511 }
512 }
513