PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.2
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Models / Order.php

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

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