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.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
← All changes | app/Helpers/StatusHelper.php +147 -5 1.5.4 → 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
@@ -82,8 +87,37 @@
82 87 'order' => $this->order,
83 88 ]);
84 89 }
85 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 +
86 120 public function updateTotalPaid($amount)
87 121 {
88 122 $this->order->total_paid = intval($amount) + intval($this->order->total_paid);
89 123 if ($this->order->total_paid >= $this->order->total_amount) {
@@ -97,9 +131,14 @@
97 131 }
98 132
99 133 public function triggerPaymentStatusActions($order, $paymentStatus)
100 134 {
101 - 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) {
102 141 $transaction = OrderTransaction::query()->where('order_id', $order->id)
103 142 ->where('status', Status::TRANSACTION_SUCCEEDED)
104 143 ->first();
105 144 (new OrderPaid($order, $this->order->customer, $transaction))->dispatch();
@@ -141,11 +180,20 @@
141 180 ->sum('total');
142 181
143 182 $isFullyPaid = $this->order->total_amount <= ($transactionPaidTotal - $refundedTotal);
144 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 +
145 191 $orderPaymentStatus = $this->order->payment_status;
146 192 if ($isFullyPaid) {
147 193 $orderPaymentStatus = Status::PAYMENT_PAID;
194 + } else if ($refundedTotal && $refundedTotal >= $netPaidTotal) {
195 + $orderPaymentStatus = Status::PAYMENT_REFUNDED;
148 196 } else if ($refundedTotal) {
149 197 $orderPaymentStatus = Status::PAYMENT_PARTIALLY_REFUNDED;
150 198 }
151 199
@@ -158,12 +206,19 @@
158 206
159 207 $oldOrderStatus = $this->order->status;
160 208 $oldPaymentStatus = $this->order->payment_status;
161 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 +
162 216 $this->order->status = $orderStatus;
163 217 $this->order->payment_status = $orderPaymentStatus;
164 218 $this->order->total_paid = $transactionPaidTotal;
165 219 $this->order->total_refund = $refundedTotal;
220 + $this->order->payment_method_title = $paymentMethodTitle;
166 221
167 222 // When transitioning to PAID, use an atomic UPDATE to prevent concurrent requests
168 223 // (e.g., payment gateway webhook + browser confirmation) from both processing
169 224 // the same payment — which would dispatch OrderPaid twice, generating duplicate
@@ -176,12 +231,13 @@
176 231 $q->whereNull('payment_status')
177 232 ->orWhere('payment_status', '!=', Status::PAYMENT_PAID);
178 233 })
179 234 ->update([
180 - 'status' => $orderStatus,
181 - 'payment_status' => $orderPaymentStatus,
182 - 'total_paid' => $transactionPaidTotal,
183 - 'total_refund' => $refundedTotal,
235 + 'status' => $orderStatus,
236 + 'payment_status' => $orderPaymentStatus,
237 + 'total_paid' => $transactionPaidTotal,
238 + 'total_refund' => $refundedTotal,
239 + 'payment_method_title' => $paymentMethodTitle,
184 240 ]);
185 241
186 242 if (!$claimed) {
187 243 // Another process already transitioned this order to paid
@@ -194,8 +250,29 @@
194 250 } else {
195 251 $this->order->save();
196 252 }
197 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 +
198 275 if (($this->order->type === 'renewal') || ($oldPaymentStatus != $this->order->payment_status && $this->order->payment_status == Status::PAYMENT_PAID)) {
199 276 if (!$latestTransaction) {
200 277 $latestTransaction = OrderTransaction::query()
201 278 ->where('order_id', $this->order->id)
@@ -271,7 +348,72 @@
271 348
272 349 (new OrderStatusUpdated($this->order, $oldOrderStatus, $this->order->status, true, $actionActivity, 'order_status'))->dispatch();
273 350 }
274 351
352 + $this->maybeActivateManualSubscription();
353 +
275 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 + }
276 418 }
277 419 }