PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.19 All 49 releases
← All changes | app/Helpers/StatusHelper.php +174 -5 1.4.2 → 1.6.5 View file →
@@ -3,12 +3,17 @@
3 3 namespace FluentCart\App\Helpers;
4 4
5 5 use FluentCart\App\Events\Order\OrderPaid;
6 6 use FluentCart\App\Events\Order\OrderStatusUpdated;
7 +use FluentCart\App\Events\Subscription\SubscriptionActivated;
7 8 use FluentCart\App\Models\Cart;
8 9 use FluentCart\App\Models\Order;
9 10 use FluentCart\App\Models\OrderTransaction;
11 +use FluentCart\App\Models\Subscription;
12 +use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
13 +use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
10 14 use FluentCart\App\Services\DateTime\DateTime;
15 +use FluentCart\App\Services\Payments\PaymentHelper;
11 16 use FluentCart\Framework\Support\Arr;
12 17
13 18
14 19 class StatusHelper
@@ -52,11 +57,67 @@
52 57 ];
53 58
54 59 (new OrderStatusUpdated($this->order, $oldStatus, $orderStatus, true, $actionActivity, 'order_status'))->dispatch();
55 60
61 + if (in_array($orderStatus, Status::getOrderSuccessStatuses())) {
62 + // Without this, the cart stays reusable, gets resurrected by the
63 + // logged-in user lookup and permanently blocks checkout with
64 + // "You have already completed this order."
65 + $this->completeRelatedCart();
66 + }
67 +
56 68 return $this;
57 69 }
58 70
71 + protected function completeRelatedCart()
72 + {
73 + $relatedCart = Cart::query()->where('order_id', $this->order->id)
74 + ->where('stage', '!=', 'completed')
75 + ->first();
76 +
77 + if (!$relatedCart) {
78 + return;
79 + }
80 +
81 + $relatedCart->stage = 'completed';
82 + $relatedCart->completed_at = DateTime::now()->format('Y-m-d H:i:s');
83 + $relatedCart->save();
84 +
85 + do_action('fluent_cart/cart_completed', [
86 + 'cart' => $relatedCart,
87 + 'order' => $this->order,
88 + ]);
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 +
59 120 public function updateTotalPaid($amount)
60 121 {
61 122 $this->order->total_paid = intval($amount) + intval($this->order->total_paid);
62 123 if ($this->order->total_paid >= $this->order->total_amount) {
@@ -70,9 +131,14 @@
70 131 }
71 132
72 133 public function triggerPaymentStatusActions($order, $paymentStatus)
73 134 {
74 - if (Status::PAYMENT_PAID === $paymentStatus) {
135 + // Initial orders only (payment / subscription). Renewal invoices are owned by
136 + // fluent_cart/renewal_paid — dispatching OrderPaid here would also fire the
137 + // async fluent_cart/order_paid_done on every renewal cycle, re-running the
138 + // new-order emails and integration feeds. Mirrors the same guard in
139 + // syncOrderStatuses().
140 + if (Status::PAYMENT_PAID === $paymentStatus && Status::ORDER_TYPE_RENEWAL !== $order->type) {
75 141 $transaction = OrderTransaction::query()->where('order_id', $order->id)
76 142 ->where('status', Status::TRANSACTION_SUCCEEDED)
77 143 ->first();
78 144 (new OrderPaid($order, $this->order->customer, $transaction))->dispatch();
@@ -114,11 +180,20 @@
114 180 ->sum('total');
115 181
116 182 $isFullyPaid = $this->order->total_amount <= ($transactionPaidTotal - $refundedTotal);
117 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 +
118 191 $orderPaymentStatus = $this->order->payment_status;
119 192 if ($isFullyPaid) {
120 193 $orderPaymentStatus = Status::PAYMENT_PAID;
194 + } else if ($refundedTotal && $refundedTotal >= $netPaidTotal) {
195 + $orderPaymentStatus = Status::PAYMENT_REFUNDED;
121 196 } else if ($refundedTotal) {
122 197 $orderPaymentStatus = Status::PAYMENT_PARTIALLY_REFUNDED;
123 198 }
124 199
@@ -131,12 +206,19 @@
131 206
132 207 $oldOrderStatus = $this->order->status;
133 208 $oldPaymentStatus = $this->order->payment_status;
134 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 +
135 216 $this->order->status = $orderStatus;
136 217 $this->order->payment_status = $orderPaymentStatus;
137 218 $this->order->total_paid = $transactionPaidTotal;
138 219 $this->order->total_refund = $refundedTotal;
220 + $this->order->payment_method_title = $paymentMethodTitle;
139 221
140 222 // When transitioning to PAID, use an atomic UPDATE to prevent concurrent requests
141 223 // (e.g., payment gateway webhook + browser confirmation) from both processing
142 224 // the same payment — which would dispatch OrderPaid twice, generating duplicate
@@ -149,12 +231,13 @@
149 231 $q->whereNull('payment_status')
150 232 ->orWhere('payment_status', '!=', Status::PAYMENT_PAID);
151 233 })
152 234 ->update([
153 - 'status' => $orderStatus,
154 - 'payment_status' => $orderPaymentStatus,
155 - 'total_paid' => $transactionPaidTotal,
156 - 'total_refund' => $refundedTotal,
235 + 'status' => $orderStatus,
236 + 'payment_status' => $orderPaymentStatus,
237 + 'total_paid' => $transactionPaidTotal,
238 + 'total_refund' => $refundedTotal,
239 + 'payment_method_title' => $paymentMethodTitle,
157 240 ]);
158 241
159 242 if (!$claimed) {
160 243 // Another process already transitioned this order to paid
@@ -167,8 +250,29 @@
167 250 } else {
168 251 $this->order->save();
169 252 }
170 253
254 + // Store-managed renewal invoice paid. Reached by every payment path for an
255 + // invoice that was created unpaid (customer pays the invoice, system auto-charge
256 + // settles, admin mark-as-paid, gateway confirmation) — they all converge on
257 + // recordManualRenewal() → syncOrderStatuses(), and the pending → paid transition
258 + // below is what the store-managed renewal engine listens for.
259 + //
260 + // NOT fired for gateway-managed (automatic) renewals: those go through
261 + // SubscriptionService::recordRenewalPayment(), which creates the child order
262 + // already paid and never reaches here. Both listeners on this hook
263 + // (RenewalService::handleRenewalPaid, SystemChargeService::cancelPendingCharge)
264 + // are scoped to manual/system collection, so that is by design — but it does mean
265 + // this is not an "any renewal was paid" hook. Use SubscriptionRenewed for that.
266 + //
267 + // Scoped to renewal+paid so initial order flow is unaffected.
268 + if ($this->order->type === Status::ORDER_TYPE_RENEWAL
269 + && $oldPaymentStatus !== $this->order->payment_status
270 + && $this->order->payment_status === Status::PAYMENT_PAID
271 + ) {
272 + do_action('fluent_cart/renewal_paid', ['order' => $this->order]);
273 + }
274 +
171 275 if (($this->order->type === 'renewal') || ($oldPaymentStatus != $this->order->payment_status && $this->order->payment_status == Status::PAYMENT_PAID)) {
172 276 if (!$latestTransaction) {
173 277 $latestTransaction = OrderTransaction::query()
174 278 ->where('order_id', $this->order->id)
@@ -244,7 +348,72 @@
244 348
245 349 (new OrderStatusUpdated($this->order, $oldOrderStatus, $this->order->status, true, $actionActivity, 'order_status'))->dispatch();
246 350 }
247 351
352 + $this->maybeActivateManualSubscription();
353 +
248 354 return $this->order;
355 + }
356 +
357 + private function maybeActivateManualSubscription()
358 + {
359 + // Initial subscription activation only. Renewal payments are owned by
360 + // RenewalService::handleRenewalPaid (hooked on fluent_cart/renewal_paid),
361 + // which sets the cadence-preserving next_billing_date (anchored to due_date).
362 + // Running this on renewals would overwrite that with guessNextBillingDate()
363 + // (order created_at + interval), pulling the date earlier by the advance window
364 + // every cycle, and could flip a paused/canceled subscription back to active.
365 + if ($this->order->type !== 'subscription') {
366 + return;
367 + }
368 +
369 + if ($this->order->payment_status !== Status::PAYMENT_PAID) {
370 + return;
371 + }
372 +
373 + $subscription = Subscription::query()
374 + ->where('parent_order_id', $this->order->id)
375 + ->whereIn('collection_method', ['manual', 'system'])
376 + ->first();
377 +
378 + if (!$subscription) {
379 + return;
380 + }
381 +
382 + $oldStatus = $subscription->status;
383 +
384 + // Initial activation only: syncOrderStatuses can run again on an already-paid
385 + // parent order (admin "Sync statuses", webhook redelivery). Without this guard
386 + // a paused/canceled/completed subscription would be forced back to active and
387 + // its next_billing_date/trial window reset.
388 + if (!in_array($oldStatus, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED])) {
389 + return;
390 + }
391 +
392 + $isTrialDaysSimulated = Arr::get($subscription->config, 'is_trial_days_simulated', 'no') === 'yes';
393 + $hasActualTrial = $subscription->trial_days > 0 && !$isTrialDaysSimulated;
394 +
395 + if ($hasActualTrial) {
396 + // Trial runs from activation, not order placement — a delayed payment
397 + // (COD, bank transfer) must not consume the trial before it starts.
398 + $trialEndsAt = gmdate('Y-m-d H:i:s', time() + ((int) $subscription->trial_days * DAY_IN_SECONDS));
399 + $updateData = [
400 + 'status' => Status::SUBSCRIPTION_TRIALING,
401 + 'trial_ends_at' => $trialEndsAt,
402 + 'next_billing_date' => $trialEndsAt,
403 + ];
404 + } else {
405 + $updateData = [
406 + 'status' => Status::SUBSCRIPTION_ACTIVE,
407 + 'next_billing_date' => $subscription->guessNextBillingDate(true),
408 + ];
409 + }
410 +
411 + $subscription = SubscriptionService::syncSubscriptionStates($subscription, $updateData);
412 +
413 + if (in_array($oldStatus, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED])
414 + && in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
415 + ) {
416 + (new SubscriptionActivated($subscription, $this->order, $this->order->customer))->dispatch();
417 + }
249 418 }
250 419 }