PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.23
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.23
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 / Order.php

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

912 lines 28.0 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\Api\ModuleSettings;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\Concerns\CanSearch;
10 use FluentCart\App\Models\Concerns\CanUpdateBatch;
11 use FluentCart\App\Models\Concerns\HasActivity;
12 use FluentCart\App\Services\DateTime\DateTime;
13 use FluentCart\App\Services\OrderService;
14 use FluentCart\App\Services\TemplateService;
15 use FluentCart\App\Services\URL;
16 use FluentCart\Framework\Database\Orm\Builder;
17 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
18 use FluentCart\Framework\Database\Orm\Relations\HasMany;
19 use FluentCart\Framework\Database\Orm\Relations\HasManyThrough;
20 use FluentCart\Framework\Database\Orm\Relations\HasOne;
21 use FluentCart\Framework\Database\Orm\Relations\MorphMany;
22 use FluentCart\Framework\Support\Arr;
23 use FluentCartPro\App\Modules\Licensing\Models\License;
24
25 /**
26 * Order Model - DB Model for Orders
27 *
28 * Database Model
29 *
30 * @package FluentCart\App\Models
31 *
32 * @version 1.0.0
33 */
34 class Order extends Model
35 {
36 use CanSearch, HasActivity, CanUpdateBatch;
37
38 protected $table = 'fct_orders';
39
40 public static function boot()
41 {
42 parent::boot();
43 static::creating(function ($model) {
44 if (empty($model->uuid)) {
45 $model->uuid = md5(time() . wp_generate_uuid4());
46 }
47
48 if (!isset($model->config)) {
49 $model->config = [];
50 }
51
52 if ($model->payment_status === 'paid' || apply_filters('fluent_cart/create_receipt_number_on_order_create', false)) {
53 $model->receipt_number = OrderService::getNextReceiptNumber();
54 $model->invoice_no = OrderService::getInvoicePrefix() . $model->receipt_number;
55 }
56 });
57
58 static::created(function ($model) {
59 if ($model->invoice_no) {
60 do_action('fluent_cart/order/invoice_number_added', [
61 'order' => $model
62 ]);
63 }
64 });
65 }
66
67 protected $fillable = [
68 'status',
69 'parent_id',
70 'invoice_no',
71 'receipt_number',
72 'fulfillment_type',
73 'type',
74 'customer_id',
75 'payment_method',
76 'payment_method_title',
77 'payment_status',
78 'currency',
79 'subtotal',
80 'discount_tax',
81 'manual_discount_total',
82 'coupon_discount_total',
83 'shipping_tax',
84 'shipping_total',
85 'fee_total',
86 'tax_total',
87 'tax_behavior',
88 'total_amount',
89 'rate',
90 'note',
91 'ip_address',
92 'completed_at',
93 'refunded_at',
94 'total_refund',
95 'uuid',
96 'created_at',
97 'refunded_at',
98 'total_paid',
99 'mode',
100 'shipping_status',
101 'config'
102 ];
103
104 protected $searchable = [
105 'id',
106 'total_amount',
107 'status',
108 'payment_method',
109 'payment_status',
110 'created_at',
111 'updated_at',
112 ];
113
114 protected $casts = [
115 'subtotal' => 'double',
116 'discount_tax' => 'double',
117 'manual_discount_total' => 'double',
118 'coupon_discount_total' => 'double',
119 'shipping_tax' => 'double',
120 'shipping_total' => 'double',
121 'fee_total' => 'double',
122 'tax_total' => 'double',
123 'total_amount' => 'double',
124 'customer_id' => 'integer',
125 ];
126
127 public function parentOrder(): BelongsTo
128 {
129 return $this->belongsTo(Order::class, 'parent_id', 'id');
130 }
131
132 public function children(): HasMany
133 {
134 return $this->hasMany(Order::class, 'parent_id', 'id');
135 }
136
137 public function transactions(): HasMany
138 {
139 return $this->hasMany(OrderTransaction::class, 'order_id', 'id');
140 }
141
142 public function subscriptions(): HasMany
143 {
144 return $this->hasMany(Subscription::class, 'parent_order_id', 'id');
145 }
146
147 public function order_items(): HasMany
148 {
149 return $this->hasMany(OrderItem::class, 'order_id', 'id');
150 }
151
152 /**
153 * Get only product order items (excludes fees, signup fees, and other non-product items).
154 * Use this in all display contexts where product line items are shown.
155 *
156 * @return \FluentCart\Framework\Support\Collection
157 */
158 public function getProductItems()
159 {
160 return $this->order_items()->whereNotIn('payment_type', ['fee', 'signup_fee'])->get();
161 }
162
163 /**
164 * Get fee order items for this order.
165 *
166 * @return HasMany
167 */
168 public function feeItems(): HasMany
169 {
170 return $this->order_items()->where('payment_type', 'fee');
171 }
172
173 /**
174 * Get applied fees as a simple array (for display purposes).
175 *
176 * @return array
177 */
178 public function getAppliedFees(): array
179 {
180 return $this->feeItems()->get()->map(function ($item) {
181 $otherInfo = is_array($item->other_info) ? $item->other_info : [];
182 return [
183 'key' => Arr::get($otherInfo, 'fee_key', ''),
184 'label' => $item->title,
185 'amount' => (int) $item->subtotal,
186 'source' => Arr::get($otherInfo, 'source', 'custom'),
187 'item_id' => $item->id,
188 ];
189 })->toArray();
190 }
191
192 public function setConfigAttribute($value)
193 {
194
195 if ($value) {
196 $decoded = \json_encode($value, true);
197 if (!($decoded)) {
198 $decoded = '[]';
199 }
200 } else {
201 $decoded = '[]';
202 }
203
204 $this->attributes['config'] = $decoded;
205 }
206
207 public function getConfigAttribute($value)
208 {
209 if (!$value) {
210 return [];
211 }
212
213 return \json_decode($value, true);
214 }
215
216 /**
217 * Retrieves a filtered list of `order_items` based on priority rules for `payment_type`.
218 *
219 * The function applies the following logic in descending order of precedence:
220 *
221 * 1. **Priority 1: Onetime Items**
222 * - If `order_items` contain `payment_type` as `onetime`, return only those items.
223 *
224 * 2. **Priority 2: Subscription Items**
225 * - If there are no `onetime` items, return `subscription` items only if:
226 * - There is no `signup_fee` or `adjustment` for the same order.
227 * - This ensures `subscription` items are returned only when no other higher priority types are present.
228 *
229 * 3. **Priority 3: Adjustment Items**
230 * - If there are no `onetime` or `subscription` items, return `adjustment` items only if:
231 * - `subscription` items exist for the same order.
232 * - This prioritizes `adjustment` items when both `adjustment` and `subscription` are present.
233 *
234 * The function uses `whereExists` and `whereNotExists` subqueries to apply these priority rules.
235 * - `whereExists` checks for the presence of certain `payment_type` values in the `order_items` table.
236 * - `whereNotExists` ensures exclusion of specific `payment_type` values if higher priority types are present.
237 *
238 * @return HasMany
239 * The filtered `order_items` relationship, ordered by the specified priority rules.
240 */
241
242 public function filteredOrderItems(): HasMany
243 {
244 return $this->hasMany(OrderItem::class, 'order_id', 'id');
245 }
246
247 public function customer(): BelongsTo
248 {
249 return $this->belongsTo(Customer::class, 'customer_id', 'id');
250 }
251
252 public function orderMeta(): HasMany
253 {
254 return $this->hasMany(OrderMeta::class, 'order_id', 'id');
255 }
256
257 public function orderTaxRates(): HasMany
258 {
259 return $this->hasMany(OrderTaxRate::class, 'order_id', 'id');
260 }
261
262
263 public function appliedCoupons(): HasMany
264 {
265 return $this->hasMany(AppliedCoupon::class, 'order_id', 'id');
266 }
267
268 public function usedCoupons(): HasManyThrough
269 {
270
271 return $this->hasManyThrough(
272 Coupon::class, // Final model
273 AppliedCoupon::class, // Intermediate model
274 'order_id', // Foreign key on applied_coupons table
275 'id', // Foreign key on coupons table
276 'id', // Local key on orders table
277 'coupon_id' // Local key on applied_coupons table
278 );
279 }
280
281 public function shipping_address(): HasOne
282 {
283 return $this->hasOne(OrderAddress::class, 'order_id', 'id')->where('type', 'shipping');
284 }
285
286 public function billing_address(): HasOne
287 {
288 return $this->hasOne(OrderAddress::class, 'order_id', 'id')->where('type', 'billing');
289 }
290
291 public function order_addresses(): HasMany
292 {
293 return $this->hasMany(OrderAddress::class, 'order_id', 'id');
294 }
295
296 public function licenses(): HasMany
297 {
298 return $this->hasMany(License::class, 'order_id', 'id');
299 }
300
301 public function scopeSearchBy($query, $search)
302 {
303 $search = trim($search);
304
305 if (!$search) {
306 return $query;
307 }
308
309 $searchTerms = explode(' ', $search);
310
311 return $query->where(function (Builder $q) use ($searchTerms) {
312 $q->where('id', 'LIKE', "%{$searchTerms[0]}%")
313 ->orWhere('status', 'LIKE', "%{$searchTerms[0]}%")
314 ->when(is_numeric($searchTerms[0]), function ($q) use ($searchTerms) {
315 $q->orWhere('total_amount', Helper::toCent($searchTerms[0]));
316 })
317 //->orWhere('total_amount', Helper::toCent($searchTerms[0]))
318 ->orWhere('payment_status', 'LIKE', "%{$searchTerms[0]}%")
319 ->orWhere('payment_method', 'LIKE', "%{$searchTerms[0]}%")
320 ->orWhere('invoice_no', 'LIKE', "%{$searchTerms[0]}%")
321 ->orWhereHas('order_items', function ($orderItemQuery) use ($searchTerms) {
322 $orderItemQuery->where('post_title', 'LIKE', "%{$searchTerms[0]}%")
323 ->orWhere('title', 'LIKE', "%{$searchTerms[0]}%");
324 })
325 ->orWhereHas('customer', function ($customerQuery) use ($searchTerms) {
326 foreach ($searchTerms as $term) {
327 $customerQuery->where(function ($q) use ($term) {
328 $q->where('email', 'LIKE', "%{$term}%")
329 ->orWhere('first_name', 'LIKE', "%{$term}%")
330 ->orWhere('last_name', 'LIKE', "%{$term}%");
331 });
332 }
333 });
334 });
335 }
336
337 public function scopeOfPaymentStatus($query, $status)
338 {
339 return $query->where('payment_status', $status);
340 }
341
342 public function scopeOfOrderStatus($query, $status)
343 {
344 return $query->where('status', $status);
345 }
346
347 public function scopeOfShippingStatus($query, $status)
348 {
349 return $query->where('shipping_status', $status);
350 }
351
352 public function scopeOfOrderType($query, $type)
353 {
354 return $query->where('order_type', $type);
355 }
356
357 public function scopeOfPaymentMethod($query, $methodName)
358 {
359 return $query->where('payment_method', $methodName);
360 }
361
362 public function scopeApplyCustomFilters($query, $filters)
363 {
364 $acceptedKeys = $this->fillable;
365 foreach ($filters as $filterKey => $filterValues) {
366 $values = Arr::get($filterValues, 'value', []);
367 if (!empty($values) && $filterKey && in_array($filterKey, $acceptedKeys)) {
368 $query->search([$filterKey => ["column" => $filterKey, "operator" => "in", "value" => $values]]);
369 }
370 }
371
372 return $query;
373 }
374
375 public function updateStatus($key, $newStatus)
376 {
377 $oldStatus = $this->$key;
378
379 if ($newStatus == $oldStatus) {
380 return $this;
381 }
382
383 if ($key === 'status' && $newStatus === Status::ORDER_COMPLETED) {
384 $this->completed_at = DateTime::gmtNow();
385 }
386
387 if ($key === 'payment_status' && $newStatus === Status::PAYMENT_REFUNDED) {
388 $this->refunded_at = DateTime::gmtNow();
389 }
390
391 $this->$key = $newStatus;
392 $this->save();
393
394 return $this;
395 }
396
397 public function updatePaymentStatus($newStatus)
398 {
399 $oldStatus = $this->payment_status;
400
401 if ($newStatus == $oldStatus) {
402 return $this;
403 }
404
405 if ($newStatus === Status::PAYMENT_REFUNDED) {
406 $this->refunded_at = DateTime::gmtNow();
407 }
408
409 $this->payment_status = $newStatus;
410 $this->save();
411
412 // do_action('fluent_cart/order_status_to_' . $newStatus, [
413 // 'order' => $this,
414 // 'new_status' => $newStatus,
415 // 'old_status' => $oldStatus
416 // ]);
417 // do_action('fluent_cart/order_status_updated', [
418 // 'order' => $this,
419 // 'new_status' => $newStatus,
420 // 'old_status' => $oldStatus
421 // ]);
422
423 return $this;
424 }
425
426 public function getMeta($metaKey, $defaultValue = false)
427 {
428 $meta = OrderMeta::query()->where('order_id', $this->id)
429 ->where('meta_key', $metaKey)
430 ->first();
431
432 if ($meta) {
433 return $meta->meta_value;
434 }
435
436 return $defaultValue;
437 }
438
439 public function updateMeta($metaKey, $value)
440 {
441 $meta = OrderMeta::query()->where('order_id', $this->id)
442 ->where('meta_key', $metaKey)
443 ->first();
444
445 if ($meta) {
446 $meta->meta_value = $value;
447 $meta->save();
448
449 return $meta;
450 }
451
452 return OrderMeta::create([
453 'order_id' => $this->id,
454 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
455 'meta_key' => $metaKey,
456 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
457 'meta_value' => $value,
458 ]);
459 }
460
461 public function deleteMeta($metaKey)
462 {
463 return OrderMeta::where('order_id', $this->id)
464 ->where('meta_key', $metaKey)
465 ->delete();
466 }
467
468 public function getTotalPaidAmount()
469 {
470 return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total');
471 }
472
473 public function getTotalRefundAmount()
474 {
475 return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total');
476 }
477
478 public function recountTotalPaidAndRefund()
479 {
480 $totalPaid = $this->getTotalPaidAmount();
481 $totalRefunded = $this->getTotalRefundAmount();
482
483 $this->total_refund = $totalRefunded;
484
485 if (floatval($totalRefunded) >= floatval($totalPaid)) {
486 $this->payment_status = Status::PAYMENT_REFUNDED;
487 } elseif ($totalPaid > $totalRefunded) {
488 $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED;
489 }
490
491 $this->save();
492
493 return $this;
494 }
495
496 public function syncOrderAfterRefund($type, $refundedAmount)
497 {
498 $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED;
499 $this->total_refund += $refundedAmount;
500 $this->payment_status = $paymentStatus;
501
502 $this->save();
503
504 return $this;
505 }
506
507 public function updateRefundedItems($refundedItemIds, $refundedAmount)
508 {
509 // these are order item ids
510 $totalItems = count($refundedItemIds);
511
512 if ($totalItems === 1) {
513 $orderItem = OrderItem::find($refundedItemIds[0]);
514 $orderItem->refund_total += $refundedAmount;
515 $orderItem->save();
516 return;
517 }
518
519 if ($totalItems === 0) {
520 // get all order items
521 $refundedItemIds = $this->order_items->pluck('id')->toArray();
522 $totalItems = count($refundedItemIds);
523 }
524
525
526 // Calculate remaining amount for each item
527 $items = [];
528 $totalRemain = 0;
529 foreach ($refundedItemIds as $itemId) {
530 $orderItem = OrderItem::find($itemId);
531 $remain = max(0, $orderItem->line_total - $orderItem->refund_total);
532 $items[] = [
533 'model' => $orderItem,
534 'remain' => $remain
535 ];
536 $totalRemain += $remain;
537 }
538
539 if ($totalRemain == 0) {
540 // nothing to refund
541 return;
542 }
543
544 if ($totalRemain < $refundedAmount) {
545 $refundedAmount = $totalRemain;
546 }
547
548 // Distribute refund proportionally
549 $distributed = 0;
550 foreach ($items as $index => $item) {
551 if ($index === count($items) - 1) {
552 // Assign the rest to the last item to avoid rounding issues
553 $amount = $refundedAmount - $distributed;
554 } else {
555 $amount = round($refundedAmount * ($item['remain'] / $totalRemain), 2);
556 $distributed += $amount;
557 }
558 $item['model']->refund_total += $amount;
559 $item['model']->save();
560 }
561 }
562
563 public function recountTotalPaid()
564 {
565 $totalPaid = $this->getTotalPaidAmount();
566 $totalRefunded = $this->getTotalRefundAmount();
567
568 $this->total_paid = ($totalPaid - $totalRefunded) < 0 ? 0 : $totalPaid - $totalRefunded;
569 $this->save();
570 return $this;
571 }
572
573 /**
574 * Get the order's label.
575 */
576 public function labels(): MorphMany
577 {
578 return $this->morphMany(LabelRelationship::class, 'labelable');
579 }
580
581 public function getLatestTransactionAttribute()
582 {
583 return OrderTransaction::query()->where('order_id', $this->id)
584 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
585 ->where('status', '!=', Status::TRANSACTION_REFUNDED)
586 ->orderBy('id', 'DESC')
587 ->first();
588 }
589
590 public function renewals(): HasMany
591 {
592 return $this
593 ->hasMany(Order::class, 'parent_id', 'id')
594 ->where('type', 'renewal')
595 ->wherenotIn('status', [
596 Status::ORDER_CANCELED,
597 Status::ORDER_FAILED,
598 Status::ORDER_ON_HOLD
599 ]);
600 }
601
602 public function isSubscription(): bool
603 {
604 return $this->order_items->where('payment_type', 'subscription')->count() > 0;
605 }
606
607
608 public function getViewUrl($type = 'customer')
609 {
610
611 if ($type === 'admin') {
612 return URL::getDashboardUrl('orders/' . $this->id . '/view');
613 }
614
615 return TemplateService::getCustomerProfileUrl('order/' . $this->uuid);
616 }
617
618 public function getLatestTransaction()
619 {
620 return OrderTransaction::query()
621 ->where('order_id', $this->id)
622 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
623 ->orderBy('id', 'DESC')
624 ->first();
625 }
626
627 public function currentSubscription(): ?Subscription
628 {
629 return Subscription::query()
630 ->where('parent_order_id', $this->id)
631 ->where('status', 'active')
632 ->orderBy('id', 'DESC')
633 ->first();
634 }
635
636 public function getDownloads($scope = 'email'): array
637 {
638 if (!in_array($this->status, Status::getOrderSuccessStatuses())) {
639 return [];
640 }
641
642 $order = $this->load('order_items');
643
644 if ($order->order_items->isEmpty()) {
645 return [];
646 }
647
648 $productIds = $order->order_items->pluck('post_id')->unique()->values();
649 $productDownloads = ProductDownload::query()->whereIn('post_id', $productIds)->get()->keyBy('id');
650
651 $groupedDownload = $productDownloads->groupBy('post_id');
652
653 $downloadData = [];
654
655 $alreadyAdded = [];
656
657 foreach ($order->order_items as $item) {
658 if (in_array($item->payment_type, ['signup_fee', 'fee'])) {
659 continue;
660 }
661
662 $availableDownloads = Arr::get($groupedDownload, $item->post_id, []);
663
664 $authorizedDownloads = [];
665
666 foreach ($availableDownloads as $download) {
667
668 $ids = $download->product_variation_id;
669
670 if (in_array($download->id, $alreadyAdded)) {
671 continue;
672 }
673 if (!is_array($ids) || empty($ids) || in_array($item->object_id, $ids)) {
674
675 $authorizedDownloads [] =
676 [
677 'download_url' => Helper::generateDownloadFileLink($download, $order->id),
678 'title' => $download->title,
679 'file_size' => $download->file_size,
680 'formatted_file_size' => Helper::readableFileSize($download->file_size),
681 ];
682
683 $alreadyAdded[] = $download->id;
684 }
685 }
686
687 if (!empty($authorizedDownloads)) {
688 $downloadData[] = [
689 'title' => $item->post_title . ' - ' . $item->title, // 'product name - variation title',
690 'product_id' => $item->post_id,
691 'variation_id' => $item->object_id,
692 'additional_html' => '',
693 'downloads' => $authorizedDownloads
694 ];
695 }
696 }
697
698
699 return apply_filters('fluent_cart/single_order_downloads', $downloadData, [
700 'order' => $order,
701 'scope' => $scope
702 ]);
703 }
704
705 public function getLicenses($with = ['product','productVariant'])
706 {
707 if (!ModuleSettings::isActive('license') || !App::isProActive()) {
708 return null;
709 }
710
711 return License::query()->where('order_id', $this->id)
712 ->with($with)
713 ->get();
714 }
715
716 public function getDownloadsById($orderId): array
717 {
718 if (empty($orderId)) {
719 return [];
720 }
721
722 $order = Order::query()->with('order_items')->find($orderId);
723
724 if (empty($order)) {
725 return [];
726 }
727
728 return $order->getDownloads();
729 }
730
731 public function getReceiptViewUrl()
732 {
733 return add_query_arg([
734 'fluent-cart' => 'receipt',
735 'order_hash' => $this->uuid,
736 ], home_url());
737 }
738
739 public function getReceiptDownloadUrl()
740 {
741 return add_query_arg(['download' => 1], $this->getReceiptViewUrl());
742 }
743
744 public function addLog($title, $description = '', $type = 'info', $by = '')
745 {
746
747 fluent_cart_add_log(
748 $title,
749 $description,
750 $type,
751 [
752 'module_type' => 'FluentCart\App\Models\Order',
753 'module_id' => $this->id,
754 'module_name' => 'Order',
755 'created_by' => $by
756 ]
757 );
758 }
759
760 public function canBeRefunded(): bool
761 {
762 $config = $this->config;
763 $upgradeTo = Arr::get($config, 'upgraded_to', 0);
764 if (!empty($upgradeTo)) {
765 return false;
766 }
767 return true;
768 }
769
770 public function generateReceiptNumber()
771 {
772 if ($this->receipt_number) {
773 return $this;
774 }
775
776 // Re-check from database — another process may have already generated the number
777 $fresh = static::query()
778 ->where('id', $this->id)
779 ->select(['id', 'receipt_number', 'invoice_no'])
780 ->first();
781
782 if ($fresh && $fresh->receipt_number) {
783 $this->receipt_number = $fresh->receipt_number;
784 $this->invoice_no = $fresh->invoice_no;
785 return $this;
786 }
787
788 // Note: if a concurrent request wins the claim below, this number goes unused
789 // and creates a gap in the receipt sequence. Gaps are acceptable — correctness
790 // (no duplicates) is the priority, and the primary guard in StatusHelper
791 // prevents this race path from being reached in normal operation.
792 $receiptNumber = OrderService::getNextReceiptNumber();
793 $invoiceNo = OrderService::getInvoicePrefix() . $receiptNumber;
794
795 // Atomic: only set receipt_number if still NULL in the database.
796 // Prevents duplicate receipt numbers when concurrent requests
797 // (webhook + browser confirmation) race to generate one.
798 // invoice_no defaults to '' (empty string) in the schema, not NULL,
799 // so we must match both NULL and '' to correctly identify unset invoices.
800 $claimed = static::query()
801 ->where('id', $this->id)
802 ->whereNull('receipt_number')
803 ->where(function ($q) {
804 $q->whereNull('invoice_no')->orWhere('invoice_no', '');
805 })
806 ->update([
807 'receipt_number' => $receiptNumber,
808 'invoice_no' => $invoiceNo,
809 ]);
810
811 if ($claimed) {
812 $this->receipt_number = $receiptNumber;
813 $this->invoice_no = $invoiceNo;
814
815 do_action('fluent_cart/order/invoice_number_added', [
816 'order' => $this
817 ]);
818 } else {
819 // Another process already generated it — use theirs
820 $fresh = static::query()
821 ->where('id', $this->id)
822 ->select(['id', 'receipt_number', 'invoice_no'])
823 ->first();
824
825 if ($fresh && $fresh->receipt_number) {
826 $this->receipt_number = $fresh->receipt_number;
827 $this->invoice_no = $fresh->invoice_no;
828 }
829 }
830
831 return $this;
832 }
833
834 public function orderOperation(): HasOne
835 {
836 return $this->hasOne(OrderOperation::class, 'order_id', 'id');
837 }
838
839 public function canBeDeleted()
840 {
841 $canBeDeleted = true;
842
843
844 if($this->mode !== Status::ORDER_MODE_TEST){
845 // Only canceled or on-hold orders can be deleted
846 if ($this->status === Status::ORDER_CANCELED || $this->status === Status::ORDER_ON_HOLD) {
847
848 $isFreeOrder = ((int)$this->total_amount) === 0;
849 $isPaidOrder = in_array($this->payment_status, Status::getOrderPaymentSuccessStatuses(), true);
850
851 // Free orders OR canceled unpaid orders can be deleted
852 if ($isPaidOrder && !$isFreeOrder) {
853 $canBeDeleted = new \WP_Error(
854 'order_cannot_be_deleted',
855 sprintf(
856 /* translators: 1: order/invoice number, 2: payment status */
857 __('Order %1$s cannot be deleted due to its current payment status: %2$s.', 'fluent-cart'),
858 $this->invoice_no,
859 $this->payment_status
860 )
861 );
862 }
863
864 } else {
865 $canBeDeleted = new \WP_Error(
866 'order_cannot_be_deleted',
867 sprintf(
868 /* translators: 1: order/invoice number, 2: order status */
869 __('Order %1$s cannot be deleted due to its current order status: %2$s.', 'fluent-cart'),
870 $this->invoice_no,
871 $this->status
872 )
873 );
874 }
875 }
876
877
878
879 if (!is_wp_error($canBeDeleted) && $this->mode !== Status::ORDER_MODE_TEST) {
880 // Handle subscription relationship
881 $parentOrderId = $this->parent_id ? $this->parent_id : $this->id;
882
883 $subscription = Subscription::query()
884 ->where('parent_order_id', $parentOrderId)
885 ->first();
886
887 // If subscription is active, prevent deletion
888 if (
889 $subscription &&
890 $subscription->status === Status::SUBSCRIPTION_ACTIVE &&
891 $this->type === 'subscription'
892 ) {
893 $canBeDeleted = new \WP_Error(
894 'order_cannot_be_deleted',
895 sprintf(
896 /* translators: %s is the order/invoice number */
897 __('Order %s cannot be deleted as it has an active subscription.', 'fluent-cart'),
898 $this->invoice_no
899 )
900 );
901 }
902 }
903
904
905 return apply_filters('fluent_cart/order_can_be_deleted', $canBeDeleted, [
906 'order' => $this
907 ]);
908 }
909
910
911 }
912