| @@ -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 | } |
| @@ -832,8 +887,12 @@ | ||
| 832 | 887 | { |
| 833 | 888 | $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED; |
| 834 | 889 | $this->total_refund += $refundedAmount; |
| 835 | 890 | $this->payment_status = $paymentStatus; |
| 891 | + | |
| 892 | + if ($paymentStatus === Status::PAYMENT_REFUNDED && !$this->refunded_at) { | |
| 893 | + $this->refunded_at = DateTime::gmtNow(); | |
| 894 | + } | |
| 836 | 895 | |
| 837 | 896 | $this->save(); |
| 838 | 897 | |
| 839 | 898 | return $this; |