PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Models / Order.php

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

1,306 lines 42.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 = 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 $businessInfo = $this->getBusinessInfo();
777
778 // Only meaningful for a MoR reverse-charge order (no orderTaxRate row) — omit
779 // for every normal order instead of appending a redundant duplicate of total_paid.
780 $morVatRemoved = $this->getMoRVatRemovedAmount();
781 if ($morVatRemoved > 0) {
782 $businessInfo['net_total_paid'] = $this->netAmount((int) $this->total_paid);
783 }
784
785 return $businessInfo;
786 }
787
788 public function getIsReverseChargeTaxOrderAttribute(): bool
789 {
790 return $this->isReverseChargeTaxOrder();
791 }
792
793 public function getCustomerTaxNumberAttribute(): string
794 {
795 return $this->getCustomerTaxNumber();
796 }
797
798 public function hasValidatedCustomerTaxNumber(): bool
799 {
800 $businessInfo = $this->getBusinessInfo();
801 if (!empty($businessInfo['tax_number'])) {
802 return (bool) Arr::get($businessInfo, 'tax_number_validated', false);
803 }
804
805 $orderTaxRate = $this->getPrimaryOrderTaxRate();
806
807 return (bool) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.valid', false);
808 }
809
810 public function getCustomerTaxName(): string
811 {
812 $businessInfoTaxName = (string) Arr::get($this->getBusinessInfo(), 'tax_number_name', '');
813 if (!empty($businessInfoTaxName)) {
814 return $businessInfoTaxName;
815 }
816
817 $orderTaxRate = $this->getPrimaryOrderTaxRate();
818
819 return (string) Arr::get($orderTaxRate->meta ?? [], 'vat_reverse.name', '');
820 }
821
822 public function getTotalPaidAmount()
823 {
824 return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total');
825 }
826
827 /**
828 * Amount of VAT removed by a merchant-of-record gateway (e.g. Paddle) for a
829 * reverse-charge order that never ran the tax module (no `fct_order_tax_rate` row).
830 * Display-only — `total_amount`/`total_paid`/`txn.total` stay gross for such orders
831 * (cover invariant: the "fully paid" equality that drives due-amount checks, digital
832 * auto-complete, and dunning reminders depends on it), so this is never subtracted
833 * into the ledger, only into `getDisplayTotalPaid()` for the admin UI. Zero for
834 * core-handled reverse charge (tax-rate row present) — those are already net at
835 * creation time.
836 */
837 public function getMoRVatRemovedAmount(): int
838 {
839 if ($this->getPrimaryOrderTaxRate()) {
840 return 0;
841 }
842
843 return (int) Arr::get($this->getBusinessInfo(), 'mor_vat_removed', 0);
844 }
845
846 /**
847 * Nets a raw gross ledger figure (total_paid, a live paid-total sum, a refund amount)
848 * by the MoR VAT removal — see getMoRVatRemovedAmount(). Single formula for every
849 * "what did we actually collect/need to refund" comparison, so callers never
850 * reimplement the subtraction themselves. Never use for due-amount, digital
851 * auto-complete, or reminder logic; those must keep comparing the gross ledger
852 * columns (total_amount vs total_paid) so the "fully paid" equality still holds.
853 */
854 public function netAmount(int $amount): int
855 {
856 return max(0, $amount - $this->getMoRVatRemovedAmount());
857 }
858
859 public function getTotalRefundAmount()
860 {
861 return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total');
862 }
863
864 public function recountTotalPaidAndRefund()
865 {
866 $totalPaid = $this->getTotalPaidAmount();
867 $totalRefunded = $this->getTotalRefundAmount();
868
869 $this->total_refund = $totalRefunded;
870
871 // Net out MoR VAT removal so a full refund of the actually captured amount
872 // resolves to "fully refunded" — see netAmount().
873 $netTotalPaid = $this->netAmount($totalPaid);
874
875 if (floatval($totalRefunded) >= floatval($netTotalPaid)) {
876 $this->payment_status = Status::PAYMENT_REFUNDED;
877 } elseif ($totalPaid > $totalRefunded) {
878 $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED;
879 }
880
881 $this->save();
882
883 return $this;
884 }
885
886 public function syncOrderAfterRefund($type, $refundedAmount)
887 {
888 $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED;
889 $this->total_refund += $refundedAmount;
890 $this->payment_status = $paymentStatus;
891
892 if ($paymentStatus === Status::PAYMENT_REFUNDED && !$this->refunded_at) {
893 $this->refunded_at = DateTime::gmtNow();
894 }
895
896 $this->save();
897
898 return $this;
899 }
900
901 public function updateRefundedItems($refundedItemIds, $refundedAmount)
902 {
903 // these are order item ids
904 $totalItems = count($refundedItemIds);
905
906 if ($totalItems === 1) {
907 $orderItem = OrderItem::find($refundedItemIds[0]);
908 $orderItem->refund_total += $refundedAmount;
909 $orderItem->save();
910 return;
911 }
912
913 if ($totalItems === 0) {
914 // get all order items
915 $refundedItemIds = $this->order_items->pluck('id')->toArray();
916 $totalItems = count($refundedItemIds);
917 }
918
919
920 // Calculate remaining amount for each item
921 $items = [];
922 $totalRemain = 0;
923 foreach ($refundedItemIds as $itemId) {
924 $orderItem = OrderItem::find($itemId);
925 $remain = max(0, $orderItem->line_total - $orderItem->refund_total);
926 $items[] = [
927 'model' => $orderItem,
928 'remain' => $remain
929 ];
930 $totalRemain += $remain;
931 }
932
933 if ($totalRemain == 0) {
934 // nothing to refund
935 return;
936 }
937
938 if ($totalRemain < $refundedAmount) {
939 $refundedAmount = $totalRemain;
940 }
941
942 // Distribute refund proportionally
943 $distributed = 0;
944 foreach ($items as $index => $item) {
945 if ($index === count($items) - 1) {
946 // Assign the rest to the last item to avoid rounding issues
947 $amount = $refundedAmount - $distributed;
948 } else {
949 $amount = round($refundedAmount * ($item['remain'] / $totalRemain), 2);
950 $distributed += $amount;
951 }
952 $item['model']->refund_total += $amount;
953 $item['model']->save();
954 }
955 }
956
957 public function recountTotalPaid()
958 {
959 $totalPaid = $this->getTotalPaidAmount();
960 $totalRefunded = $this->getTotalRefundAmount();
961
962 $this->total_paid = ($totalPaid - $totalRefunded) < 0 ? 0 : $totalPaid - $totalRefunded;
963 $this->save();
964 return $this;
965 }
966
967 /**
968 * Get the order's label.
969 */
970 public function labels(): MorphMany
971 {
972 return $this->morphMany(LabelRelationship::class, 'labelable');
973 }
974
975 public function getLatestTransactionAttribute()
976 {
977 return OrderTransaction::query()->where('order_id', $this->id)
978 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
979 ->where('status', '!=', Status::TRANSACTION_REFUNDED)
980 ->orderBy('id', 'DESC')
981 ->first();
982 }
983
984 public function renewals(): HasMany
985 {
986 return $this
987 ->hasMany(Order::class, 'parent_id', 'id')
988 ->where('type', 'renewal')
989 ->wherenotIn('status', [
990 Status::ORDER_CANCELED,
991 Status::ORDER_FAILED,
992 Status::ORDER_ON_HOLD
993 ]);
994 }
995
996 public function isSubscription(): bool
997 {
998 return $this->order_items->where('payment_type', 'subscription')->count() > 0;
999 }
1000
1001
1002 public function getViewUrl($type = 'customer')
1003 {
1004
1005 if ($type === 'admin') {
1006 return URL::getDashboardUrl('orders/' . $this->id . '/view');
1007 }
1008
1009 return TemplateService::getCustomerProfileUrl('order/' . $this->uuid);
1010 }
1011
1012 public function getLatestTransaction()
1013 {
1014 return OrderTransaction::query()
1015 ->where('order_id', $this->id)
1016 ->where('transaction_type', '!=', Status::TRANSACTION_TYPE_REFUND)
1017 ->orderBy('id', 'DESC')
1018 ->first();
1019 }
1020
1021 public function currentSubscription(): ?Subscription
1022 {
1023 return Subscription::query()
1024 ->where('parent_order_id', $this->id)
1025 ->where('status', 'active')
1026 ->orderBy('id', 'DESC')
1027 ->first();
1028 }
1029
1030 public function getDownloads($scope = 'email'): array
1031 {
1032 if (!in_array($this->status, Status::getOrderSuccessStatuses())) {
1033 return [];
1034 }
1035
1036 $order = $this->load('order_items');
1037
1038 if ($order->order_items->isEmpty()) {
1039 return [];
1040 }
1041
1042 $productIds = $order->order_items->pluck('post_id')->unique()->values();
1043 $productDownloads = ProductDownload::query()->whereIn('post_id', $productIds)->get()->keyBy('id');
1044
1045 $groupedDownload = $productDownloads->groupBy('post_id');
1046
1047 $downloadData = [];
1048
1049 $alreadyAdded = [];
1050
1051 foreach ($order->order_items as $item) {
1052 if (in_array($item->payment_type, ['signup_fee', 'fee'])) {
1053 continue;
1054 }
1055
1056 $availableDownloads = Arr::get($groupedDownload, $item->post_id, []);
1057
1058 $authorizedDownloads = [];
1059
1060 foreach ($availableDownloads as $download) {
1061
1062 $ids = $download->product_variation_id;
1063
1064 if (in_array($download->id, $alreadyAdded)) {
1065 continue;
1066 }
1067 if (!is_array($ids) || empty($ids) || in_array($item->object_id, $ids)) {
1068
1069 $authorizedDownloads [] =
1070 [
1071 'download_url' => Helper::generateDownloadFileLink($download, $order->id),
1072 'title' => $download->title,
1073 'file_size' => $download->file_size,
1074 'formatted_file_size' => Helper::readableFileSize($download->file_size),
1075 ];
1076
1077 $alreadyAdded[] = $download->id;
1078 }
1079 }
1080
1081 if (!empty($authorizedDownloads)) {
1082 $downloadData[] = [
1083 'title' => $item->post_title . ' - ' . $item->title, // 'product name - variation title',
1084 'product_id' => $item->post_id,
1085 'variation_id' => $item->object_id,
1086 'additional_html' => '',
1087 'downloads' => $authorizedDownloads
1088 ];
1089 }
1090 }
1091
1092
1093 return apply_filters('fluent_cart/single_order_downloads', $downloadData, [
1094 'order' => $order,
1095 'scope' => $scope
1096 ]);
1097 }
1098
1099 public function getLicenses($with = ['product','productVariant'])
1100 {
1101 if (!ModuleSettings::isActive('license') || !App::isProActive()) {
1102 return null;
1103 }
1104
1105 return License::query()->where('order_id', $this->id)
1106 ->with($with)
1107 ->get();
1108 }
1109
1110 public function getDownloadsById($orderId): array
1111 {
1112 if (empty($orderId)) {
1113 return [];
1114 }
1115
1116 $order = Order::query()->with('order_items')->find($orderId);
1117
1118 if (empty($order)) {
1119 return [];
1120 }
1121
1122 return $order->getDownloads();
1123 }
1124
1125 public function getReceiptViewUrl()
1126 {
1127 return add_query_arg([
1128 'fluent-cart' => 'receipt',
1129 'order_hash' => $this->uuid,
1130 ], home_url());
1131 }
1132
1133 public function getReceiptDownloadUrl()
1134 {
1135 return add_query_arg(['download' => 1], $this->getReceiptViewUrl());
1136 }
1137
1138 public function addLog($title, $description = '', $type = 'info', $by = '')
1139 {
1140
1141 fluent_cart_add_log(
1142 $title,
1143 $description,
1144 $type,
1145 [
1146 'module_type' => 'FluentCart\App\Models\Order',
1147 'module_id' => $this->id,
1148 'module_name' => 'Order',
1149 'created_by' => $by
1150 ]
1151 );
1152 }
1153
1154 public function canBeRefunded(): bool
1155 {
1156 $config = $this->config;
1157 $upgradeTo = Arr::get($config, 'upgraded_to', 0);
1158 if (!empty($upgradeTo)) {
1159 return false;
1160 }
1161 return true;
1162 }
1163
1164 public function generateReceiptNumber()
1165 {
1166 if ($this->receipt_number) {
1167 return $this;
1168 }
1169
1170 // Re-check from database — another process may have already generated the number
1171 $fresh = static::query()
1172 ->where('id', $this->id)
1173 ->select(['id', 'receipt_number', 'invoice_no'])
1174 ->first();
1175
1176 if ($fresh && $fresh->receipt_number) {
1177 $this->receipt_number = $fresh->receipt_number;
1178 $this->invoice_no = $fresh->invoice_no;
1179 return $this;
1180 }
1181
1182 // Note: if a concurrent request wins the claim below, this number goes unused
1183 // and creates a gap in the receipt sequence. Gaps are acceptable — correctness
1184 // (no duplicates) is the priority, and the primary guard in StatusHelper
1185 // prevents this race path from being reached in normal operation.
1186 $receiptNumber = OrderService::getNextReceiptNumber();
1187 $invoiceNo = OrderService::getInvoicePrefix() . $receiptNumber;
1188
1189 // Atomic: only set receipt_number if still NULL in the database.
1190 // Prevents duplicate receipt numbers when concurrent requests
1191 // (webhook + browser confirmation) race to generate one.
1192 // invoice_no defaults to '' (empty string) in the schema, not NULL,
1193 // so we must match both NULL and '' to correctly identify unset invoices.
1194 $claimed = static::query()
1195 ->where('id', $this->id)
1196 ->whereNull('receipt_number')
1197 ->where(function ($q) {
1198 $q->whereNull('invoice_no')->orWhere('invoice_no', '');
1199 })
1200 ->update([
1201 'receipt_number' => $receiptNumber,
1202 'invoice_no' => $invoiceNo,
1203 ]);
1204
1205 if ($claimed) {
1206 $this->receipt_number = $receiptNumber;
1207 $this->invoice_no = $invoiceNo;
1208
1209 do_action('fluent_cart/order/invoice_number_added', [
1210 'order' => $this
1211 ]);
1212 } else {
1213 // Another process already generated it — use theirs
1214 $fresh = static::query()
1215 ->where('id', $this->id)
1216 ->select(['id', 'receipt_number', 'invoice_no'])
1217 ->first();
1218
1219 if ($fresh && $fresh->receipt_number) {
1220 $this->receipt_number = $fresh->receipt_number;
1221 $this->invoice_no = $fresh->invoice_no;
1222 }
1223 }
1224
1225 return $this;
1226 }
1227
1228 public function orderOperation(): HasOne
1229 {
1230 return $this->hasOne(OrderOperation::class, 'order_id', 'id');
1231 }
1232
1233 public function canBeDeleted()
1234 {
1235 $canBeDeleted = true;
1236
1237
1238 if($this->mode !== Status::ORDER_MODE_TEST){
1239 // Only canceled or on-hold orders can be deleted
1240 if ($this->status === Status::ORDER_CANCELED || $this->status === Status::ORDER_ON_HOLD) {
1241
1242 $isFreeOrder = ((int)$this->total_amount) === 0;
1243 $isPaidOrder = in_array($this->payment_status, Status::getOrderPaymentSuccessStatuses(), true);
1244
1245 // Free orders OR canceled unpaid orders can be deleted
1246 if ($isPaidOrder && !$isFreeOrder) {
1247 $canBeDeleted = new \WP_Error(
1248 'order_cannot_be_deleted',
1249 sprintf(
1250 /* translators: 1: order/invoice number, 2: payment status */
1251 __('Order %1$s cannot be deleted due to its current payment status: %2$s.', 'fluent-cart'),
1252 $this->invoice_no,
1253 $this->payment_status
1254 )
1255 );
1256 }
1257
1258 } else {
1259 $canBeDeleted = new \WP_Error(
1260 'order_cannot_be_deleted',
1261 sprintf(
1262 /* translators: 1: order/invoice number, 2: order status */
1263 __('Order %1$s cannot be deleted due to its current order status: %2$s.', 'fluent-cart'),
1264 $this->invoice_no,
1265 $this->status
1266 )
1267 );
1268 }
1269 }
1270
1271
1272
1273 if (!is_wp_error($canBeDeleted) && $this->mode !== Status::ORDER_MODE_TEST) {
1274 // Handle subscription relationship
1275 $parentOrderId = $this->parent_id ? $this->parent_id : $this->id;
1276
1277 $subscription = Subscription::query()
1278 ->where('parent_order_id', $parentOrderId)
1279 ->first();
1280
1281 // If subscription is active, prevent deletion
1282 if (
1283 $subscription &&
1284 $subscription->status === Status::SUBSCRIPTION_ACTIVE &&
1285 $this->type === 'subscription'
1286 ) {
1287 $canBeDeleted = new \WP_Error(
1288 'order_cannot_be_deleted',
1289 sprintf(
1290 /* translators: %s is the order/invoice number */
1291 __('Order %s cannot be deleted as it has an active subscription.', 'fluent-cart'),
1292 $this->invoice_no
1293 )
1294 );
1295 }
1296 }
1297
1298
1299 return apply_filters('fluent_cart/order_can_be_deleted', $canBeDeleted, [
1300 'order' => $this
1301 ]);
1302 }
1303
1304
1305 }
1306