PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 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 All 50 releases
← All changes | app/Models/Order.php +102 -2 1.6.1 → 1.7.0 View file →
@@ -56,8 +56,18 @@
56 56 }
57 57 });
58 58
59 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 +
60 70 if ($model->invoice_no) {
61 71 do_action('fluent_cart/order/invoice_number_added', [
62 72 'order' => $model
63 73 ]);
@@ -762,9 +772,18 @@
762 772 }
763 773
764 774 public function getBusinessInfoAttribute(): array
765 775 {
766 - return $this->getBusinessInfo();
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;
767 786 }
768 787
769 788 public function getIsReverseChargeTaxOrderAttribute(): bool
770 789 {
@@ -804,8 +823,40 @@
804 823 {
805 824 return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total');
806 825 }
807 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 +
808 859 public function getTotalRefundAmount()
809 860 {
810 861 return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total');
811 862 }
@@ -816,9 +867,13 @@
816 867 $totalRefunded = $this->getTotalRefundAmount();
817 868
818 869 $this->total_refund = $totalRefunded;
819 870
820 - 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)) {
821 876 $this->payment_status = Status::PAYMENT_REFUNDED;
822 877 } elseif ($totalPaid > $totalRefunded) {
823 878 $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED;
824 879 }
@@ -833,8 +888,12 @@
833 888 $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED;
834 889 $this->total_refund += $refundedAmount;
835 890 $this->payment_status = $paymentStatus;
836 891
892 + if ($paymentStatus === Status::PAYMENT_REFUNDED && !$this->refunded_at) {
893 + $this->refunded_at = DateTime::gmtNow();
894 + }
895 +
837 896 $this->save();
838 897
839 898 return $this;
840 899 }
@@ -1073,8 +1132,49 @@
1073 1132
1074 1133 public function getReceiptDownloadUrl()
1075 1134 {
1076 1135 return add_query_arg(['download' => 1], $this->getReceiptViewUrl());
1136 + }
1137 +
1138 + /**
1139 + * The public "review your order" page for this order.
1140 + *
1141 + * Same shape as the receipt URL, and the same credential: the order uuid.
1142 + * That is what lets the link work in an email to a guest-checkout buyer,
1143 + * who has no account to log into — see
1144 + * ProductReviewService::resolveOrderGrant().
1145 + *
1146 + * @return string
1147 + */
1148 + public function getOrderReviewUrl()
1149 + {
1150 + return static::reviewUrlForHash((string) $this->uuid);
1151 + }
1152 +
1153 + /**
1154 + * The order-review URL for an order hash, without loading the order —
1155 + * the email parsers already hold the hash.
1156 + *
1157 + * @param string $orderHash
1158 + * @return string
1159 + */
1160 + public static function reviewUrlForHash($orderHash)
1161 + {
1162 + // The store's chosen page wins when it has one: it carries the theme's
1163 + // header, footer and styling, which the query route deliberately does
1164 + // not. getOrderReviewPage() returns home_url() when nothing is picked,
1165 + // which is exactly when the route should answer instead.
1166 + $storeSettings = new \FluentCart\Api\StoreSettings();
1167 + $pageUrl = $storeSettings->getOrderReviewPage();
1168 +
1169 + if ($pageUrl && $pageUrl !== home_url()) {
1170 + return add_query_arg(['order_hash' => $orderHash], $pageUrl);
1171 + }
1172 +
1173 + return add_query_arg([
1174 + 'fluent-cart' => 'order-review',
1175 + 'order_hash' => $orderHash,
1176 + ], home_url());
1077 1177 }
1078 1178
1079 1179 public function addLog($title, $description = '', $type = 'info', $by = '')
1080 1180 {