| @@ -8,8 +8,9 @@ | ||
| 8 | 8 | use FluentCart\App\Models\Cart; |
| 9 | 9 | use FluentCart\App\Models\Order; |
| 10 | 10 | use FluentCart\App\Models\OrderTransaction; |
| 11 | 11 | use FluentCart\App\Models\Subscription; |
| 12 | +use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager; | |
| 12 | 13 | use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService; |
| 13 | 14 | use FluentCart\App\Services\DateTime\DateTime; |
| 14 | 15 | use FluentCart\App\Services\Payments\PaymentHelper; |
| 15 | 16 | use FluentCart\Framework\Support\Arr; |
| @@ -86,8 +87,37 @@ | ||
| 86 | 87 | 'order' => $this->order, |
| 87 | 88 | ]); |
| 88 | 89 | } |
| 89 | 90 | |
| 91 | + /** | |
| 92 | + * Backfills payment_method_title when it was never stamped at creation. | |
| 93 | + * | |
| 94 | + * Only changeOrderStatus() (the COD-only path) writes payment_method_title. | |
| 95 | + * Every other gateway settles via syncOrderStatuses(), which never touched | |
| 96 | + * it, so the column stays empty for those orders. | |
| 97 | + */ | |
| 98 | + protected function resolvePaymentMethodTitle() | |
| 99 | + { | |
| 100 | + $title = $this->order->payment_method_title; | |
| 101 | + if ($title) { | |
| 102 | + return $title; | |
| 103 | + } | |
| 104 | + | |
| 105 | + $slug = $this->order->payment_method; | |
| 106 | + if (!$slug || !class_exists(GatewayManager::class)) { | |
| 107 | + return $title; | |
| 108 | + } | |
| 109 | + | |
| 110 | + $gateway = GatewayManager::getInstance($slug); | |
| 111 | + if (!$gateway || !method_exists($gateway, 'getMeta')) { | |
| 112 | + return $title; | |
| 113 | + } | |
| 114 | + | |
| 115 | + $resolvedTitle = (string) $gateway->getMeta('title'); | |
| 116 | + | |
| 117 | + return $resolvedTitle !== '' ? $resolvedTitle : $title; | |
| 118 | + } | |
| 119 | + | |
| 90 | 120 | public function updateTotalPaid($amount) |
| 91 | 121 | { |
| 92 | 122 | $this->order->total_paid = intval($amount) + intval($this->order->total_paid); |
| 93 | 123 | if ($this->order->total_paid >= $this->order->total_amount) { |
| @@ -150,11 +180,20 @@ | ||
| 150 | 180 | ->sum('total'); |
| 151 | 181 | |
| 152 | 182 | $isFullyPaid = $this->order->total_amount <= ($transactionPaidTotal - $refundedTotal); |
| 153 | 183 | |
| 184 | + // total_paid stays gross for a MoR order (cover invariant — see | |
| 185 | + // Order::netAmount()); net it out here so a full refund of the actually | |
| 186 | + // captured amount resolves to "refunded" instead of being stuck at | |
| 187 | + // "partially_refunded" on every later idempotent resync (e.g. a Paddle webhook | |
| 188 | + // replay for the already-succeeded transaction). | |
| 189 | + $netPaidTotal = $this->order->netAmount($transactionPaidTotal); | |
| 190 | + | |
| 154 | 191 | $orderPaymentStatus = $this->order->payment_status; |
| 155 | 192 | if ($isFullyPaid) { |
| 156 | 193 | $orderPaymentStatus = Status::PAYMENT_PAID; |
| 194 | + } else if ($refundedTotal && $refundedTotal >= $netPaidTotal) { | |
| 195 | + $orderPaymentStatus = Status::PAYMENT_REFUNDED; | |
| 157 | 196 | } else if ($refundedTotal) { |
| 158 | 197 | $orderPaymentStatus = Status::PAYMENT_PARTIALLY_REFUNDED; |
| 159 | 198 | } |
| 160 | 199 | |
| @@ -167,12 +206,19 @@ | ||
| 167 | 206 | |
| 168 | 207 | $oldOrderStatus = $this->order->status; |
| 169 | 208 | $oldPaymentStatus = $this->order->payment_status; |
| 170 | 209 | |
| 210 | + $paymentMethodTitle = $this->resolvePaymentMethodTitle(); | |
| 211 | + | |
| 212 | + if ($orderPaymentStatus === Status::PAYMENT_REFUNDED && !$this->order->refunded_at) { | |
| 213 | + $this->order->refunded_at = DateTime::gmtNow(); | |
| 214 | + } | |
| 215 | + | |
| 171 | 216 | $this->order->status = $orderStatus; |
| 172 | 217 | $this->order->payment_status = $orderPaymentStatus; |
| 173 | 218 | $this->order->total_paid = $transactionPaidTotal; |
| 174 | 219 | $this->order->total_refund = $refundedTotal; |
| 220 | + $this->order->payment_method_title = $paymentMethodTitle; | |
| 175 | 221 | |
| 176 | 222 | // When transitioning to PAID, use an atomic UPDATE to prevent concurrent requests |
| 177 | 223 | // (e.g., payment gateway webhook + browser confirmation) from both processing |
| 178 | 224 | // the same payment — which would dispatch OrderPaid twice, generating duplicate |
| @@ -185,12 +231,13 @@ | ||
| 185 | 231 | $q->whereNull('payment_status') |
| 186 | 232 | ->orWhere('payment_status', '!=', Status::PAYMENT_PAID); |
| 187 | 233 | }) |
| 188 | 234 | ->update([ |
| 189 | - 'status' => $orderStatus, | |
| 190 | - 'payment_status' => $orderPaymentStatus, | |
| 191 | - 'total_paid' => $transactionPaidTotal, | |
| 192 | - 'total_refund' => $refundedTotal, | |
| 235 | + 'status' => $orderStatus, | |
| 236 | + 'payment_status' => $orderPaymentStatus, | |
| 237 | + 'total_paid' => $transactionPaidTotal, | |
| 238 | + 'total_refund' => $refundedTotal, | |
| 239 | + 'payment_method_title' => $paymentMethodTitle, | |
| 193 | 240 | ]); |
| 194 | 241 | |
| 195 | 242 | if (!$claimed) { |
| 196 | 243 | // Another process already transitioned this order to paid |