| @@ -42,9 +42,9 @@ | ||
| 42 | 42 | { |
| 43 | 43 | parent::boot(); |
| 44 | 44 | static::creating(function ($model) { |
| 45 | 45 | if (empty($model->uuid)) { |
| 46 | - $model->uuid = md5(time() . wp_generate_uuid4()); | |
| 46 | + $model->uuid = static::generateOrderUuid(); | |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | if (!isset($model->config)) { |
| 50 | 50 | $model->config = []; |
| @@ -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 | ]); |
| @@ -64,8 +74,59 @@ | ||
| 64 | 74 | } |
| 65 | 75 | }); |
| 66 | 76 | } |
| 67 | 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 | + | |
| 68 | 129 | protected $fillable = [ |
| 69 | 130 | 'status', |
| 70 | 131 | 'parent_id', |
| 71 | 132 | 'invoice_no', |
| @@ -711,9 +772,18 @@ | ||
| 711 | 772 | } |
| 712 | 773 | |
| 713 | 774 | public function getBusinessInfoAttribute(): array |
| 714 | 775 | { |
| 715 | - 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; | |
| 716 | 786 | } |
| 717 | 787 | |
| 718 | 788 | public function getIsReverseChargeTaxOrderAttribute(): bool |
| 719 | 789 | { |
| @@ -753,8 +823,40 @@ | ||
| 753 | 823 | { |
| 754 | 824 | return $this->transactions()->where('status', Status::TRANSACTION_SUCCEEDED)->sum('total'); |
| 755 | 825 | } |
| 756 | 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 | + | |
| 757 | 859 | public function getTotalRefundAmount() |
| 758 | 860 | { |
| 759 | 861 | return $this->transactions()->where('status', Status::TRANSACTION_REFUNDED)->sum('total'); |
| 760 | 862 | } |
| @@ -765,9 +867,13 @@ | ||
| 765 | 867 | $totalRefunded = $this->getTotalRefundAmount(); |
| 766 | 868 | |
| 767 | 869 | $this->total_refund = $totalRefunded; |
| 768 | 870 | |
| 769 | - 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)) { | |
| 770 | 876 | $this->payment_status = Status::PAYMENT_REFUNDED; |
| 771 | 877 | } elseif ($totalPaid > $totalRefunded) { |
| 772 | 878 | $this->payment_status = Status::PAYMENT_PARTIALLY_REFUNDED; |
| 773 | 879 | } |
| @@ -781,8 +887,12 @@ | ||
| 781 | 887 | { |
| 782 | 888 | $paymentStatus = $type == 'full' ? Status::PAYMENT_REFUNDED : Status::PAYMENT_PARTIALLY_REFUNDED; |
| 783 | 889 | $this->total_refund += $refundedAmount; |
| 784 | 890 | $this->payment_status = $paymentStatus; |
| 891 | + | |
| 892 | + if ($paymentStatus === Status::PAYMENT_REFUNDED && !$this->refunded_at) { | |
| 893 | + $this->refunded_at = DateTime::gmtNow(); | |
| 894 | + } | |
| 785 | 895 | |
| 786 | 896 | $this->save(); |
| 787 | 897 | |
| 788 | 898 | return $this; |