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

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