| @@ -772,9 +772,18 @@ | ||
| 772 | 772 | } |
| 773 | 773 | |
| 774 | 774 | public function getBusinessInfoAttribute(): array |
| 775 | 775 | { |
| 776 | - 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; | |
| 777 | 786 | } |
| 778 | 787 | |
| 779 | 788 | public function getIsReverseChargeTaxOrderAttribute(): bool |
| 780 | 789 | { |
| @@ -814,8 +823,40 @@ | ||
| 814 | 823 | { |
| 815 | 824 | return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total'); |
| 816 | 825 | } |
| 817 | 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 | + | |
| 818 | 859 | public function getTotalRefundAmount() |
| 819 | 860 | { |
| 820 | 861 | return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total'); |
| 821 | 862 | } |
| @@ -826,9 +867,13 @@ | ||
| 826 | 867 | $totalRefunded = $this->getTotalRefundAmount(); |
| 827 | 868 | |
| 828 | 869 | $this->total_refund = $totalRefunded; |
| 829 | 870 | |
| 830 | - 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)) { | |
| 831 | 876 | $this->payment_status = Status::PAYMENT_REFUNDED; |
| 832 | 877 | } elseif ($totalPaid > $totalRefunded) { |
| 833 | 878 | $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED; |
| 834 | 879 | } |
| @@ -843,8 +888,12 @@ | ||
| 843 | 888 | $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED; |
| 844 | 889 | $this->total_refund += $refundedAmount; |
| 845 | 890 | $this->payment_status = $paymentStatus; |
| 846 | 891 | |
| 892 | + if ($paymentStatus === Status::PAYMENT_REFUNDED && !$this->refunded_at) { | |
| 893 | + $this->refunded_at = DateTime::gmtNow(); | |
| 894 | + } | |
| 895 | + | |
| 847 | 896 | $this->save(); |
| 848 | 897 | |
| 849 | 898 | return $this; |
| 850 | 899 | } |
| @@ -1083,8 +1132,49 @@ | ||
| 1083 | 1132 | |
| 1084 | 1133 | public function getReceiptDownloadUrl() |
| 1085 | 1134 | { |
| 1086 | 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()); | |
| 1087 | 1177 | } |
| 1088 | 1178 | |
| 1089 | 1179 | public function addLog($title, $description = '', $type = 'info', $by = '') |
| 1090 | 1180 | { |