PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Models/Order.php +396 -2 1.3.21 → 1.6.5 View file →
@@ -19,8 +19,9 @@
19 19 use FluentCart\Framework\Database\Orm\Relations\HasManyThrough;
20 20 use FluentCart\Framework\Database\Orm\Relations\HasOne;
21 21 use FluentCart\Framework\Database\Orm\Relations\MorphMany;
22 22 use FluentCart\Framework\Support\Arr;
23 +use FluentCart\App\Services\Renderer\Receipt\TaxSummaryHelper;
23 24 use FluentCartPro\App\Modules\Licensing\Models\License;
24 25
25 26 /**
26 27 * Order Model - DB Model for Orders
@@ -41,9 +42,9 @@
41 42 {
42 43 parent::boot();
43 44 static::creating(function ($model) {
44 45 if (empty($model->uuid)) {
45 - $model->uuid = md5(time() . wp_generate_uuid4());
46 + $model->uuid = static::generateOrderUuid();
46 47 }
47 48
48 49 if (!isset($model->config)) {
49 50 $model->config = [];
@@ -55,8 +56,18 @@
55 56 }
56 57 });
57 58
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 +
59 70 if ($model->invoice_no) {
60 71 do_action('fluent_cart/order/invoice_number_added', [
61 72 'order' => $model
62 73 ]);
@@ -63,8 +74,59 @@
63 74 }
64 75 });
65 76 }
66 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 +
67 129 protected $fillable = [
68 130 'status',
69 131 'parent_id',
70 132 'invoice_no',
@@ -119,8 +181,9 @@
119 181 'shipping_tax' => 'double',
120 182 'shipping_total' => 'double',
121 183 'fee_total' => 'double',
122 184 'tax_total' => 'double',
185 + 'tax_behavior' => 'integer',
123 186 'total_amount' => 'double',
124 187 'customer_id' => 'integer',
125 188 ];
126 189
@@ -464,13 +527,336 @@
464 527 ->where('meta_key', $metaKey)
465 528 ->delete();
466 529 }
467 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 +
468 822 public function getTotalPaidAmount()
469 823 {
470 824 return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total');
471 825 }
472 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 +
473 859 public function getTotalRefundAmount()
474 860 {
475 861 return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total');
476 862 }
@@ -481,9 +867,13 @@
481 867 $totalRefunded = $this->getTotalRefundAmount();
482 868
483 869 $this->total_refund = $totalRefunded;
484 870
485 - if (floatval($totalRefunded) >= floatval($totalPaid)) {
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)) {
486 876 $this->payment_status = Status::PAYMENT_REFUNDED;
487 877 } elseif ($totalPaid > $totalRefunded) {
488 878 $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED;
489 879 }
@@ -497,8 +887,12 @@
497 887 {
498 888 $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED;
499 889 $this->total_refund += $refundedAmount;
500 890 $this->payment_status = $paymentStatus;
891 +
892 + if ($paymentStatus === Status::PAYMENT_REFUNDED && !$this->refunded_at) {
893 + $this->refunded_at = DateTime::gmtNow();
894 + }
501 895
502 896 $this->save();
503 897
504 898 return $this;