PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.0
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.5.0, at app/Models/Order.php

1,181 lines 37.1 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 FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
24 use FluentCartPro\App\Modules\Licensing\Models\License;
25
26 /**
27 * Order Model - DB Model for Orders
28 *
29 * Database Model
30 *
31 * @package FluentCart\App\Models
32 *
33 * @version 1.0.0
34 */
35 class Order extends Model
36 {
37 use CanSearch, HasActivity, CanUpdateBatch;
38
39 protected $table = 'fct_orders';
40
41 public static function boot()
42 {
43 parent::boot();
44 static::creating(function ($model) {
45 if (empty($model->uuid)) {
46 $model->uuid = md5(time() . wp_generate_uuid4());
47 }
48
49 if (!isset($model->config)) {
50 $model->config = [];
51 }
52
53 if ($model->payment_status === 'paid' || apply_filters('fluent_cart/create_receipt_number_on_order_create', false)) {
54 $model->receipt_number = OrderService::getNextReceiptNumber();
55 $model->invoice_no = OrderService::getInvoicePrefix() . $model->receipt_number;
56 }
57 });
58
59 static::created(function ($model) {
60 if ($model->invoice_no) {
61 do_action('fluent_cart/order/invoice_number_added', [
62 'order' => $model
63 ]);
64 }
65 });
66 }
67
68 protected $fillable = [
69 'status',
70 'parent_id',
71 'invoice_no',
72 'receipt_number',
73 'fulfillment_type',
74 'type',
75 'customer_id',
76 'payment_method',
77 'payment_method_title',
78 'payment_status',
79 'currency',
80 'subtotal',
81 'discount_tax',
82 'manual_discount_total',
83 'coupon_discount_total',
84 'shipping_tax',
85 'shipping_total',
86 'fee_total',
87 'tax_total',
88 'tax_behavior',
89 'total_amount',
90 'rate',
91 'note',
92 'ip_address',
93 'completed_at',
94 'refunded_at',
95 'total_refund',
96 'uuid',
97 'created_at',
98 'refunded_at',
99 'total_paid',
100 'mode',
101 'shipping_status',
102 'config'
103 ];
104
105 protected $searchable = [
106 'id',
107 'total_amount',
108 'status',
109 'payment_method',
110 'payment_status',
111 'created_at',
112 'updated_at',
113 ];
114
115 protected $casts = [
116 'subtotal' => 'double',
117 'discount_tax' => 'double',
118 'manual_discount_total' => 'double',
119 'coupon_discount_total' => 'double',
120 'shipping_tax' => 'double',
121 'shipping_total' => 'double',
122 'fee_total' => 'double',
123 'tax_total' => 'double',
124 'tax_behavior' => 'integer',
125 'total_amount' => 'double',
126 'customer_id' => 'integer',
127 ];
128
129 public function parentOrder(): BelongsTo
130 {
131 return $this->belongsTo(Order::class, 'parent_id', 'id');
132 }
133
134 public function children(): HasMany
135 {
136 return $this->hasMany(Order::class, 'parent_id', 'id');
137 }
138
139 public function transactions(): HasMany
140 {
141 return $this->hasMany(OrderTransaction::class, 'order_id', 'id');
142 }
143
144 public function subscriptions(): HasMany
145 {
146 return $this->hasMany(Subscription::class, 'parent_order_id', 'id');
147 }
148
149 public function order_items(): HasMany
150 {
151 return $this->hasMany(OrderItem::class, 'order_id', 'id');
152 }
153
154 /**
155 * Get only product order items (excludes fees, signup fees, and other non-product items).
156 * Use this in all display contexts where product line items are shown.
157 *
158 * @return \FluentCart\Framework\Support\Collection
159 */
160 public function getProductItems()
161 {
162 return $this->order_items()->whereNotIn('payment_type', ['fee', 'signup_fee'])->get();
163 }
164
165 /**
166 * Get fee order items for this order.
167 *
168 * @return HasMany
169 */
170 public function feeItems(): HasMany
171 {
172 return $this->order_items()->where('payment_type', 'fee');
173 }
174
175 /**
176 * Get applied fees as a simple array (for display purposes).
177 *
178 * @return array
179 */
180 public function getAppliedFees(): array
181 {
182 return $this->feeItems()->get()->map(function ($item) {
183 $otherInfo = is_array($item->other_info) ? $item->other_info : [];
184 return [
185 'key' => Arr::get($otherInfo, 'fee_key', ''),
186 'label' => $item->title,
187 'amount' => (int) $item->subtotal,
188 'source' => Arr::get($otherInfo, 'source', 'custom'),
189 'item_id' => $item->id,
190 ];
191 })->toArray();
192 }
193
194 public function setConfigAttribute($value)
195 {
196
197 if ($value) {
198 $decoded = \json_encode($value, true);
199 if (!($decoded)) {
200 $decoded = '[]';
201 }
202 } else {
203 $decoded = '[]';
204 }
205
206 $this->attributes['config'] = $decoded;
207 }
208
209 public function getConfigAttribute($value)
210 {
211 if (!$value) {
212 return [];
213 }
214
215 return \json_decode($value, true);
216 }
217
218 /**
219 * Retrieves a filtered list of `order_items` based on priority rules for `payment_type`.
220 *
221 * The function applies the following logic in descending order of precedence:
222 *
223 * 1. **Priority 1: Onetime Items**
224 * - If `order_items` contain `payment_type` as `onetime`, return only those items.
225 *
226 * 2. **Priority 2: Subscription Items**
227 * - If there are no `onetime` items, return `subscription` items only if:
228 * - There is no `signup_fee` or `adjustment` for the same order.
229 * - This ensures `subscription` items are returned only when no other higher priority types are present.
230 *
231 * 3. **Priority 3: Adjustment Items**
232 * - If there are no `onetime` or `subscription` items, return `adjustment` items only if:
233 * - `subscription` items exist for the same order.
234 * - This prioritizes `adjustment` items when both `adjustment` and `subscription` are present.
235 *
236 * The function uses `whereExists` and `whereNotExists` subqueries to apply these priority rules.
237 * - `whereExists` checks for the presence of certain `payment_type` values in the `order_items` table.
238 * - `whereNotExists` ensures exclusion of specific `payment_type` values if higher priority types are present.
239 *
240 * @return HasMany
241 * The filtered `order_items` relationship, ordered by the specified priority rules.
242 */
243
244 public function filteredOrderItems(): HasMany
245 {
246 return $this->hasMany(OrderItem::class, 'order_id', 'id');
247 }
248
249 public function customer(): BelongsTo
250 {
251 return $this->belongsTo(Customer::class, 'customer_id', 'id');
252 }
253
254 public function orderMeta(): HasMany
255 {
256 return $this->hasMany(OrderMeta::class, 'order_id', 'id');
257 }
258
259 public function orderTaxRates(): HasMany
260 {
261 return $this->hasMany(OrderTaxRate::class, 'order_id', 'id');
262 }
263
264
265 public function appliedCoupons(): HasMany
266 {
267 return $this->hasMany(AppliedCoupon::class, 'order_id', 'id');
268 }
269
270 public function usedCoupons(): HasManyThrough
271 {
272
273 return $this->hasManyThrough(
274 Coupon::class, // Final model
275 AppliedCoupon::class, // Intermediate model
276 'order_id', // Foreign key on applied_coupons table
277 'id', // Foreign key on coupons table
278 'id', // Local key on orders table
279 'coupon_id' // Local key on applied_coupons table
280 );
281 }
282
283 public function shipping_address(): HasOne
284 {
285 return $this->hasOne(OrderAddress::class, 'order_id', 'id')->where('type', 'shipping');
286 }
287
288 public function billing_address(): HasOne
289 {
290 return $this->hasOne(OrderAddress::class, 'order_id', 'id')->where('type', 'billing');
291 }
292
293 public function order_addresses(): HasMany
294 {
295 return $this->hasMany(OrderAddress::class, 'order_id', 'id');
296 }
297
298 public function licenses(): HasMany
299 {
300 return $this->hasMany(License::class, 'order_id', 'id');
301 }
302
303 public function scopeSearchBy($query, $search)
304 {
305 $search = trim($search);
306
307 if (!$search) {
308 return $query;
309 }
310
311 $searchTerms = explode(' ', $search);
312
313 return $query->where(function (Builder $q) use ($searchTerms) {
314 $q->where('id', 'LIKE', "%{$searchTerms[0]}%")
315 ->orWhere('status', 'LIKE', "%{$searchTerms[0]}%")
316 ->when(is_numeric($searchTerms[0]), function ($q) use ($searchTerms) {
317 $q->orWhere('total_amount', Helper::toCent($searchTerms[0]));
318 })
319 //->orWhere('total_amount', Helper::toCent($searchTerms[0]))
320 ->orWhere('payment_status', 'LIKE', "%{$searchTerms[0]}%")
321 ->orWhere('payment_method', 'LIKE', "%{$searchTerms[0]}%")
322 ->orWhere('invoice_no', 'LIKE', "%{$searchTerms[0]}%")
323 ->orWhereHas('order_items', function ($orderItemQuery) use ($searchTerms) {
324 $orderItemQuery->where('post_title', 'LIKE', "%{$searchTerms[0]}%")
325 ->orWhere('title', 'LIKE', "%{$searchTerms[0]}%");
326 })
327 ->orWhereHas('customer', function ($customerQuery) use ($searchTerms) {
328 foreach ($searchTerms as $term) {
329 $customerQuery->where(function ($q) use ($term) {
330 $q->where('email', 'LIKE', "%{$term}%")
331 ->orWhere('first_name', 'LIKE', "%{$term}%")
332 ->orWhere('last_name', 'LIKE', "%{$term}%");
333 });
334 }
335 });
336 });
337 }
338
339 public function scopeOfPaymentStatus($query, $status)
340 {
341 return $query->where('payment_status', $status);
342 }
343
344 public function scopeOfOrderStatus($query, $status)
345 {
346 return $query->where('status', $status);
347 }
348
349 public function scopeOfShippingStatus($query, $status)
350 {
351 return $query->where('shipping_status', $status);
352 }
353
354 public function scopeOfOrderType($query, $type)
355 {
356 return $query->where('order_type', $type);
357 }
358
359 public function scopeOfPaymentMethod($query, $methodName)
360 {
361 return $query->where('payment_method', $methodName);
362 }
363
364 public function scopeApplyCustomFilters($query, $filters)
365 {
366 $acceptedKeys = $this->fillable;
367 foreach ($filters as $filterKey => $filterValues) {
368 $values = Arr::get($filterValues, 'value', []);
369 if (!empty($values) && $filterKey && in_array($filterKey, $acceptedKeys)) {
370 $query->search([$filterKey => ["column" => $filterKey, "operator" => "in", "value" => $values]]);
371 }
372 }
373
374 return $query;
375 }
376
377 public function updateStatus($key, $newStatus)
378 {
379 $oldStatus = $this->$key;
380
381 if ($newStatus == $oldStatus) {
382 return $this;
383 }
384
385 if ($key === 'status' && $newStatus === Status::ORDER_COMPLETED) {
386 $this->completed_at = DateTime::gmtNow();
387 }
388
389 if ($key === 'payment_status' && $newStatus === Status::PAYMENT_REFUNDED) {
390 $this->refunded_at = DateTime::gmtNow();
391 }
392
393 $this->$key = $newStatus;
394 $this->save();
395
396 return $this;
397 }
398
399 public function updatePaymentStatus($newStatus)
400 {
401 $oldStatus = $this->payment_status;
402
403 if ($newStatus == $oldStatus) {
404 return $this;
405 }
406
407 if ($newStatus === Status::PAYMENT_REFUNDED) {
408 $this->refunded_at = DateTime::gmtNow();
409 }
410
411 $this->payment_status = $newStatus;
412 $this->save();
413
414 // do_action('fluent_cart/order_status_to_' . $newStatus, [
415 // 'order' => $this,
416 // 'new_status' => $newStatus,
417 // 'old_status' => $oldStatus
418 // ]);
419 // do_action('fluent_cart/order_status_updated', [
420 // 'order' => $this,
421 // 'new_status' => $newStatus,
422 // 'old_status' => $oldStatus
423 // ]);
424
425 return $this;
426 }
427
428 public function getMeta($metaKey, $defaultValue = false)
429 {
430 $meta = OrderMeta::query()->where('order_id', $this->id)
431 ->where('meta_key', $metaKey)
432 ->first();
433
434 if ($meta) {
435 return $meta->meta_value;
436 }
437
438 return $defaultValue;
439 }
440
441 public function updateMeta($metaKey, $value)
442 {
443 $meta = OrderMeta::query()->where('order_id', $this->id)
444 ->where('meta_key', $metaKey)
445 ->first();
446
447 if ($meta) {
448 $meta->meta_value = $value;
449 $meta->save();
450
451 return $meta;
452 }
453
454 return OrderMeta::create([
455 'order_id' => $this->id,
456 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
457 'meta_key' => $metaKey,
458 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
459 'meta_value' => $value,
460 ]);
461 }
462
463 public function deleteMeta($metaKey)
464 {
465 return OrderMeta::where('order_id', $this->id)
466 ->where('meta_key', $metaKey)
467 ->delete();
468 }
469
470 public function getBusinessInfo(): array
471 {
472 $businessInfo = $this->getMeta('business_info', []);
473
474 return is_array($businessInfo) ? $businessInfo : [];
475 }
476
477 public function getPrimaryOrderTaxRate()
478 {
479 return $this->orderTaxRates ? $this->orderTaxRates->first() : null;
480 }
481
482 public function getReversedTaxTotal()
483 {
484 $this->loadMissing(['orderTaxRates']);
485 $primaryRate = $this->getPrimaryOrderTaxRate();
486 if (!$primaryRate) {
487 return 0;
488 }
489 return (int) Arr::get(
490 is_array($primaryRate->meta) ? $primaryRate->meta : [],
491 'reverse_charge_original_tax_total',
492 0
493 );
494 }
495
496 public function isB2BOrder(): bool
497 {
498 return !empty($this->getBusinessInfo());
499 }
500
501 public function getIsB2BOrderAttribute(): bool
502 {
503 return $this->isB2BOrder();
504 }
505
506 public function isReverseChargeTaxOrder(): bool
507 {
508 $orderTaxRate = $this->getPrimaryOrderTaxRate();
509 $reverseChargeApplied = Arr::get($orderTaxRate->meta ?? [], 'reverse_charge_applied', null);
510
511 if ($reverseChargeApplied !== null) {
512 return (bool) $reverseChargeApplied;
513 }
514
515 return $this->hasValidatedCustomerTaxNumber() && ((int) $this->tax_total + (int) $this->shipping_tax) === 0;
516 }
517
518 public function getOrderRcMode(): string
519 {
520 $this->loadMissing(['orderTaxRates']);
521 $primaryRate = $this->getPrimaryOrderTaxRate();
522 $stored = Arr::get((array) ($primaryRate ? $primaryRate->meta : []), 'reverse_charge_price_mode', null);
523 if ($stored !== null) {
524 return (string) $stored;
525 }
526 return (string) Arr::get(
527 get_option('fluent_cart_tax_configuration_settings', []),
528 'eu_vat_settings.reverse_charge_price_mode',
529 'fixed'
530 );
531 }
532
533 public function getDisplayTaxLines(): array
534 {
535 $displayTaxLines = [];
536 $isReverseCharge = $this->isReverseChargeTaxOrder();
537
538 foreach ($this->orderTaxRates ?: [] as $orderTaxRate) {
539 $meta = $this->normalizeOrderTaxRateMeta((array) $orderTaxRate->meta, (int) $orderTaxRate->tax_rate_id);
540
541 $ratePercent = (float) Arr::get($meta, 'rate_percent', 0);
542 $rateTaxAmount = (int) $orderTaxRate->order_tax;
543 $taxableAmount = (int) Arr::get($meta, 'taxable_amount', 0);
544 $isCompound = (bool) Arr::get($meta, 'is_compound', false);
545 $isMixedInclusive = (bool) Arr::get($meta, 'is_mixed_inclusive', false);
546 $lineInclusive = Arr::get($meta, 'inclusive', null);
547 $label = trim((string) Arr::get($meta, 'label', ''));
548
549 if ($isReverseCharge) {
550 if ($ratePercent <= 0) {
551 continue;
552 }
553 } elseif ($rateTaxAmount <= 0) {
554 continue;
555 }
556
557 $displayLabel = $label ?: __('Tax', 'fluent-cart');
558
559 if ($ratePercent > 0) {
560 $displayLabel .= ' (' . Helper::formatTaxRatePercent($ratePercent) . '%)';
561 }
562
563 if ($isCompound) {
564 $displayLabel .= ' (' . __('Compound', 'fluent-cart') . ')';
565 }
566
567 if ($taxableAmount > 0 && !$isMixedInclusive) {
568 if ($lineInclusive === null) {
569 $displayBase = $this->tax_behavior == 2
570 ? max(0, $taxableAmount - $rateTaxAmount)
571 : $taxableAmount;
572 } else {
573 $displayBase = $lineInclusive
574 ? max(0, $taxableAmount - $rateTaxAmount)
575 : $taxableAmount;
576 }
577
578 $displayLabel .= ' ' . sprintf(
579 __('on %s', 'fluent-cart'),
580 html_entity_decode(Helper::toDecimal($displayBase), ENT_QUOTES, 'UTF-8')
581 );
582 }
583
584 $displayTaxLines[] = [
585 'label' => $displayLabel,
586 'order_tax' => $rateTaxAmount,
587 'total_tax' => (int) $orderTaxRate->total_tax,
588 'rate_id' => (int) $orderTaxRate->tax_rate_id,
589 ];
590 }
591
592 return $displayTaxLines;
593 }
594
595 public function getDisplayTaxLinesAttribute(): array
596 {
597 return $this->getDisplayTaxLines();
598 }
599
600 public function getDisplayShippingTaxLines(): array
601 {
602 $displayLines = [];
603 foreach ($this->orderTaxRates ?: [] as $orderTaxRate) {
604 $shippingTax = (int) $orderTaxRate->shipping_tax;
605 if ($shippingTax <= 0) {
606 continue;
607 }
608 $meta = $this->normalizeOrderTaxRateMeta((array) $orderTaxRate->meta, (int) $orderTaxRate->tax_rate_id);
609 $ratePercent = (float) Arr::get($meta, 'rate_percent', 0);
610 $label = trim((string) Arr::get($meta, 'label', ''));
611 $rateName = $label ?: __('Tax', 'fluent-cart');
612 if ($ratePercent > 0) {
613 $formattedRatePercent = Helper::formatTaxRatePercent($ratePercent);
614 /* translators: %1$s: tax rate name e.g. "VAT", %2$s: rate percentage e.g. "19" */
615 $displayLabel = sprintf(__('%1$s (%2$s%%) on shipping', 'fluent-cart'), $rateName, $formattedRatePercent);
616 } else {
617 /* translators: %1$s: tax rate name e.g. "VAT" */
618 $displayLabel = sprintf(__('%1$s on shipping', 'fluent-cart'), $rateName);
619 }
620 $displayLines[] = [
621 'label' => $displayLabel,
622 'shipping_tax' => $shippingTax,
623 ];
624 }
625 return $displayLines;
626 }
627
628 public function getDisplayShippingTaxLinesAttribute(): array
629 {
630 return $this->getDisplayShippingTaxLines();
631 }
632
633 protected function normalizeOrderTaxRateMeta(array $meta, int $taxRateId): array
634 {
635 if (
636 array_key_exists('label', $meta) ||
637 array_key_exists('rate_percent', $meta) ||
638 array_key_exists('taxable_amount', $meta) ||
639 array_key_exists('is_compound', $meta)
640 ) {
641 return $meta;
642 }
643
644 $legacyRates = (array) Arr::get($meta, 'rates', []);
645 if (!$legacyRates) {
646 return $meta;
647 }
648
649 $legacyRateMeta = [];
650
651 foreach ($legacyRates as $legacyRate) {
652 if ((int) Arr::get($legacyRate, 'rate_id', 0) === $taxRateId) {
653 $legacyRateMeta = (array) $legacyRate;
654 break;
655 }
656 }
657
658 if (!$legacyRateMeta) {
659 $legacyRateMeta = (array) reset($legacyRates);
660 }
661
662 if (!$legacyRateMeta) {
663 return $meta;
664 }
665
666 return array_merge($meta, [
667 'label' => Arr::get($legacyRateMeta, 'label', ''),
668 'rate_percent' => (float) Arr::get($legacyRateMeta, 'rate_percent', Arr::get($legacyRateMeta, 'rate', 0)),
669 'taxable_amount' => (int) Arr::get($legacyRateMeta, 'taxable_amount', 0),
670 'is_compound' => (bool) Arr::get($legacyRateMeta, 'is_compound', false),
671 'inclusive' => Arr::get($legacyRateMeta, 'inclusive', null),
672 'is_mixed_inclusive' => (bool) Arr::get($legacyRateMeta, 'is_mixed_inclusive', false),
673 ]);
674 }
675
676 public function getCustomerTaxNumber(): string
677 {
678 $businessInfoTaxNumber = (string) Arr::get($this->getBusinessInfo(), 'tax_number', '');
679 if (!empty($businessInfoTaxNumber)) {
680 return $businessInfoTaxNumber;
681 }
682
683 $legacyTaxNumber = (string) $this->getMeta('vat_tax_id', '');
684 if (!empty($legacyTaxNumber)) {
685 return $legacyTaxNumber;
686 }
687
688 $orderTaxRate = $this->getPrimaryOrderTaxRate();
689
690 return (string) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.vat_number', '');
691 }
692
693 public function getTaxSummaryAttribute(): array
694 {
695 return TaxSummaryHelper::computeTaxSummary($this);
696 }
697
698 public function getBusinessInfoAttribute(): array
699 {
700 return $this->getBusinessInfo();
701 }
702
703 public function getIsReverseChargeTaxOrderAttribute(): bool
704 {
705 return $this->isReverseChargeTaxOrder();
706 }
707
708 public function getCustomerTaxNumberAttribute(): string
709 {
710 return $this->getCustomerTaxNumber();
711 }
712
713 public function hasValidatedCustomerTaxNumber(): bool
714 {
715 $businessInfo = $this->getBusinessInfo();
716 if (!empty($businessInfo['tax_number'])) {
717 return (bool) Arr::get($businessInfo, 'tax_number_validated', false);
718 }
719
720 $orderTaxRate = $this->getPrimaryOrderTaxRate();
721
722 return (bool) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.valid', false);
723 }
724
725 public function getCustomerTaxName(): string
726 {
727 $businessInfoTaxName = (string) Arr::get($this->getBusinessInfo(), 'tax_number_name', '');
728 if (!empty($businessInfoTaxName)) {
729 return $businessInfoTaxName;
730 }
731
732 $orderTaxRate = $this->getPrimaryOrderTaxRate();
733
734 return (string) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.name', '');
735 }
736
737 public function getTotalPaidAmount()
738 {
739 return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total');
740 }
741
742 public function getTotalRefundAmount()
743 {
744 return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total');
745 }
746
747 public function recountTotalPaidAndRefund()
748 {
749 $totalPaid = $this->getTotalPaidAmount();
750 $totalRefunded = $this->getTotalRefundAmount();
751
752 $this->total_refund = $totalRefunded;
753
754 if (floatval($totalRefunded) >= floatval($totalPaid)) {
755 $this->payment_status = Status::PAYMENT_REFUNDED;
756 } elseif ($totalPaid > $totalRefunded) {
757 $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED;
758 }
759
760 $this->save();
761
762 return $this;
763 }
764
765 public function syncOrderAfterRefund($type, $refundedAmount)
766 {
767 $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED;
768 $this->total_refund += $refundedAmount;
769 $this->payment_status = $paymentStatus;
770
771 $this->save();
772
773 return $this;
774 }
775
776 public function updateRefundedItems($refundedItemIds, $refundedAmount)
777 {
778 // these are order item ids
779 $totalItems = count($refundedItemIds);
780
781 if ($totalItems === 1) {
782 $orderItem = OrderItem::find($refundedItemIds[0]);
783 $orderItem->refund_total += $refundedAmount;
784 $orderItem->save();
785 return;
786 }
787
788 if ($totalItems === 0) {
789 // get all order items
790 $refundedItemIds = $this->order_items->pluck('id')->toArray();
791 $totalItems = count($refundedItemIds);
792 }
793
794
795 // Calculate remaining amount for each item
796 $items = [];
797 $totalRemain = 0;
798 foreach ($refundedItemIds as $itemId) {
799 $orderItem = OrderItem::find($itemId);
800 $remain = max(0, $orderItem->line_total - $orderItem->refund_total);
801 $items[] = [
802 'model' => $orderItem,
803 'remain' => $remain
804 ];
805 $totalRemain += $remain;
806 }
807
808 if ($totalRemain == 0) {
809 // nothing to refund
810 return;
811 }
812
813 if ($totalRemain < $refundedAmount) {
814 $refundedAmount = $totalRemain;
815 }
816
817 // Distribute refund proportionally
818 $distributed = 0;
819 foreach ($items as $index => $item) {
820 if ($index === count($items) - 1) {
821 // Assign the rest to the last item to avoid rounding issues
822 $amount = $refundedAmount - $distributed;
823 } else {
824 $amount = round($refundedAmount * ($item['remain'] / $totalRemain), 2);
825 $distributed += $amount;
826 }
827 $item['model']->refund_total += $amount;
828 $item['model']->save();
829 }
830 }
831
832 public function recountTotalPaid()
833 {
834 $totalPaid = $this->getTotalPaidAmount();
835 $totalRefunded = $this->getTotalRefundAmount();
836
837 $this->total_paid = ($totalPaid - $totalRefunded) < 0 ? 0 : $totalPaid - $totalRefunded;
838 $this->save();
839 return $this;
840 }
841
842 /**
843 * Get the order's label.
844 */
845 public function labels(): MorphMany
846 {
847 return $this->morphMany(LabelRelationship::class, 'labelable');
848 }
849
850 public function getLatestTransactionAttribute()
851 {
852 return OrderTransaction::query()->where('order_id', $this->id)
853 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
854 ->where('status', '!=', Status::TRANSACTION_REFUNDED)
855 ->orderBy('id', 'DESC')
856 ->first();
857 }
858
859 public function renewals(): HasMany
860 {
861 return $this
862 ->hasMany(Order::class, 'parent_id', 'id')
863 ->where('type', 'renewal')
864 ->wherenotIn('status', [
865 Status::ORDER_CANCELED,
866 Status::ORDER_FAILED,
867 Status::ORDER_ON_HOLD
868 ]);
869 }
870
871 public function isSubscription(): bool
872 {
873 return $this->order_items->where('payment_type', 'subscription')->count() > 0;
874 }
875
876
877 public function getViewUrl($type = 'customer')
878 {
879
880 if ($type === 'admin') {
881 return URL::getDashboardUrl('orders/' . $this->id . '/view');
882 }
883
884 return TemplateService::getCustomerProfileUrl('order/' . $this->uuid);
885 }
886
887 public function getLatestTransaction()
888 {
889 return OrderTransaction::query()
890 ->where('order_id', $this->id)
891 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
892 ->orderBy('id', 'DESC')
893 ->first();
894 }
895
896 public function currentSubscription(): ?Subscription
897 {
898 return Subscription::query()
899 ->where('parent_order_id', $this->id)
900 ->where('status', 'active')
901 ->orderBy('id', 'DESC')
902 ->first();
903 }
904
905 public function getDownloads($scope = 'email'): array
906 {
907 if (!in_array($this->status, Status::getOrderSuccessStatuses())) {
908 return [];
909 }
910
911 $order = $this->load('order_items');
912
913 if ($order->order_items->isEmpty()) {
914 return [];
915 }
916
917 $productIds = $order->order_items->pluck('post_id')->unique()->values();
918 $productDownloads = ProductDownload::query()->whereIn('post_id', $productIds)->get()->keyBy('id');
919
920 $groupedDownload = $productDownloads->groupBy('post_id');
921
922 $downloadData = [];
923
924 $alreadyAdded = [];
925
926 foreach ($order->order_items as $item) {
927 if (in_array($item->payment_type, ['signup_fee', 'fee'])) {
928 continue;
929 }
930
931 $availableDownloads = Arr::get($groupedDownload, $item->post_id, []);
932
933 $authorizedDownloads = [];
934
935 foreach ($availableDownloads as $download) {
936
937 $ids = $download->product_variation_id;
938
939 if (in_array($download->id, $alreadyAdded)) {
940 continue;
941 }
942 if (!is_array($ids) || empty($ids) || in_array($item->object_id, $ids)) {
943
944 $authorizedDownloads [] =
945 [
946 'download_url' => Helper::generateDownloadFileLink($download, $order->id),
947 'title' => $download->title,
948 'file_size' => $download->file_size,
949 'formatted_file_size' => Helper::readableFileSize($download->file_size),
950 ];
951
952 $alreadyAdded[] = $download->id;
953 }
954 }
955
956 if (!empty($authorizedDownloads)) {
957 $downloadData[] = [
958 'title' => $item->post_title . ' - ' . $item->title, // 'product name - variation title',
959 'product_id' => $item->post_id,
960 'variation_id' => $item->object_id,
961 'additional_html' => '',
962 'downloads' => $authorizedDownloads
963 ];
964 }
965 }
966
967
968 return apply_filters('fluent_cart/single_order_downloads', $downloadData, [
969 'order' => $order,
970 'scope' => $scope
971 ]);
972 }
973
974 public function getLicenses($with = ['product','productVariant'])
975 {
976 if (!ModuleSettings::isActive('license') || !App::isProActive()) {
977 return null;
978 }
979
980 return License::query()->where('order_id', $this->id)
981 ->with($with)
982 ->get();
983 }
984
985 public function getDownloadsById($orderId): array
986 {
987 if (empty($orderId)) {
988 return [];
989 }
990
991 $order = Order::query()->with('order_items')->find($orderId);
992
993 if (empty($order)) {
994 return [];
995 }
996
997 return $order->getDownloads();
998 }
999
1000 public function getReceiptViewUrl()
1001 {
1002 return add_query_arg([
1003 'fluent-cart' => 'receipt',
1004 'order_hash' => $this->uuid,
1005 ], home_url());
1006 }
1007
1008 public function getReceiptDownloadUrl()
1009 {
1010 return add_query_arg(['download' => 1], $this->getReceiptViewUrl());
1011 }
1012
1013 public function addLog($title, $description = '', $type = 'info', $by = '')
1014 {
1015
1016 fluent_cart_add_log(
1017 $title,
1018 $description,
1019 $type,
1020 [
1021 'module_type' => 'FluentCart\App\Models\Order',
1022 'module_id' => $this->id,
1023 'module_name' => 'Order',
1024 'created_by' => $by
1025 ]
1026 );
1027 }
1028
1029 public function canBeRefunded(): bool
1030 {
1031 $config = $this->config;
1032 $upgradeTo = Arr::get($config, 'upgraded_to', 0);
1033 if (!empty($upgradeTo)) {
1034 return false;
1035 }
1036 return true;
1037 }
1038
1039 public function generateReceiptNumber()
1040 {
1041 if ($this->receipt_number) {
1042 return $this;
1043 }
1044
1045 // Re-check from database — another process may have already generated the number
1046 $fresh = static::query()
1047 ->where('id', $this->id)
1048 ->select(['id', 'receipt_number', 'invoice_no'])
1049 ->first();
1050
1051 if ($fresh && $fresh->receipt_number) {
1052 $this->receipt_number = $fresh->receipt_number;
1053 $this->invoice_no = $fresh->invoice_no;
1054 return $this;
1055 }
1056
1057 // Note: if a concurrent request wins the claim below, this number goes unused
1058 // and creates a gap in the receipt sequence. Gaps are acceptable — correctness
1059 // (no duplicates) is the priority, and the primary guard in StatusHelper
1060 // prevents this race path from being reached in normal operation.
1061 $receiptNumber = OrderService::getNextReceiptNumber();
1062 $invoiceNo = OrderService::getInvoicePrefix() . $receiptNumber;
1063
1064 // Atomic: only set receipt_number if still NULL in the database.
1065 // Prevents duplicate receipt numbers when concurrent requests
1066 // (webhook + browser confirmation) race to generate one.
1067 // invoice_no defaults to '' (empty string) in the schema, not NULL,
1068 // so we must match both NULL and '' to correctly identify unset invoices.
1069 $claimed = static::query()
1070 ->where('id', $this->id)
1071 ->whereNull('receipt_number')
1072 ->where(function ($q) {
1073 $q->whereNull('invoice_no')->orWhere('invoice_no', '');
1074 })
1075 ->update([
1076 'receipt_number' => $receiptNumber,
1077 'invoice_no' => $invoiceNo,
1078 ]);
1079
1080 if ($claimed) {
1081 $this->receipt_number = $receiptNumber;
1082 $this->invoice_no = $invoiceNo;
1083
1084 do_action('fluent_cart/order/invoice_number_added', [
1085 'order' => $this
1086 ]);
1087 } else {
1088 // Another process already generated it — use theirs
1089 $fresh = static::query()
1090 ->where('id', $this->id)
1091 ->select(['id', 'receipt_number', 'invoice_no'])
1092 ->first();
1093
1094 if ($fresh && $fresh->receipt_number) {
1095 $this->receipt_number = $fresh->receipt_number;
1096 $this->invoice_no = $fresh->invoice_no;
1097 }
1098 }
1099
1100 return $this;
1101 }
1102
1103 public function orderOperation(): HasOne
1104 {
1105 return $this->hasOne(OrderOperation::class, 'order_id', 'id');
1106 }
1107
1108 public function canBeDeleted()
1109 {
1110 $canBeDeleted = true;
1111
1112
1113 if($this->mode !== Status::ORDER_MODE_TEST){
1114 // Only canceled or on-hold orders can be deleted
1115 if ($this->status === Status::ORDER_CANCELED || $this->status === Status::ORDER_ON_HOLD) {
1116
1117 $isFreeOrder = ((int)$this->total_amount) === 0;
1118 $isPaidOrder = in_array($this->payment_status, Status::getOrderPaymentSuccessStatuses(), true);
1119
1120 // Free orders OR canceled unpaid orders can be deleted
1121 if ($isPaidOrder && !$isFreeOrder) {
1122 $canBeDeleted = new \WP_Error(
1123 'order_cannot_be_deleted',
1124 sprintf(
1125 /* translators: 1: order/invoice number, 2: payment status */
1126 __('Order %1$s cannot be deleted due to its current payment status: %2$s.', 'fluent-cart'),
1127 $this->invoice_no,
1128 $this->payment_status
1129 )
1130 );
1131 }
1132
1133 } else {
1134 $canBeDeleted = new \WP_Error(
1135 'order_cannot_be_deleted',
1136 sprintf(
1137 /* translators: 1: order/invoice number, 2: order status */
1138 __('Order %1$s cannot be deleted due to its current order status: %2$s.', 'fluent-cart'),
1139 $this->invoice_no,
1140 $this->status
1141 )
1142 );
1143 }
1144 }
1145
1146
1147
1148 if (!is_wp_error($canBeDeleted) && $this->mode !== Status::ORDER_MODE_TEST) {
1149 // Handle subscription relationship
1150 $parentOrderId = $this->parent_id ? $this->parent_id : $this->id;
1151
1152 $subscription = Subscription::query()
1153 ->where('parent_order_id', $parentOrderId)
1154 ->first();
1155
1156 // If subscription is active, prevent deletion
1157 if (
1158 $subscription &&
1159 $subscription->status === Status::SUBSCRIPTION_ACTIVE &&
1160 $this->type === 'subscription'
1161 ) {
1162 $canBeDeleted = new \WP_Error(
1163 'order_cannot_be_deleted',
1164 sprintf(
1165 /* translators: %s is the order/invoice number */
1166 __('Order %s cannot be deleted as it has an active subscription.', 'fluent-cart'),
1167 $this->invoice_no
1168 )
1169 );
1170 }
1171 }
1172
1173
1174 return apply_filters('fluent_cart/order_can_be_deleted', $canBeDeleted, [
1175 'order' => $this
1176 ]);
1177 }
1178
1179
1180 }
1181