PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 trunk 1.2.0 All 47 releases
fluent-cart / app / Helpers / StatusHelper.php

StatusHelper.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Helpers/StatusHelper.php

420 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Helpers;
4
5 use FluentCart\App\Events\Order\OrderPaid;
6 use FluentCart\App\Events\Order\OrderStatusUpdated;
7 use FluentCart\App\Events\Subscription\SubscriptionActivated;
8 use FluentCart\App\Models\Cart;
9 use FluentCart\App\Models\Order;
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;
14 use FluentCart\App\Services\DateTime\DateTime;
15 use FluentCart\App\Services\Payments\PaymentHelper;
16 use FluentCart\Framework\Support\Arr;
17
18
19 class StatusHelper
20 {
21 protected $order;
22
23 public function __construct($order = null)
24 {
25 $this->order = $order;
26 }
27
28 public function setOrder($order)
29 {
30 $this->order = $order;
31 return $this;
32 }
33
34 public function changeOrderStatus($orderStatus, $paymentStatus, $title, $slug)
35 {
36 $oldStatus = $this->order->status;
37 if ($orderStatus === $oldStatus) {
38 return $this;
39 }
40 if ($orderStatus === Status::ORDER_COMPLETED) {
41 $this->order->completed_at = DateTime::gmtNow();
42 }
43 if ($paymentStatus === Status::PAYMENT_REFUNDED) {
44 $this->order->refunded_at = DateTime::gmtNow();
45 }
46 $this->order->status = $orderStatus;
47 $this->order->payment_method = $slug;
48 $this->order->payment_status = $paymentStatus;
49 $this->order->payment_method_title = $title;
50 $this->order->save();
51
52 $actionActivity = [
53 'title' => __('Order status updated', 'fluent-cart'),
54 'content' => sprintf(
55 /* translators: 1: old status, 2: new status */
56 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $orderStatus)
57 ];
58
59 (new OrderStatusUpdated($this->order, $oldStatus, $orderStatus, true, $actionActivity, 'order_status'))->dispatch();
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
68 return $this;
69 }
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
120 public function updateTotalPaid($amount)
121 {
122 $this->order->total_paid = intval($amount) + intval($this->order->total_paid);
123 if ($this->order->total_paid >= $this->order->total_amount) {
124 $this->order->payment_status = Status::PAYMENT_PAID;
125 $this->triggerPaymentStatusActions($this->order, Status::PAYMENT_PAID);
126 } else if ($this->order->total_paid < $this->order->total_amount && Status::PAYMENT_PARTIALLY_PAID !== $this->order->payment_status) {
127 $this->order->payment_status = Status::PAYMENT_PARTIALLY_PAID;
128 }
129 $this->order->save();
130 return $this;
131 }
132
133 public function triggerPaymentStatusActions($order, $paymentStatus)
134 {
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) {
141 $transaction = OrderTransaction::query()->where('order_id', $order->id)
142 ->where('status', Status::TRANSACTION_SUCCEEDED)
143 ->first();
144 (new OrderPaid($order, $this->order->customer, $transaction))->dispatch();
145 }
146
147 // Trigger any other payment status actions based on the payment status
148 }
149
150 public function updateTransactionData($updateData = [], $transaction = null)
151 {
152 if ($transaction) {
153 $transaction->update($updateData);
154 return $this;
155 }
156
157 OrderTransaction::query()->where('order_id', $this->order->id)
158 ->where('order_type', 'payment')
159 ->update($updateData);
160
161 return $this;
162 }
163
164 public function syncOrderStatuses($latestTransaction = null)
165 {
166 // Change the order status depends on payment paid
167 // Change the paid total depends on the order total amount
168 // Change the payment status depends on the order total paid
169
170 $successStatuses = Status::getTransactionSuccessStatuses();
171
172 $transactionPaidTotal = OrderTransaction::query()
173 ->where('order_id', $this->order->id)
174 ->whereIn('status', $successStatuses)
175 ->sum('total');
176
177 $refundedTotal = OrderTransaction::query()
178 ->where('order_id', $this->order->id)
179 ->where('status', Status::TRANSACTION_REFUNDED)
180 ->sum('total');
181
182 $isFullyPaid = $this->order->total_amount <= ($transactionPaidTotal - $refundedTotal);
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
191 $orderPaymentStatus = $this->order->payment_status;
192 if ($isFullyPaid) {
193 $orderPaymentStatus = Status::PAYMENT_PAID;
194 } else if ($refundedTotal && $refundedTotal >= $netPaidTotal) {
195 $orderPaymentStatus = Status::PAYMENT_REFUNDED;
196 } else if ($refundedTotal) {
197 $orderPaymentStatus = Status::PAYMENT_PARTIALLY_REFUNDED;
198 }
199
200 $orderStatus = $this->order->status;
201 if (!in_array($orderStatus, Status::getOrderSuccessStatuses())) {
202 if ($orderPaymentStatus == Status::PAYMENT_PAID) {
203 $orderStatus = Status::ORDER_PROCESSING;
204 }
205 }
206
207 $oldOrderStatus = $this->order->status;
208 $oldPaymentStatus = $this->order->payment_status;
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
216 $this->order->status = $orderStatus;
217 $this->order->payment_status = $orderPaymentStatus;
218 $this->order->total_paid = $transactionPaidTotal;
219 $this->order->total_refund = $refundedTotal;
220 $this->order->payment_method_title = $paymentMethodTitle;
221
222 // When transitioning to PAID, use an atomic UPDATE to prevent concurrent requests
223 // (e.g., payment gateway webhook + browser confirmation) from both processing
224 // the same payment — which would dispatch OrderPaid twice, generating duplicate
225 // invoice numbers and sending duplicate emails.
226 // The WHERE condition ensures only one process can claim the transition.
227 if ($orderPaymentStatus == Status::PAYMENT_PAID && $oldPaymentStatus != Status::PAYMENT_PAID) {
228 $claimed = Order::query()
229 ->where('id', $this->order->id)
230 ->where(function ($q) {
231 $q->whereNull('payment_status')
232 ->orWhere('payment_status', '!=', Status::PAYMENT_PAID);
233 })
234 ->update([
235 'status' => $orderStatus,
236 'payment_status' => $orderPaymentStatus,
237 'total_paid' => $transactionPaidTotal,
238 'total_refund' => $refundedTotal,
239 'payment_method_title' => $paymentMethodTitle,
240 ]);
241
242 if (!$claimed) {
243 // Another process already transitioned this order to paid
244 $this->order = Order::query()->where('id', $this->order->id)->first();
245 return $this->order;
246 }
247
248 // Refresh in-memory model to stay in sync with the DB after query-builder update
249 $this->order = Order::query()->where('id', $this->order->id)->first();
250 } else {
251 $this->order->save();
252 }
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
275 if (($this->order->type === 'renewal') || ($oldPaymentStatus != $this->order->payment_status && $this->order->payment_status == Status::PAYMENT_PAID)) {
276 if (!$latestTransaction) {
277 $latestTransaction = OrderTransaction::query()
278 ->where('order_id', $this->order->id)
279 ->orderBy('id', 'desc')
280 ->first();
281 }
282
283 $relatedCart = Cart::query()->where('order_id', $this->order->id)
284 ->where('stage', '!=', 'completed')
285 ->first();
286
287 if ($relatedCart) {
288 $relatedCart->stage = 'completed';
289 $relatedCart->completed_at = DateTime::now()->format('Y-m-d H:i:s');
290 $relatedCart->save();
291
292 do_action('fluent_cart/cart_completed', [
293 'cart' => $relatedCart,
294 'order' => $this->order,
295 ]);
296
297 $onSuccessActions = Arr::get($relatedCart->checkout_data, '__on_success_actions__', []);
298
299 if ($onSuccessActions) {
300 foreach ($onSuccessActions as $onSuccessAction) {
301 $onSuccessAction = (string)$onSuccessAction;
302 if (has_action($onSuccessAction)) {
303 do_action($onSuccessAction, [
304 'cart' => $relatedCart,
305 'order' => $this->order,
306 'transaction' => $latestTransaction
307 ]);
308 }
309 }
310 }
311 }
312
313 if ($this->order->type !== 'renewal' && $oldPaymentStatus != $this->order->payment_status && $this->order->payment_status == Status::PAYMENT_PAID) {
314 (new OrderPaid($this->order, $this->order->customer, $latestTransaction))->dispatch();
315 }
316 }
317
318 if ($oldOrderStatus != $this->order->status) {
319 $actionActivity = [
320 'title' => __('Order status updated', 'fluent-cart'),
321 'content' => sprintf(
322 /* translators: 1: old status, 2: new status */
323 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldOrderStatus, $this->order->status)
324 ];
325 (new OrderStatusUpdated($this->order, $oldOrderStatus, $this->order->status, true, $actionActivity, 'order_status'))->dispatch();
326 }
327
328 $autoCompleteDigitalOrder = apply_filters('fluent_cart/order_status/auto_complete_digital_order', true, [
329 'order' => $this->order,
330 ]);
331
332 // Now if it's a digital product so we will make it auto completed
333 if ($this->order->fulfillment_type == 'digital' && $autoCompleteDigitalOrder
334 && !$refundedTotal && ($oldOrderStatus != $this->order->status)
335 && ($this->order->status != Status::ORDER_COMPLETED)
336 ) {
337 $oldOrderStatus = $this->order->status;
338 $this->order->status = Status::ORDER_COMPLETED;
339 $this->order->completed_at = DateTime::gmtNow();
340 $this->order->save();
341
342 $actionActivity = [
343 'title' => __('Order status updated', 'fluent-cart'),
344 'content' => sprintf(
345 /* translators: 1: old status, 2: new status */
346 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldOrderStatus, $this->order->status)
347 ];
348
349 (new OrderStatusUpdated($this->order, $oldOrderStatus, $this->order->status, true, $actionActivity, 'order_status'))->dispatch();
350 }
351
352 $this->maybeActivateManualSubscription();
353
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 }
418 }
419 }
420