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

1,196 lines 37.9 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 // Capture clean label (name + percent, no base suffix) for the shared folded-row builder.
568 $rateLabelClean = $displayLabel;
569
570 if ($taxableAmount > 0 && !$isMixedInclusive) {
571 if ($lineInclusive === null) {
572 $displayBase = $this->tax_behavior == 2
573 ? max(0, $taxableAmount - $rateTaxAmount)
574 : $taxableAmount;
575 } else {
576 $displayBase = $lineInclusive
577 ? max(0, $taxableAmount - $rateTaxAmount)
578 : $taxableAmount;
579 }
580
581 $displayLabel .= ' ' . sprintf(
582 __('on %s', 'fluent-cart'),
583 html_entity_decode(Helper::toDecimal($displayBase), ENT_QUOTES, 'UTF-8')
584 );
585 }
586
587 $displayTaxLines[] = [
588 'label' => $displayLabel,
589 'rate_label' => $rateLabelClean,
590 'order_tax' => $rateTaxAmount,
591 'total_tax' => (int) $orderTaxRate->total_tax,
592 'rate_id' => (int) $orderTaxRate->tax_rate_id,
593 'rate_percent' => $ratePercent,
594 'taxable_amount' => isset($displayBase) ? (int) $displayBase : $taxableAmount,
595 'inclusive' => $lineInclusive === null ? ((int) $this->tax_behavior === 2) : (bool) $lineInclusive,
596 ];
597 unset($displayBase);
598 }
599
600 return $displayTaxLines;
601 }
602
603 public function getDisplayTaxLinesAttribute(): array
604 {
605 return $this->getDisplayTaxLines();
606 }
607
608 public function getDisplayShippingTaxLines(): array
609 {
610 $displayLines = [];
611 foreach ($this->orderTaxRates ?: [] as $orderTaxRate) {
612 $shippingTax = (int) $orderTaxRate->shipping_tax;
613 if ($shippingTax <= 0) {
614 continue;
615 }
616 $meta = $this->normalizeOrderTaxRateMeta((array) $orderTaxRate->meta, (int) $orderTaxRate->tax_rate_id);
617 $ratePercent = (float) Arr::get($meta, 'rate_percent', 0);
618 $label = trim((string) Arr::get($meta, 'label', ''));
619 $rateName = $label ?: __('Tax', 'fluent-cart');
620 if ($ratePercent > 0) {
621 $formattedRatePercent = Helper::formatTaxRatePercent($ratePercent);
622 /* translators: %1$s: tax rate name e.g. "VAT", %2$s: rate percentage e.g. "19" */
623 $displayLabel = sprintf(__('%1$s (%2$s%%) on shipping', 'fluent-cart'), $rateName, $formattedRatePercent);
624 } else {
625 /* translators: %1$s: tax rate name e.g. "VAT" */
626 $displayLabel = sprintf(__('%1$s on shipping', 'fluent-cart'), $rateName);
627 }
628 $displayLines[] = [
629 'label' => $displayLabel,
630 'shipping_tax' => $shippingTax,
631 'rate_id' => (int) $orderTaxRate->tax_rate_id,
632 'rate_percent' => $ratePercent,
633 ];
634 }
635 return $displayLines;
636 }
637
638 public function getDisplayShippingTaxLinesAttribute(): array
639 {
640 return $this->getDisplayShippingTaxLines();
641 }
642
643 protected function normalizeOrderTaxRateMeta(array $meta, int $taxRateId): array
644 {
645 if (
646 array_key_exists('label', $meta) ||
647 array_key_exists('rate_percent', $meta) ||
648 array_key_exists('taxable_amount', $meta) ||
649 array_key_exists('is_compound', $meta)
650 ) {
651 return $meta;
652 }
653
654 $legacyRates = (array) Arr::get($meta, 'rates', []);
655 if (!$legacyRates) {
656 return $meta;
657 }
658
659 $legacyRateMeta = [];
660
661 foreach ($legacyRates as $legacyRate) {
662 if ((int) Arr::get($legacyRate, 'rate_id', 0) === $taxRateId) {
663 $legacyRateMeta = (array) $legacyRate;
664 break;
665 }
666 }
667
668 if (!$legacyRateMeta) {
669 $legacyRateMeta = (array) reset($legacyRates);
670 }
671
672 if (!$legacyRateMeta) {
673 return $meta;
674 }
675
676 return array_merge($meta, [
677 'label' => Arr::get($legacyRateMeta, 'label', ''),
678 'rate_percent' => (float) Arr::get($legacyRateMeta, 'rate_percent', Arr::get($legacyRateMeta, 'rate', 0)),
679 'taxable_amount' => (int) Arr::get($legacyRateMeta, 'taxable_amount', 0),
680 'is_compound' => (bool) Arr::get($legacyRateMeta, 'is_compound', false),
681 'inclusive' => Arr::get($legacyRateMeta, 'inclusive', null),
682 'is_mixed_inclusive' => (bool) Arr::get($legacyRateMeta, 'is_mixed_inclusive', false),
683 ]);
684 }
685
686 public function getCustomerTaxNumber(): string
687 {
688 $businessInfoTaxNumber = (string) Arr::get($this->getBusinessInfo(), 'tax_number', '');
689 if (!empty($businessInfoTaxNumber)) {
690 return $businessInfoTaxNumber;
691 }
692
693 $legacyTaxNumber = (string) $this->getMeta('vat_tax_id', '');
694 if (!empty($legacyTaxNumber)) {
695 return $legacyTaxNumber;
696 }
697
698 $topLevelLegacyTaxId = (string) $this->getMeta('tax_id', '');
699 if (!empty($topLevelLegacyTaxId)) {
700 return $topLevelLegacyTaxId;
701 }
702
703 $orderTaxRate = $this->getPrimaryOrderTaxRate();
704
705 return (string) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.vat_number', '');
706 }
707
708 public function getTaxSummaryAttribute(): array
709 {
710 return TaxSummaryHelper::computeTaxSummary($this);
711 }
712
713 public function getBusinessInfoAttribute(): array
714 {
715 return $this->getBusinessInfo();
716 }
717
718 public function getIsReverseChargeTaxOrderAttribute(): bool
719 {
720 return $this->isReverseChargeTaxOrder();
721 }
722
723 public function getCustomerTaxNumberAttribute(): string
724 {
725 return $this->getCustomerTaxNumber();
726 }
727
728 public function hasValidatedCustomerTaxNumber(): bool
729 {
730 $businessInfo = $this->getBusinessInfo();
731 if (!empty($businessInfo['tax_number'])) {
732 return (bool) Arr::get($businessInfo, 'tax_number_validated', false);
733 }
734
735 $orderTaxRate = $this->getPrimaryOrderTaxRate();
736
737 return (bool) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.valid', false);
738 }
739
740 public function getCustomerTaxName(): string
741 {
742 $businessInfoTaxName = (string) Arr::get($this->getBusinessInfo(), 'tax_number_name', '');
743 if (!empty($businessInfoTaxName)) {
744 return $businessInfoTaxName;
745 }
746
747 $orderTaxRate = $this->getPrimaryOrderTaxRate();
748
749 return (string) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.name', '');
750 }
751
752 public function getTotalPaidAmount()
753 {
754 return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total');
755 }
756
757 public function getTotalRefundAmount()
758 {
759 return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total');
760 }
761
762 public function recountTotalPaidAndRefund()
763 {
764 $totalPaid = $this->getTotalPaidAmount();
765 $totalRefunded = $this->getTotalRefundAmount();
766
767 $this->total_refund = $totalRefunded;
768
769 if (floatval($totalRefunded) >= floatval($totalPaid)) {
770 $this->payment_status = Status::PAYMENT_REFUNDED;
771 } elseif ($totalPaid > $totalRefunded) {
772 $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED;
773 }
774
775 $this->save();
776
777 return $this;
778 }
779
780 public function syncOrderAfterRefund($type, $refundedAmount)
781 {
782 $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED;
783 $this->total_refund += $refundedAmount;
784 $this->payment_status = $paymentStatus;
785
786 $this->save();
787
788 return $this;
789 }
790
791 public function updateRefundedItems($refundedItemIds, $refundedAmount)
792 {
793 // these are order item ids
794 $totalItems = count($refundedItemIds);
795
796 if ($totalItems === 1) {
797 $orderItem = OrderItem::find($refundedItemIds[0]);
798 $orderItem->refund_total += $refundedAmount;
799 $orderItem->save();
800 return;
801 }
802
803 if ($totalItems === 0) {
804 // get all order items
805 $refundedItemIds = $this->order_items->pluck('id')->toArray();
806 $totalItems = count($refundedItemIds);
807 }
808
809
810 // Calculate remaining amount for each item
811 $items = [];
812 $totalRemain = 0;
813 foreach ($refundedItemIds as $itemId) {
814 $orderItem = OrderItem::find($itemId);
815 $remain = max(0, $orderItem->line_total - $orderItem->refund_total);
816 $items[] = [
817 'model' => $orderItem,
818 'remain' => $remain
819 ];
820 $totalRemain += $remain;
821 }
822
823 if ($totalRemain == 0) {
824 // nothing to refund
825 return;
826 }
827
828 if ($totalRemain < $refundedAmount) {
829 $refundedAmount = $totalRemain;
830 }
831
832 // Distribute refund proportionally
833 $distributed = 0;
834 foreach ($items as $index => $item) {
835 if ($index === count($items) - 1) {
836 // Assign the rest to the last item to avoid rounding issues
837 $amount = $refundedAmount - $distributed;
838 } else {
839 $amount = round($refundedAmount * ($item['remain'] / $totalRemain), 2);
840 $distributed += $amount;
841 }
842 $item['model']->refund_total += $amount;
843 $item['model']->save();
844 }
845 }
846
847 public function recountTotalPaid()
848 {
849 $totalPaid = $this->getTotalPaidAmount();
850 $totalRefunded = $this->getTotalRefundAmount();
851
852 $this->total_paid = ($totalPaid - $totalRefunded) < 0 ? 0 : $totalPaid - $totalRefunded;
853 $this->save();
854 return $this;
855 }
856
857 /**
858 * Get the order's label.
859 */
860 public function labels(): MorphMany
861 {
862 return $this->morphMany(LabelRelationship::class, 'labelable');
863 }
864
865 public function getLatestTransactionAttribute()
866 {
867 return OrderTransaction::query()->where('order_id', $this->id)
868 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
869 ->where('status', '!=', Status::TRANSACTION_REFUNDED)
870 ->orderBy('id', 'DESC')
871 ->first();
872 }
873
874 public function renewals(): HasMany
875 {
876 return $this
877 ->hasMany(Order::class, 'parent_id', 'id')
878 ->where('type', 'renewal')
879 ->wherenotIn('status', [
880 Status::ORDER_CANCELED,
881 Status::ORDER_FAILED,
882 Status::ORDER_ON_HOLD
883 ]);
884 }
885
886 public function isSubscription(): bool
887 {
888 return $this->order_items->where('payment_type', 'subscription')->count() > 0;
889 }
890
891
892 public function getViewUrl($type = 'customer')
893 {
894
895 if ($type === 'admin') {
896 return URL::getDashboardUrl('orders/' . $this->id . '/view');
897 }
898
899 return TemplateService::getCustomerProfileUrl('order/' . $this->uuid);
900 }
901
902 public function getLatestTransaction()
903 {
904 return OrderTransaction::query()
905 ->where('order_id', $this->id)
906 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
907 ->orderBy('id', 'DESC')
908 ->first();
909 }
910
911 public function currentSubscription(): ?Subscription
912 {
913 return Subscription::query()
914 ->where('parent_order_id', $this->id)
915 ->where('status', 'active')
916 ->orderBy('id', 'DESC')
917 ->first();
918 }
919
920 public function getDownloads($scope = 'email'): array
921 {
922 if (!in_array($this->status, Status::getOrderSuccessStatuses())) {
923 return [];
924 }
925
926 $order = $this->load('order_items');
927
928 if ($order->order_items->isEmpty()) {
929 return [];
930 }
931
932 $productIds = $order->order_items->pluck('post_id')->unique()->values();
933 $productDownloads = ProductDownload::query()->whereIn('post_id', $productIds)->get()->keyBy('id');
934
935 $groupedDownload = $productDownloads->groupBy('post_id');
936
937 $downloadData = [];
938
939 $alreadyAdded = [];
940
941 foreach ($order->order_items as $item) {
942 if (in_array($item->payment_type, ['signup_fee', 'fee'])) {
943 continue;
944 }
945
946 $availableDownloads = Arr::get($groupedDownload, $item->post_id, []);
947
948 $authorizedDownloads = [];
949
950 foreach ($availableDownloads as $download) {
951
952 $ids = $download->product_variation_id;
953
954 if (in_array($download->id, $alreadyAdded)) {
955 continue;
956 }
957 if (!is_array($ids) || empty($ids) || in_array($item->object_id, $ids)) {
958
959 $authorizedDownloads [] =
960 [
961 'download_url' => Helper::generateDownloadFileLink($download, $order->id),
962 'title' => $download->title,
963 'file_size' => $download->file_size,
964 'formatted_file_size' => Helper::readableFileSize($download->file_size),
965 ];
966
967 $alreadyAdded[] = $download->id;
968 }
969 }
970
971 if (!empty($authorizedDownloads)) {
972 $downloadData[] = [
973 'title' => $item->post_title . ' - ' . $item->title, // 'product name - variation title',
974 'product_id' => $item->post_id,
975 'variation_id' => $item->object_id,
976 'additional_html' => '',
977 'downloads' => $authorizedDownloads
978 ];
979 }
980 }
981
982
983 return apply_filters('fluent_cart/single_order_downloads', $downloadData, [
984 'order' => $order,
985 'scope' => $scope
986 ]);
987 }
988
989 public function getLicenses($with = ['product','productVariant'])
990 {
991 if (!ModuleSettings::isActive('license') || !App::isProActive()) {
992 return null;
993 }
994
995 return License::query()->where('order_id', $this->id)
996 ->with($with)
997 ->get();
998 }
999
1000 public function getDownloadsById($orderId): array
1001 {
1002 if (empty($orderId)) {
1003 return [];
1004 }
1005
1006 $order = Order::query()->with('order_items')->find($orderId);
1007
1008 if (empty($order)) {
1009 return [];
1010 }
1011
1012 return $order->getDownloads();
1013 }
1014
1015 public function getReceiptViewUrl()
1016 {
1017 return add_query_arg([
1018 'fluent-cart' => 'receipt',
1019 'order_hash' => $this->uuid,
1020 ], home_url());
1021 }
1022
1023 public function getReceiptDownloadUrl()
1024 {
1025 return add_query_arg(['download' => 1], $this->getReceiptViewUrl());
1026 }
1027
1028 public function addLog($title, $description = '', $type = 'info', $by = '')
1029 {
1030
1031 fluent_cart_add_log(
1032 $title,
1033 $description,
1034 $type,
1035 [
1036 'module_type' => 'FluentCart\App\Models\Order',
1037 'module_id' => $this->id,
1038 'module_name' => 'Order',
1039 'created_by' => $by
1040 ]
1041 );
1042 }
1043
1044 public function canBeRefunded(): bool
1045 {
1046 $config = $this->config;
1047 $upgradeTo = Arr::get($config, 'upgraded_to', 0);
1048 if (!empty($upgradeTo)) {
1049 return false;
1050 }
1051 return true;
1052 }
1053
1054 public function generateReceiptNumber()
1055 {
1056 if ($this->receipt_number) {
1057 return $this;
1058 }
1059
1060 // Re-check from database — another process may have already generated the number
1061 $fresh = static::query()
1062 ->where('id', $this->id)
1063 ->select(['id', 'receipt_number', 'invoice_no'])
1064 ->first();
1065
1066 if ($fresh && $fresh->receipt_number) {
1067 $this->receipt_number = $fresh->receipt_number;
1068 $this->invoice_no = $fresh->invoice_no;
1069 return $this;
1070 }
1071
1072 // Note: if a concurrent request wins the claim below, this number goes unused
1073 // and creates a gap in the receipt sequence. Gaps are acceptable — correctness
1074 // (no duplicates) is the priority, and the primary guard in StatusHelper
1075 // prevents this race path from being reached in normal operation.
1076 $receiptNumber = OrderService::getNextReceiptNumber();
1077 $invoiceNo = OrderService::getInvoicePrefix() . $receiptNumber;
1078
1079 // Atomic: only set receipt_number if still NULL in the database.
1080 // Prevents duplicate receipt numbers when concurrent requests
1081 // (webhook + browser confirmation) race to generate one.
1082 // invoice_no defaults to '' (empty string) in the schema, not NULL,
1083 // so we must match both NULL and '' to correctly identify unset invoices.
1084 $claimed = static::query()
1085 ->where('id', $this->id)
1086 ->whereNull('receipt_number')
1087 ->where(function ($q) {
1088 $q->whereNull('invoice_no')->orWhere('invoice_no', '');
1089 })
1090 ->update([
1091 'receipt_number' => $receiptNumber,
1092 'invoice_no' => $invoiceNo,
1093 ]);
1094
1095 if ($claimed) {
1096 $this->receipt_number = $receiptNumber;
1097 $this->invoice_no = $invoiceNo;
1098
1099 do_action('fluent_cart/order/invoice_number_added', [
1100 'order' => $this
1101 ]);
1102 } else {
1103 // Another process already generated it — use theirs
1104 $fresh = static::query()
1105 ->where('id', $this->id)
1106 ->select(['id', 'receipt_number', 'invoice_no'])
1107 ->first();
1108
1109 if ($fresh && $fresh->receipt_number) {
1110 $this->receipt_number = $fresh->receipt_number;
1111 $this->invoice_no = $fresh->invoice_no;
1112 }
1113 }
1114
1115 return $this;
1116 }
1117
1118 public function orderOperation(): HasOne
1119 {
1120 return $this->hasOne(OrderOperation::class, 'order_id', 'id');
1121 }
1122
1123 public function canBeDeleted()
1124 {
1125 $canBeDeleted = true;
1126
1127
1128 if($this->mode !== Status::ORDER_MODE_TEST){
1129 // Only canceled or on-hold orders can be deleted
1130 if ($this->status === Status::ORDER_CANCELED || $this->status === Status::ORDER_ON_HOLD) {
1131
1132 $isFreeOrder = ((int)$this->total_amount) === 0;
1133 $isPaidOrder = in_array($this->payment_status, Status::getOrderPaymentSuccessStatuses(), true);
1134
1135 // Free orders OR canceled unpaid orders can be deleted
1136 if ($isPaidOrder && !$isFreeOrder) {
1137 $canBeDeleted = new \WP_Error(
1138 'order_cannot_be_deleted',
1139 sprintf(
1140 /* translators: 1: order/invoice number, 2: payment status */
1141 __('Order %1$s cannot be deleted due to its current payment status: %2$s.', 'fluent-cart'),
1142 $this->invoice_no,
1143 $this->payment_status
1144 )
1145 );
1146 }
1147
1148 } else {
1149 $canBeDeleted = new \WP_Error(
1150 'order_cannot_be_deleted',
1151 sprintf(
1152 /* translators: 1: order/invoice number, 2: order status */
1153 __('Order %1$s cannot be deleted due to its current order status: %2$s.', 'fluent-cart'),
1154 $this->invoice_no,
1155 $this->status
1156 )
1157 );
1158 }
1159 }
1160
1161
1162
1163 if (!is_wp_error($canBeDeleted) && $this->mode !== Status::ORDER_MODE_TEST) {
1164 // Handle subscription relationship
1165 $parentOrderId = $this->parent_id ? $this->parent_id : $this->id;
1166
1167 $subscription = Subscription::query()
1168 ->where('parent_order_id', $parentOrderId)
1169 ->first();
1170
1171 // If subscription is active, prevent deletion
1172 if (
1173 $subscription &&
1174 $subscription->status === Status::SUBSCRIPTION_ACTIVE &&
1175 $this->type === 'subscription'
1176 ) {
1177 $canBeDeleted = new \WP_Error(
1178 'order_cannot_be_deleted',
1179 sprintf(
1180 /* translators: %s is the order/invoice number */
1181 __('Order %s cannot be deleted as it has an active subscription.', 'fluent-cart'),
1182 $this->invoice_no
1183 )
1184 );
1185 }
1186 }
1187
1188
1189 return apply_filters('fluent_cart/order_can_be_deleted', $canBeDeleted, [
1190 'order' => $this
1191 ]);
1192 }
1193
1194
1195 }
1196