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

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

373 lines 15.6 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\Subscriptions\Services\SubscriptionService;
13 use FluentCart\App\Services\DateTime\DateTime;
14 use FluentCart\App\Services\Payments\PaymentHelper;
15 use FluentCart\Framework\Support\Arr;
16
17
18 class StatusHelper
19 {
20 protected $order;
21
22 public function __construct($order = null)
23 {
24 $this->order = $order;
25 }
26
27 public function setOrder($order)
28 {
29 $this->order = $order;
30 return $this;
31 }
32
33 public function changeOrderStatus($orderStatus, $paymentStatus, $title, $slug)
34 {
35 $oldStatus = $this->order->status;
36 if ($orderStatus === $oldStatus) {
37 return $this;
38 }
39 if ($orderStatus === Status::ORDER_COMPLETED) {
40 $this->order->completed_at = DateTime::gmtNow();
41 }
42 if ($paymentStatus === Status::PAYMENT_REFUNDED) {
43 $this->order->refunded_at = DateTime::gmtNow();
44 }
45 $this->order->status = $orderStatus;
46 $this->order->payment_method = $slug;
47 $this->order->payment_status = $paymentStatus;
48 $this->order->payment_method_title = $title;
49 $this->order->save();
50
51 $actionActivity = [
52 'title' => __('Order status updated', 'fluent-cart'),
53 'content' => sprintf(
54 /* translators: 1: old status, 2: new status */
55 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $orderStatus)
56 ];
57
58 (new OrderStatusUpdated($this->order, $oldStatus, $orderStatus, true, $actionActivity, 'order_status'))->dispatch();
59
60 if (in_array($orderStatus, Status::getOrderSuccessStatuses())) {
61 // Without this, the cart stays reusable, gets resurrected by the
62 // logged-in user lookup and permanently blocks checkout with
63 // "You have already completed this order."
64 $this->completeRelatedCart();
65 }
66
67 return $this;
68 }
69
70 protected function completeRelatedCart()
71 {
72 $relatedCart = Cart::query()->where('order_id', $this->order->id)
73 ->where('stage', '!=', 'completed')
74 ->first();
75
76 if (!$relatedCart) {
77 return;
78 }
79
80 $relatedCart->stage = 'completed';
81 $relatedCart->completed_at = DateTime::now()->format('Y-m-d H:i:s');
82 $relatedCart->save();
83
84 do_action('fluent_cart/cart_completed', [
85 'cart' => $relatedCart,
86 'order' => $this->order,
87 ]);
88 }
89
90 public function updateTotalPaid($amount)
91 {
92 $this->order->total_paid = intval($amount) + intval($this->order->total_paid);
93 if ($this->order->total_paid >= $this->order->total_amount) {
94 $this->order->payment_status = Status::PAYMENT_PAID;
95 $this->triggerPaymentStatusActions($this->order, Status::PAYMENT_PAID);
96 } else if ($this->order->total_paid < $this->order->total_amount && Status::PAYMENT_PARTIALLY_PAID !== $this->order->payment_status) {
97 $this->order->payment_status = Status::PAYMENT_PARTIALLY_PAID;
98 }
99 $this->order->save();
100 return $this;
101 }
102
103 public function triggerPaymentStatusActions($order, $paymentStatus)
104 {
105 // Initial orders only (payment / subscription). Renewal invoices are owned by
106 // fluent_cart/renewal_paid — dispatching OrderPaid here would also fire the
107 // async fluent_cart/order_paid_done on every renewal cycle, re-running the
108 // new-order emails and integration feeds. Mirrors the same guard in
109 // syncOrderStatuses().
110 if (Status::PAYMENT_PAID === $paymentStatus && Status::ORDER_TYPE_RENEWAL !== $order->type) {
111 $transaction = OrderTransaction::query()->where('order_id', $order->id)
112 ->where('status', Status::TRANSACTION_SUCCEEDED)
113 ->first();
114 (new OrderPaid($order, $this->order->customer, $transaction))->dispatch();
115 }
116
117 // Trigger any other payment status actions based on the payment status
118 }
119
120 public function updateTransactionData($updateData = [], $transaction = null)
121 {
122 if ($transaction) {
123 $transaction->update($updateData);
124 return $this;
125 }
126
127 OrderTransaction::query()->where('order_id', $this->order->id)
128 ->where('order_type', 'payment')
129 ->update($updateData);
130
131 return $this;
132 }
133
134 public function syncOrderStatuses($latestTransaction = null)
135 {
136 // Change the order status depends on payment paid
137 // Change the paid total depends on the order total amount
138 // Change the payment status depends on the order total paid
139
140 $successStatuses = Status::getTransactionSuccessStatuses();
141
142 $transactionPaidTotal = OrderTransaction::query()
143 ->where('order_id', $this->order->id)
144 ->whereIn('status', $successStatuses)
145 ->sum('total');
146
147 $refundedTotal = OrderTransaction::query()
148 ->where('order_id', $this->order->id)
149 ->where('status', Status::TRANSACTION_REFUNDED)
150 ->sum('total');
151
152 $isFullyPaid = $this->order->total_amount <= ($transactionPaidTotal - $refundedTotal);
153
154 $orderPaymentStatus = $this->order->payment_status;
155 if ($isFullyPaid) {
156 $orderPaymentStatus = Status::PAYMENT_PAID;
157 } else if ($refundedTotal) {
158 $orderPaymentStatus = Status::PAYMENT_PARTIALLY_REFUNDED;
159 }
160
161 $orderStatus = $this->order->status;
162 if (!in_array($orderStatus, Status::getOrderSuccessStatuses())) {
163 if ($orderPaymentStatus == Status::PAYMENT_PAID) {
164 $orderStatus = Status::ORDER_PROCESSING;
165 }
166 }
167
168 $oldOrderStatus = $this->order->status;
169 $oldPaymentStatus = $this->order->payment_status;
170
171 $this->order->status = $orderStatus;
172 $this->order->payment_status = $orderPaymentStatus;
173 $this->order->total_paid = $transactionPaidTotal;
174 $this->order->total_refund = $refundedTotal;
175
176 // When transitioning to PAID, use an atomic UPDATE to prevent concurrent requests
177 // (e.g., payment gateway webhook + browser confirmation) from both processing
178 // the same payment — which would dispatch OrderPaid twice, generating duplicate
179 // invoice numbers and sending duplicate emails.
180 // The WHERE condition ensures only one process can claim the transition.
181 if ($orderPaymentStatus == Status::PAYMENT_PAID && $oldPaymentStatus != Status::PAYMENT_PAID) {
182 $claimed = Order::query()
183 ->where('id', $this->order->id)
184 ->where(function ($q) {
185 $q->whereNull('payment_status')
186 ->orWhere('payment_status', '!=', Status::PAYMENT_PAID);
187 })
188 ->update([
189 'status' => $orderStatus,
190 'payment_status' => $orderPaymentStatus,
191 'total_paid' => $transactionPaidTotal,
192 'total_refund' => $refundedTotal,
193 ]);
194
195 if (!$claimed) {
196 // Another process already transitioned this order to paid
197 $this->order = Order::query()->where('id', $this->order->id)->first();
198 return $this->order;
199 }
200
201 // Refresh in-memory model to stay in sync with the DB after query-builder update
202 $this->order = Order::query()->where('id', $this->order->id)->first();
203 } else {
204 $this->order->save();
205 }
206
207 // Store-managed renewal invoice paid. Reached by every payment path for an
208 // invoice that was created unpaid (customer pays the invoice, system auto-charge
209 // settles, admin mark-as-paid, gateway confirmation) — they all converge on
210 // recordManualRenewal() → syncOrderStatuses(), and the pending → paid transition
211 // below is what the store-managed renewal engine listens for.
212 //
213 // NOT fired for gateway-managed (automatic) renewals: those go through
214 // SubscriptionService::recordRenewalPayment(), which creates the child order
215 // already paid and never reaches here. Both listeners on this hook
216 // (RenewalService::handleRenewalPaid, SystemChargeService::cancelPendingCharge)
217 // are scoped to manual/system collection, so that is by design — but it does mean
218 // this is not an "any renewal was paid" hook. Use SubscriptionRenewed for that.
219 //
220 // Scoped to renewal+paid so initial order flow is unaffected.
221 if ($this->order->type === Status::ORDER_TYPE_RENEWAL
222 && $oldPaymentStatus !== $this->order->payment_status
223 && $this->order->payment_status === Status::PAYMENT_PAID
224 ) {
225 do_action('fluent_cart/renewal_paid', ['order' => $this->order]);
226 }
227
228 if (($this->order->type === 'renewal') || ($oldPaymentStatus != $this->order->payment_status && $this->order->payment_status == Status::PAYMENT_PAID)) {
229 if (!$latestTransaction) {
230 $latestTransaction = OrderTransaction::query()
231 ->where('order_id', $this->order->id)
232 ->orderBy('id', 'desc')
233 ->first();
234 }
235
236 $relatedCart = Cart::query()->where('order_id', $this->order->id)
237 ->where('stage', '!=', 'completed')
238 ->first();
239
240 if ($relatedCart) {
241 $relatedCart->stage = 'completed';
242 $relatedCart->completed_at = DateTime::now()->format('Y-m-d H:i:s');
243 $relatedCart->save();
244
245 do_action('fluent_cart/cart_completed', [
246 'cart' => $relatedCart,
247 'order' => $this->order,
248 ]);
249
250 $onSuccessActions = Arr::get($relatedCart->checkout_data, '__on_success_actions__', []);
251
252 if ($onSuccessActions) {
253 foreach ($onSuccessActions as $onSuccessAction) {
254 $onSuccessAction = (string)$onSuccessAction;
255 if (has_action($onSuccessAction)) {
256 do_action($onSuccessAction, [
257 'cart' => $relatedCart,
258 'order' => $this->order,
259 'transaction' => $latestTransaction
260 ]);
261 }
262 }
263 }
264 }
265
266 if ($this->order->type !== 'renewal' && $oldPaymentStatus != $this->order->payment_status && $this->order->payment_status == Status::PAYMENT_PAID) {
267 (new OrderPaid($this->order, $this->order->customer, $latestTransaction))->dispatch();
268 }
269 }
270
271 if ($oldOrderStatus != $this->order->status) {
272 $actionActivity = [
273 'title' => __('Order status updated', 'fluent-cart'),
274 'content' => sprintf(
275 /* translators: 1: old status, 2: new status */
276 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldOrderStatus, $this->order->status)
277 ];
278 (new OrderStatusUpdated($this->order, $oldOrderStatus, $this->order->status, true, $actionActivity, 'order_status'))->dispatch();
279 }
280
281 $autoCompleteDigitalOrder = apply_filters('fluent_cart/order_status/auto_complete_digital_order', true, [
282 'order' => $this->order,
283 ]);
284
285 // Now if it's a digital product so we will make it auto completed
286 if ($this->order->fulfillment_type == 'digital' && $autoCompleteDigitalOrder
287 && !$refundedTotal && ($oldOrderStatus != $this->order->status)
288 && ($this->order->status != Status::ORDER_COMPLETED)
289 ) {
290 $oldOrderStatus = $this->order->status;
291 $this->order->status = Status::ORDER_COMPLETED;
292 $this->order->completed_at = DateTime::gmtNow();
293 $this->order->save();
294
295 $actionActivity = [
296 'title' => __('Order status updated', 'fluent-cart'),
297 'content' => sprintf(
298 /* translators: 1: old status, 2: new status */
299 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldOrderStatus, $this->order->status)
300 ];
301
302 (new OrderStatusUpdated($this->order, $oldOrderStatus, $this->order->status, true, $actionActivity, 'order_status'))->dispatch();
303 }
304
305 $this->maybeActivateManualSubscription();
306
307 return $this->order;
308 }
309
310 private function maybeActivateManualSubscription()
311 {
312 // Initial subscription activation only. Renewal payments are owned by
313 // RenewalService::handleRenewalPaid (hooked on fluent_cart/renewal_paid),
314 // which sets the cadence-preserving next_billing_date (anchored to due_date).
315 // Running this on renewals would overwrite that with guessNextBillingDate()
316 // (order created_at + interval), pulling the date earlier by the advance window
317 // every cycle, and could flip a paused/canceled subscription back to active.
318 if ($this->order->type !== 'subscription') {
319 return;
320 }
321
322 if ($this->order->payment_status !== Status::PAYMENT_PAID) {
323 return;
324 }
325
326 $subscription = Subscription::query()
327 ->where('parent_order_id', $this->order->id)
328 ->whereIn('collection_method', ['manual', 'system'])
329 ->first();
330
331 if (!$subscription) {
332 return;
333 }
334
335 $oldStatus = $subscription->status;
336
337 // Initial activation only: syncOrderStatuses can run again on an already-paid
338 // parent order (admin "Sync statuses", webhook redelivery). Without this guard
339 // a paused/canceled/completed subscription would be forced back to active and
340 // its next_billing_date/trial window reset.
341 if (!in_array($oldStatus, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED])) {
342 return;
343 }
344
345 $isTrialDaysSimulated = Arr::get($subscription->config, 'is_trial_days_simulated', 'no') === 'yes';
346 $hasActualTrial = $subscription->trial_days > 0 && !$isTrialDaysSimulated;
347
348 if ($hasActualTrial) {
349 // Trial runs from activation, not order placement — a delayed payment
350 // (COD, bank transfer) must not consume the trial before it starts.
351 $trialEndsAt = gmdate('Y-m-d H:i:s', time() + ((int) $subscription->trial_days * DAY_IN_SECONDS));
352 $updateData = [
353 'status' => Status::SUBSCRIPTION_TRIALING,
354 'trial_ends_at' => $trialEndsAt,
355 'next_billing_date' => $trialEndsAt,
356 ];
357 } else {
358 $updateData = [
359 'status' => Status::SUBSCRIPTION_ACTIVE,
360 'next_billing_date' => $subscription->guessNextBillingDate(true),
361 ];
362 }
363
364 $subscription = SubscriptionService::syncSubscriptionStates($subscription, $updateData);
365
366 if (in_array($oldStatus, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED])
367 && in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
368 ) {
369 (new SubscriptionActivated($subscription, $this->order, $this->order->customer))->dispatch();
370 }
371 }
372 }
373