PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 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 All 50 releases
fluent-cart / app / Models / Customer.php

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

501 lines 14.8 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 * 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.
168 */
169 public function getPhotoAttribute(): string
170 {
171 return Helper::getUserAvatarUrl($this->user_id, $this->email);
172 }
173
174 /**
175 * Accessor method to get the country's name with country code,
176 *
177 * @return string
178 */
179 public function getCountryNameAttribute(): string
180 {
181 return Helper::getCountryName($this->country);
182 }
183
184 public function recountStats()
185 {
186 $this->total_order_count = Order::query()->where('customer_id', $this->id)
187 // ->whereIn('order_status', Status::getOrderSuccessStatuses())
188 ->count();
189
190 $this->total_order_value = Order::query()->where('customer_id', $this->id)
191 // ->whereIn('order_status', Status::getOrderSuccessStatuses())
192 ->sum('total_amount');
193
194 $this->save();
195
196 return $this;
197 }
198
199 public function recountStat()
200 {
201
202 $stats = Order::query()->where('customer_id', $this->id)
203 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
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();
208
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;
214 $this->save();
215
216
217 return $this;
218 }
219
220 /**
221 * Local scope to filter subscribers by search/query string
222 *
223 * @param \FluentCart\Framework\Database\Query\Builder $query
224 * @param string $search
225 *
226 * @return \FluentCart\Framework\Database\Query\Builder $query
227 */
228 public function scopeSearchBy($query, $search)
229 {
230 if ($search) {
231
232 $fields = $this->searchable;
233
234 // maybe operator based search
235 $operators = ['=', '!=', '>', '<'];
236
237 // check if search has an operator with regexp
238 $operatorPattern = '/\s*(' . implode('|', $operators) . ')\s*/';
239
240 $search = trim($search);
241 if (preg_match($operatorPattern, $search, $matches)) {
242 $operator = $matches[1];
243 $searchParts = explode($operator, $search);
244 if (count($searchParts) >= 2) {
245 $column = trim($searchParts[0]);
246 $value = trim($searchParts[1]);
247
248 // Check if the column is valid
249 $validColumns = $this->fillable;
250 $validColumns[] = 'id';
251
252 if (in_array($column, $validColumns)) {
253 return $query->where($column, $operator, $value);
254 }
255 }
256 }
257
258 $maybeColumnSearch = explode(':', $search);
259
260 if (count($maybeColumnSearch) >= 2) {
261 $column = $maybeColumnSearch[0];
262 $validColumns = $this->fillable;
263 $validColumns[] = 'id';
264 if (in_array($column, $validColumns)) {
265 return $query->where($column, 'LIKE', '%%' . trim($maybeColumnSearch[1]) . '%%');
266 }
267 }
268
269 $maybeExactSearch = explode('=', $search);
270 if (count($maybeExactSearch) >= 2) {
271 $column = $maybeExactSearch[0];
272 $validColumns = $this->fillable;
273 $validColumns[] = 'id';
274 if (in_array($column, $validColumns)) {
275 return $query->where($column, trim($maybeExactSearch[1]));
276 }
277 }
278
279 $query->where(function ($query) use ($fields, $search) {
280 $query->where(array_shift($fields), 'LIKE', "%$search%");
281
282 $nameArray = explode(' ', $search);
283 if (count($nameArray) >= 2) {
284 $query->orWhere(function ($q) use ($nameArray) {
285 $fname = array_shift($nameArray);
286 $lastName = implode(' ', $nameArray);
287 $q->where('first_name', 'LIKE', "$fname%");
288 $q->where('last_name', 'LIKE', "$lastName%");
289 });
290 }
291
292 foreach ($fields as $field) {
293 $query->orWhere($field, 'LIKE', "%$search%");
294 }
295 });
296 }
297
298 return $query;
299 }
300
301 public function scopeApplyCustomFilters($query, $filters)
302 {
303 if (!$filters) {
304 return $query;
305 }
306
307 $acceptedKeys = $this->fillable;
308
309 foreach ($filters as $filterKey => $filter) {
310
311 if (!in_array($filterKey, $acceptedKeys)) {
312 continue;
313 }
314
315 $value = Arr::get($filter, 'value', '');
316 $operator = Arr::get($filter, 'operator', '');
317 if (!$value || !$operator || is_array($value)) {
318 continue;
319 }
320
321 switch (strtolower($operator)) {
322 case 'includes':
323 $operator = "like_all";
324 break;
325 case 'not_includes':
326 $operator = "not_like";
327 break;
328 case 'gt':
329 $operator = ">";
330 break;
331 case 'lt':
332 $operator = "<";
333 break;
334
335 default:
336
337 }
338 $param = [$filterKey => ["column" => $filterKey, "operator" => $operator, "value" => trim($value)]];
339 $query->when($param, function ($query) use ($param) {
340 return $query->search($param);
341 });
342 }
343
344 return $query;
345 }
346
347 public function updateCustomerStatus($newStatus)
348 {
349 $oldStatus = $this->status;
350
351 if ($newStatus == $oldStatus) {
352 return $this;
353 }
354
355 $this->status = $newStatus;
356 $this->save();
357
358 do_action('fluent_cart/customer_status_to_' . $newStatus, [
359 'customer' => $this,
360 'old_status' => $oldStatus,
361 'new_status' => $newStatus
362 ]);
363 do_action('fluent_cart/customer_status_updated', [
364 'customer' => $this,
365 'old_status' => $oldStatus,
366 'new_status' => $newStatus
367 ]);
368
369 return $this;
370 }
371
372 /**
373 * Get the customer's label.
374 */
375 public function labels(): MorphMany
376 {
377 return $this->morphMany(LabelRelationship::class, 'labelable');
378 }
379
380 /**
381 * Define the relationship with the User model.
382 */
383 public function wpUser(): BelongsTo
384 {
385 return $this->belongsTo(User::class, 'user_id');
386 }
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 */
398 public function getWpUserId($recheck = false)
399 {
400 return $this->user_id;
401 }
402
403 public function getFormattedAddressAttribute(): array
404 {
405
406 return [
407 'country' => $this->country ? AddressHelper::getCountryNameByCode($this->country): '',
408 'state' => AddressHelper::getStateNameByCode($this->state, $this->country),
409 'city' => $this->city,
410 'postcode' => $this->postcode,
411 'first_name' => $this->first_name,
412 'last_name' => $this->last_name,
413 'full_name' => $this->full_name
414 ];
415 }
416
417 public function getUserLinkAttribute()
418 {
419 if ($this->user_id) {
420 return admin_url('user-edit.php?user_id=' . $this->user_id);
421 }
422 return '';
423 }
424
425 public function getMeta($metaKey, $default = null)
426 {
427 $exist = CustomerMeta::query()->where('customer_id', $this->id)
428 ->where('meta_key', $metaKey)
429 ->first();
430
431 if ($exist) {
432 return $exist->meta_value;
433 }
434
435 return $default;
436 }
437
438 public function updateMeta($metaKey, $metaValue)
439 {
440 $exist = CustomerMeta::query()->where('customer_id', $this->id)
441 ->where('meta_key', $metaKey)
442 ->first();
443
444 if ($exist) {
445 $exist->meta_value = $metaValue;
446 $exist->save();
447 } else {
448 $exist = CustomerMeta::query()->create([
449 'customer_id' => $this->id,
450 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
451 'meta_key' => $metaKey,
452 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
453 'meta_value' => $metaValue
454 ]);
455 }
456
457 return $exist;
458 }
459
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 */
466 public function getWpUser()
467 {
468 if (!$this->user_id) {
469 return false;
470 }
471
472 return get_user_by('ID', $this->user_id);
473 }
474
475 public function scopeSearchByFullName ($query, $data) {
476
477 $operator = Arr::get($data, 'operator', 'like_all');
478
479 $search = Arr::get($data, 'value');
480 $search = sanitize_text_field(trim($search));
481
482 $fullName = \FluentCart\App\App::db()->raw("CONCAT(first_name, ' ', last_name)");
483
484 switch ($operator) {
485 case 'starts_with':
486 $pattern = "{$search}%";
487 break;
488 case 'ends_with':
489 $pattern = "%{$search}";
490 break;
491 case 'not_like':
492 return $query->where($fullName, 'NOT LIKE', "%{$search}%");
493 default: // contains
494 $pattern = "%{$search}%";
495 }
496
497 return $query->where($fullName, 'LIKE', $pattern);
498
499 }
500 }
501