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.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
fluent-cart / app / Modules / Subscriptions / Services / SubscriptionService.php

SubscriptionService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.1, at app/Modules/Subscriptions/Services/SubscriptionService.php

1,269 lines 55.2 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\Modules\Subscriptions\Services;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Events\Subscription\SubscriptionCanceled;
7 use FluentCart\App\Events\Subscription\SubscriptionEOT;
8 use FluentCart\App\Events\Subscription\SubscriptionPaused;
9 use FluentCart\App\Events\Subscription\SubscriptionPeriodSkipped;
10 use FluentCart\App\Events\Subscription\SubscriptionReactivated;
11 use FluentCart\App\Events\Subscription\SubscriptionRenewed;
12 use FluentCart\App\Events\Subscription\SubscriptionResumed;
13 use FluentCart\App\Events\Subscription\SubscriptionUpdated;
14 use FluentCart\App\Events\Subscription\SubscriptionValidityExpired;
15 use FluentCart\App\Helpers\Status;
16 use FluentCart\App\Helpers\StatusHelper;
17 use FluentCart\App\Models\Order;
18 use FluentCart\App\Models\OrderItem;
19 use FluentCart\App\Models\OrderTaxRate;
20 use FluentCart\App\Models\OrderTransaction;
21 use FluentCart\App\Models\Subscription;
22 use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService;
23 use WP_Error;
24 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
25 use FluentCart\App\Services\DateTime\DateTime;
26 use FluentCart\App\Services\Payments\PaymentHelper;
27 use FluentCart\Framework\Support\Arr;
28
29 class SubscriptionService
30 {
31 /**
32 * Record a gateway-managed (automatic collection) renewal payment.
33 *
34 * Called from gateway recurring-charge webhooks — Stripe invoice.paid, PayPal IPN,
35 * Mollie, Paddle, Authorize.Net. Creates the renewal child order ALREADY PAID
36 * (payment_status = paid, total_paid = total) in a single insert.
37 *
38 * Because there is no pending → paid transition and syncOrderStatuses() is never
39 * called on the new order, this path does NOT fire fluent_cart/renewal_paid. That
40 * is intentional: both listeners on that hook (RenewalService::handleRenewalPaid,
41 * SystemChargeService::cancelPendingCharge) are scoped to manual/system collection,
42 * and for automatic subscriptions the gateway owns next_billing_date. The renewal is
43 * announced here by dispatching SubscriptionRenewed instead — the one event that
44 * covers both renewal paths. Anything that must react to every renewal regardless of
45 * collection method belongs on SubscriptionRenewed, not on renewal_paid.
46 *
47 * Exception: when a pending/scheduled invoice already exists for this subscription,
48 * this method delegates to recordManualRenewal() below, which DOES go through
49 * syncOrderStatuses() and therefore does fire renewal_paid.
50 *
51 * @param array $transactionData
52 * @param Subscription|null $subscriptionModel
53 * @param array $subscriptionUpdateArgs
54 * @return OrderTransaction|\WP_Error
55 */
56 public static function recordRenewalPayment($transactionData, $subscriptionModel = null, $subscriptionUpdateArgs = [])
57 {
58 if (!$subscriptionModel) {
59 $subscriptionModel = Subscription::query()->find($transactionData['subscription_id']);
60 }
61
62 if (!$subscriptionModel) {
63 return new \WP_Error('subscription_not_found', __('Subscription not found.', 'fluent-cart'));
64 }
65
66 $vendorTransactionId = $transactionData['vendor_charge_id'] ?? null;
67
68 global $wpdb;
69 $lockName = null;
70
71 if ($vendorTransactionId) {
72 $lockName = 'fc_webhook_' . $vendorTransactionId;
73 $acquired = (bool) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 5)", $lockName));
74 if (!$acquired) {
75 return new \WP_Error('lock_failed', __('Duplicate webhook processing in progress.', 'fluent-cart'));
76 }
77 if (OrderTransaction::query()
78 ->where('vendor_charge_id', $vendorTransactionId)
79 ->where('status', '!=', Status::TRANSACTION_FAILED)
80 ->exists()) {
81 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
82 return new \WP_Error('transaction_exists', __('This transaction already exists for this subscription.', 'fluent-cart'));
83 }
84 }
85
86 $parentOrder = $subscriptionModel->order;
87
88 if (!$parentOrder) {
89 return new \WP_Error('parent_order_not_found', __('Parent order not found for this subscription.', 'fluent-cart'));
90 }
91
92 // If a pending manual invoice exists for this subscription, process it instead of
93 // creating a new renewal order. This handles the case where a manual renewal invoice
94 // was paid via a gateway that converts the subscription to automatic (e.g. Stripe),
95 // and the actual charge fires as a subscription_cycle webhook after a deferred period.
96 $existingInvoice = Order::query()
97 ->where('parent_id', $parentOrder->id)
98 ->where('type', Status::ORDER_TYPE_RENEWAL)
99 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
100 ->first();
101
102 if ($existingInvoice) {
103 $existingTransaction = OrderTransaction::query()
104 ->where('order_id', $existingInvoice->id)
105 ->where(function ($query) use ($vendorTransactionId) {
106 $query->where('status', Status::TRANSACTION_PENDING)
107 ->orWhere(function ($query) use ($vendorTransactionId) {
108 // A failed row only stands in for the invoice if it's the same
109 // PaymentIntent being retried — otherwise it's an unrelated attempt.
110 $query->where('status', Status::TRANSACTION_FAILED)
111 ->where('vendor_charge_id', $vendorTransactionId);
112 });
113 })
114 ->orderBy('id', 'DESC')
115 ->first();
116
117 if ($existingTransaction) {
118 $transactionUpdateData = array_filter([
119 'total' => $transactionData['total'] ?? $existingTransaction->total,
120 'status' => Status::TRANSACTION_SUCCEEDED,
121 'payment_method' => $transactionData['payment_method'] ?? $existingTransaction->payment_method,
122 'vendor_charge_id' => $transactionData['vendor_charge_id'] ?? null,
123 'card_last_4' => $transactionData['card_last_4'] ?? '',
124 'card_brand' => $transactionData['card_brand'] ?? '',
125 'payment_method_type' => $transactionData['payment_method_type'] ?? '',
126 ]);
127 $existingTransaction->update($transactionUpdateData);
128 $existingTransaction = OrderTransaction::query()->find($existingTransaction->id);
129
130 $billingInfo = $subscriptionModel->getMeta('active_payment_method', []) ?: [];
131
132 static::recordManualRenewal($subscriptionModel, $existingTransaction, [
133 'billing_info' => $billingInfo,
134 'subscription_args' => $subscriptionUpdateArgs,
135 ]);
136
137 if ($lockName) {
138 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
139 }
140
141 return $existingTransaction;
142 }
143 }
144
145 $transactionDefaults = [
146 'order_id' => $parentOrder->id,
147 'subscription_id' => $subscriptionModel->id,
148 'order_type' => Status::ORDER_TYPE_RENEWAL,
149 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
150 'payment_method' => $subscriptionModel->current_payment_method,
151 'payment_mode' => $parentOrder->mode,
152 'status' => Status::TRANSACTION_SUCCEEDED,
153 'currency' => $parentOrder->currency,
154 'total' => $subscriptionModel->recurring_total,
155 'meta' => Arr::get($transactionData, 'meta', [])
156 ];
157
158 $transactionData = wp_parse_args($transactionData, $transactionDefaults);
159
160 $createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s'));
161
162 // Let's create the order item first
163 $variation = $subscriptionModel->variation;
164 $product = $subscriptionModel->product;
165
166 $parentOrderItem = OrderItem::query()
167 ->where('order_id', $parentOrder->id)
168 ->where('payment_type', Status::ORDER_TYPE_SUBSCRIPTION)
169 ->first();
170
171 $taxTotal = Arr::get($transactionData, 'tax_total', 0);
172 if (!$taxTotal && $subscriptionModel->recurring_tax_total) {
173 $taxTotal = $subscriptionModel->recurring_tax_total;
174 }
175
176 // A subscription item may be inclusive even when the parent order is mixed (behavior=3).
177 // Check the per-item line_meta to determine the actual inclusion for this item.
178 $isItemInclusive = $parentOrder->tax_behavior === 2
179 || ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null
180 && (bool) Arr::get((array) $parentOrderItem->line_meta, 'tax_config.inclusive', false));
181
182 if (!$taxTotal && $isItemInclusive && $parentOrderItem) {
183 $taxTotal = (int) Arr::get($parentOrderItem->other_info, 'recurring_tax', 0);
184 }
185
186 $subtotal = $transactionData['total'];
187 if ($taxTotal) {
188 $subtotal = $transactionData['total'] - $taxTotal;
189 }
190
191 $orderItem = [
192 'post_id' => $subscriptionModel->product_id,
193 'object_id' => $subscriptionModel->variation_id,
194 'payment_type' => Status::ORDER_TYPE_SUBSCRIPTION,
195 'post_title' => $product && $product->post_title ? $product->post_title : $subscriptionModel->item_name,
196 'title' => $product && $variation ? $variation->variation_title : '',
197 'quantity' => 1,
198 'fulfillment_type' => $parentOrderItem ? $parentOrderItem->fulfillment_type : 'digital',
199 'unit_price' => $subtotal,
200 'subtotal' => $subtotal,
201 'tax_amount' => $taxTotal,
202 'line_total' => $transactionData['total'],
203 'line_meta' => [],
204 'other_info' => []
205 ];
206
207 $bundleItemIds = Arr::get($parentOrderItem->line_meta, 'bundle_item_ids', []);
208
209 $isBundleOrder = false;
210 if ($bundleItemIds) {
211 $isBundleOrder = true;
212 $orderItem['line_meta'] = array_merge(
213 $orderItem['line_meta'],
214 [
215 'bundle_item_ids' => $bundleItemIds
216 ]
217 );
218 }
219
220 $fulfillmentType = $orderItem['fulfillment_type'];
221
222 $wpdb->query('START TRANSACTION');
223
224 // Let's create the order first
225 $childOrderData = [
226 'parent_id' => $parentOrder->id,
227 'fulfillment_type' => $fulfillmentType,
228 'status' => $fulfillmentType === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED,
229 'type' => Status::ORDER_TYPE_RENEWAL,
230 'mode' => $transactionData['payment_mode'],
231 'shipping_status' => $fulfillmentType === 'physical' ? Status::SHIPPING_UNSHIPPED : '',
232 'customer_id' => $subscriptionModel->customer_id,
233 'payment_method' => $transactionData['payment_method'],
234 'payment_status' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? Status::PAYMENT_PAID : Status::PAYMENT_PENDING,
235 'currency' => $transactionData['currency'],
236 'tax_behavior' => $parentOrder->tax_behavior,
237 'subtotal' => $subtotal,
238 'tax_total' => $taxTotal,
239 'total_amount' => $transactionData['total'],
240 'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0,
241 'completed_at' => DateTime::now()->format('Y-m-d H:i:s'),
242 'created_at' => $createdAt,
243 'config' => []
244 ];
245
246 try {
247 $childOrder = Order::query()->create($childOrderData);
248
249 if (!$childOrder) {
250 throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart'));
251 }
252
253 $billingAddress = $parentOrder->billing_address;
254 $shippingAddress = $parentOrder->shipping_address;
255
256 $customer = $parentOrder->customer;
257
258 $fullName = '';
259 $email = '';
260 $firstName = '';
261 $lastName = '';
262 if ($customer) {
263 $fullName = $customer->first_name . ' ' . $customer->last_name;
264 $email = $customer->email;
265 $firstName = $customer->first_name;
266 $lastName = $customer->last_name;
267 }
268
269 $billingAddressData = $billingAddress ? [
270 'type' => 'billing',
271 'full_name' => $fullName,
272 'address_1' => $billingAddress->address_1,
273 'address_2' => $billingAddress->address_2,
274 'city' => $billingAddress->city,
275 'state' => $billingAddress->state,
276 'postcode' => $billingAddress->postcode,
277 'country' => $billingAddress->country,
278 'email' => $email,
279 'first_name' => $firstName,
280 'last_name' => $lastName
281 ] : [];
282
283 $shippingAddressData = $shippingAddress ? [
284 'type' => 'shipping',
285 'full_name' => $fullName,
286 'address_1' => $shippingAddress->address_1,
287 'address_2' => $shippingAddress->address_2,
288 'city' => $shippingAddress->city,
289 'state' => $shippingAddress->state,
290 'postcode' => $shippingAddress->postcode,
291 'country' => $shippingAddress->country,
292 'email' => $email,
293 'first_name' => $firstName,
294 'last_name' => $lastName
295 ] : [];
296
297 \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses(
298 $childOrder->id,
299 $billingAddressData,
300 $shippingAddressData
301 );
302
303 // Copy tax ID meta from parent order if exists
304 $parentTaxId = $parentOrder->getMeta('tax_id', '');
305 if ($parentTaxId) {
306 $childOrder->updateMeta('tax_id', $parentTaxId);
307 }
308
309 // Copy order tax rates from parent order
310 $parentTaxRates = $parentOrder->orderTaxRates;
311 foreach ($parentTaxRates as $taxRate) {
312 OrderTaxRate::query()->create([
313 'order_id' => $childOrder->id,
314 'tax_rate_id' => $taxRate->tax_rate_id,
315 'shipping_tax' => $taxRate->shipping_tax,
316 'order_tax' => $taxRate->order_tax,
317 'total_tax' => $taxRate->total_tax,
318 'meta' => $taxRate->meta,
319 ]);
320 }
321
322 // Create Order Item
323 $orderItem['order_id'] = $childOrder->id;
324 $orderItem['created_at'] = $createdAt;
325 OrderItem::query()->create($orderItem);
326
327 // let's create the transaction
328 $transactionData['order_id'] = $childOrder->id;
329
330 $createdTransaction = OrderTransaction::query()->create($transactionData);
331
332 $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs);
333
334 $wpdb->query('COMMIT');
335 } catch (\Throwable $e) {
336 $wpdb->query('ROLLBACK');
337 if ($lockName) {
338 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
339 }
340 return new \WP_Error('renewal_failed', $e->getMessage());
341 }
342
343 (new SubscriptionRenewed($subscriptionModel, $childOrder, $parentOrder, $childOrder->customer))->dispatch();
344
345 return $createdTransaction;
346 }
347
348 /**
349 * @param $subscriptionModel
350 * @param $subscriptionUpdateArgs
351 * - next_billing_date - You must provide this if you want to update the next billing date.
352 * * - Accepts all other filliable attributes of the Subscription model.
353 * @return mixed
354 */
355 public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [])
356 {
357 $billsCount = $subscriptionModel->calculateBillCount();
358
359 $subscriptionUpdateArgs['bill_count'] = $billsCount;
360 $billTimes = $subscriptionModel->bill_times;
361 $oldStatus = $subscriptionModel->status;
362
363 $subscriptionUpdateArgs['bill_count'] = $billsCount;
364 $isEot = $billTimes > 0 && $billsCount >= $billTimes;
365
366 if ($isEot) {
367 $subscriptionUpdateArgs['status'] = 'completed';
368 $subscriptionUpdateArgs['next_billing_date'] = NULL;
369 $subscriptionUpdateArgs['canceled_at'] = NULL;
370 } else if (!$subscriptionModel->next_billing_date && empty($subscriptionUpdateArgs['next_billing_date'])) {
371 $subscriptionUpdateArgs['next_billing_date'] = $subscriptionModel->guessNextBillingDate();
372 }
373
374 if (Arr::get($subscriptionUpdateArgs, 'status') === Status::SUBSCRIPTION_ACTIVE) {
375 $subscriptionUpdateArgs['recurring_total'] = Arr::get($subscriptionUpdateArgs, 'recurring_total', $subscriptionModel->recurring_total);
376 }
377
378 $givenSubscriptionStatus = Arr::get($subscriptionUpdateArgs, 'status');
379 if ($givenSubscriptionStatus === Status::SUBSCRIPTION_CANCELED && empty($subscriptionUpdateArgs['canceled_at'])) {
380 $subscriptionUpdateArgs['canceled_at'] = gmdate('Y-m-d H:i:s');
381 }
382
383 $subscriptionModel->fill($subscriptionUpdateArgs);
384 $dirtyData = $subscriptionModel->getDirty();
385 $subscriptionModel->save();
386
387 $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', []));
388
389 foreach ($meta as $key => $value) {
390 $subscriptionModel->updateMeta($key, $value);
391 }
392
393 // The gateway on file just changed (a renewal invoice paid through a different
394 // gateway, an admin edit). `system` is only meaningful while that gateway can
395 // token-charge, so re-derive it — otherwise the subscription keeps claiming
396 // auto-charge against a gateway that will refuse every attempt.
397 if (isset($dirtyData['current_payment_method'])) {
398 SystemChargeService::reconcileGatewayCapability($subscriptionModel);
399 }
400
401 // validity_expired_at should only exist when status IS expired
402 if ($subscriptionModel->status !== Status::SUBSCRIPTION_EXPIRED) {
403 $subscriptionModel->deleteMeta('validity_expired_at');
404 }
405
406 if ($oldStatus === $subscriptionModel->status) {
407 if ($dirtyData) {
408 do_action('fluent_cart/subscription/data_updated', [
409 'subscription' => $subscriptionModel,
410 'updated_data' => $dirtyData
411 ]);
412 }
413
414 return $subscriptionModel; // No change in status
415 }
416
417 if ($isEot) {
418 (new SubscriptionEOT($subscriptionModel, $subscriptionModel->order))->dispatch();
419 }
420
421 do_action('fluent_cart/payments/subscription_status_changed', [
422 'subscription' => $subscriptionModel,
423 'order' => $subscriptionModel->order,
424 'customer' => $subscriptionModel->customer,
425 'old_status' => $oldStatus,
426 'new_status' => $subscriptionModel->status
427 ]);
428
429 /**
430 * lists of hooks for this action
431 * fluent_cart/payments/subscription_canceled
432 * fluent_cart/payments/subscription_active
433 * fluent_cart/payments/subscription_paused
434 * fluent_cart/payments/subscription_expired
435 * fluent_cart/payments/subscription_failing
436 * fluent_cart/payments/subscription_expiring
437 * fluent_cart/payments/subscription_completed
438 **/
439 do_action('fluent_cart/payments/subscription_' . $subscriptionModel->status, [
440 'subscription' => $subscriptionModel,
441 'order' => $subscriptionModel->order,
442 'customer' => $subscriptionModel->customer,
443 'old_status' => $oldStatus,
444 'new_status' => $subscriptionModel->status
445 ]);
446
447 // Gateway-originated cancel (webhook) reaches only the raw status bus above;
448 // route it through the chokepoint so void + native event fire like every
449 // other path. The old_status === status early return keeps this once-only.
450 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED) {
451 self::finalizeCancellation(
452 $subscriptionModel,
453 Arr::get($subscriptionUpdateArgs, 'reason', __('Canceled at gateway', 'fluent-cart'))
454 );
455 }
456
457 // note: we needed this event, currently being used in integrations
458 if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) {
459 $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
460 (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch();
461 }
462
463 if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE &&
464 in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) {
465 (new SubscriptionReactivated($subscriptionModel, $subscriptionModel->order, $subscriptionModel->customer, $oldStatus))->dispatch();
466 }
467
468 if ($subscriptionModel->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) {
469 self::dispatchStatusEvent($subscriptionModel, 'paused', ['old_status' => $oldStatus]);
470 }
471
472 if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) {
473 self::dispatchStatusEvent($subscriptionModel, 'resumed', ['old_status' => $oldStatus]);
474 }
475
476 return $subscriptionModel;
477 }
478
479
480 /**
481 *
482 * Use this method when you are reactivating a expired subscription manually by creating order, transaction etc.
483 * Make sure you already handle your transaction statuses!
484 *
485 * @param \FluentCart\App\Models\Subscription $subscriptionModel
486 * @param \FluentCart\App\Models\OrderTransaction $transaction
487 * @param $args
488 * @return mixed
489 */
490 public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = [])
491 {
492 $renewalOrder = $transaction->order;
493
494 // payment_status and total_paid are deliberately NOT set here — every caller has
495 // already marked the transaction succeeded, and syncOrderStatuses() below derives
496 // both from the transactions and claims the pending → paid transition atomically.
497 // Pre-setting them destroyed that transition, which (a) suppressed
498 // fluent_cart/renewal_paid, so RenewalService::handleRenewalPaid() never
499 // advanced next_billing_date (the customer was re-invoiced forever), and
500 // (b) bypassed the atomic claim that stops a webhook and a browser confirmation
501 // from both processing the same renewal payment.
502 $orderUpdateData = [
503 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED,
504 'type' => Status::ORDER_TYPE_RENEWAL,
505 'payment_method' => $transaction->payment_method,
506 'completed_at' => DateTime::now()->format('Y-m-d H:i:s')
507 ];
508
509 $renewalOrder->fill($orderUpdateData);
510 $renewalOrder->save();
511
512 if ($billingInfo = Arr::get($args, 'billing_info', [])) {
513 $subscriptionModel->updateMeta('active_payment_method', $billingInfo);
514 }
515
516 $updateData = wp_parse_args(Arr::get($args, 'subscription_args', []), [
517 'status' => Status::SUBSCRIPTION_ACTIVE,
518 'current_payment_method' => $transaction->payment_method,
519 ]);
520
521 $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData);
522
523 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
524
525 // Single-event contract for renewal processing — exactly one owner per
526 // subscription type, so SubscriptionRenewed fires exactly once:
527 //
528 // store-billed (manual/system) → RenewalService::handleRenewalPaid(),
529 // reached through the fluent_cart/renewal_paid hook that
530 // syncOrderStatuses() fires above. It advances next_billing_date,
531 // derives bill_count / EOT, and dispatches the event.
532 // gateway-billed (automatic) → here. The invoice engine does not handle
533 // these, so this is their only dispatch point.
534 //
535 // Keyed on the collection method rather than on the `renewal_processed`
536 // marker: handleRenewalPaid() stamps that marker before its EOT early-return,
537 // so a marker check would swallow the event on a final installment.
538 if ($transaction->total > 0 && !$subscriptionModel->usesRenewalEngine()) {
539 (new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch();
540 }
541
542 return $subscriptionModel;
543 }
544
545 /**
546 * Single dispatch point for subscription lifecycle status events.
547 *
548 * Every confirmed transition — manual local update, gateway sync response, or
549 * gateway webhook/confirmation — routes through here so the first-class event
550 * (and the hook it fires) happens exactly once, whatever path caused the change.
551 *
552 * @param Subscription $subscription
553 * @param string $event One of: paused, resumed, updated, period_skipped
554 * @param array $context order, customer, old_status, reason, updates, changes,
555 * old_next_billing_date, new_next_billing_date
556 * @return void
557 */
558 public static function dispatchStatusEvent(Subscription $subscription, string $event, array $context = [])
559 {
560 $order = Arr::get($context, 'order') ?: $subscription->order;
561 $customer = Arr::get($context, 'customer') ?: ($order ? $order->customer : null);
562 $oldStatus = Arr::get($context, 'old_status');
563 $reason = (string) Arr::get($context, 'reason', '');
564
565 switch ($event) {
566 case 'paused':
567 (new SubscriptionPaused($subscription, $order, $customer, $oldStatus, $reason))->dispatch();
568 break;
569 case 'resumed':
570 (new SubscriptionResumed($subscription, $order, $customer, $oldStatus, $reason))->dispatch();
571 break;
572 case 'updated':
573 (new SubscriptionUpdated($subscription, $order, $customer, Arr::get($context, 'updates', []), Arr::get($context, 'changes', [])))->dispatch();
574 break;
575 case 'period_skipped':
576 (new SubscriptionPeriodSkipped($subscription, $order, $customer, Arr::get($context, 'old_next_billing_date'), Arr::get($context, 'new_next_billing_date')))->dispatch();
577 break;
578 }
579 }
580
581 /**
582 * Pause a subscription
583 *
584 * For manual subscriptions, this just updates the local status.
585 * For automatic subscriptions, delegates to the gateway.
586 *
587 * @param Subscription $subscription
588 * @param string $reason
589 * @return true|\WP_Error
590 */
591 public static function pauseSubscription(Subscription $subscription, $reason = '')
592 {
593 if (!$subscription->canPause()) {
594 return new \WP_Error(
595 'cannot_pause',
596 __('This subscription cannot be paused.', 'fluent-cart')
597 );
598 }
599
600 // Store-billed (manual/system) subscriptions: local status update.
601 if ($subscription->usesRenewalEngine()) {
602 $oldStatus = $subscription->status;
603 $subscription->status = Status::SUBSCRIPTION_PAUSED;
604 $subscription->save();
605
606 self::voidPendingRenewals(
607 $subscription,
608 'Subscription paused; open renewal order voided.'
609 );
610
611 $subscription->addLog(
612 'Subscription paused',
613 $reason ?: __('Subscription paused manually', 'fluent-cart'),
614 'info'
615 );
616
617 // Fires fluent_cart/subscription_paused once, with the original
618 // subscription/reason keys plus order/customer/old_status.
619 self::dispatchStatusEvent($subscription, 'paused', [
620 'old_status' => $oldStatus,
621 'reason' => $reason,
622 ]);
623
624 return true;
625 }
626
627 // Automatic subscriptions: delegate to gateway
628 $gateway = App::gateway($subscription->current_payment_method);
629
630 if (!$gateway || !in_array('pause_subscription', $gateway->supportedFeatures)) {
631 return new \WP_Error(
632 'unsupported_pause',
633 __('Current payment method does not support pausing.', 'fluent-cart')
634 );
635 }
636
637 if (method_exists($gateway->subscriptions, 'pause')) {
638 return $gateway->subscriptions->pause($subscription, $reason);
639 }
640
641 return new \WP_Error(
642 'unsupported_pause',
643 __('Current payment method does not support pausing.', 'fluent-cart')
644 );
645 }
646
647 /**
648 * Resume a paused subscription
649 *
650 * For manual subscriptions, this updates status back to active.
651 * For automatic subscriptions, delegates to the gateway.
652 *
653 * @param Subscription $subscription
654 * @param string $reason
655 * @return true|\WP_Error
656 */
657 public static function resumeSubscription(Subscription $subscription, $reason = '')
658 {
659 if (!$subscription->canResume()) {
660 return new \WP_Error(
661 'cannot_resume',
662 __('This subscription cannot be resumed.', 'fluent-cart')
663 );
664 }
665
666 // Store-billed (manual/system) subscriptions: local status update.
667 if ($subscription->usesRenewalEngine()) {
668 $oldStatus = $subscription->status;
669 $subscription->status = Status::SUBSCRIPTION_ACTIVE;
670 $subscription->save();
671
672 SystemChargeService::restoreScheduledChargesForSubscription($subscription);
673
674 $subscription->addLog(
675 'Subscription resumed',
676 $reason ?: __('Subscription resumed manually', 'fluent-cart'),
677 'info'
678 );
679
680 // Fires fluent_cart/subscription_resumed once, with the original
681 // subscription/reason keys plus order/customer/old_status.
682 self::dispatchStatusEvent($subscription, 'resumed', [
683 'old_status' => $oldStatus,
684 'reason' => $reason,
685 ]);
686
687 return true;
688 }
689
690 // Automatic subscriptions: delegate to gateway
691 $gateway = App::gateway($subscription->current_payment_method);
692
693 if (!$gateway || !in_array('resume_subscription', $gateway->supportedFeatures)) {
694 return new \WP_Error(
695 'unsupported_resume',
696 __('Current payment method does not support resuming.', 'fluent-cart')
697 );
698 }
699
700 if (method_exists($gateway->subscriptions, 'resume')) {
701 return $gateway->subscriptions->resume($subscription, $reason);
702 }
703
704 return new \WP_Error(
705 'unsupported_resume',
706 __('Current payment method does not support resuming.', 'fluent-cart')
707 );
708 }
709
710 /**
711 * Reactivate a canceled/expired store-billed (manual or system) subscription locally —
712 * no gateway/checkout involved. Voids any pending renewal invoice from the missed
713 * period and advances next_billing_date so the overdue scanner doesn't immediately
714 * re-flag it. Shared by the admin reactivate endpoint and the customer-dashboard
715 * future-dated reactivation short-circuit.
716 *
717 * @param Subscription $subscription
718 * @return Subscription|\WP_Error
719 */
720 public static function reactivateSubscriptionLocally(Subscription $subscription)
721 {
722 if (!$subscription->usesRenewalEngine()) {
723 return new \WP_Error(
724 'unsupported_local_reactivation',
725 __('This subscription must be reactivated through its payment gateway.', 'fluent-cart')
726 );
727 }
728
729 if (!$subscription->canReactivate()) {
730 return new \WP_Error(
731 'cannot_reactivate',
732 __('This subscription cannot be reactivated.', 'fluent-cart')
733 );
734 }
735
736 global $wpdb;
737
738 $wpdb->query('START TRANSACTION');
739
740 try {
741 // Lock subscription before orders — skipNextPeriod locks in this order too;
742 // diverging risks a deadlock on the same rows.
743 $locked = Subscription::query()
744 ->where('id', $subscription->id)
745 ->lockForUpdate()
746 ->first();
747
748 // Re-check under the lock: canReactivate() ran on pre-lock state.
749 $subscription->fill([
750 'status' => $locked ? $locked->status : $subscription->status,
751 'next_billing_date' => $locked ? $locked->next_billing_date : $subscription->next_billing_date,
752 ]);
753
754 if (!$locked || !$subscription->canReactivate()) {
755 $wpdb->query('ROLLBACK');
756 return new \WP_Error(
757 'cannot_reactivate',
758 __('This subscription cannot be reactivated.', 'fluent-cart')
759 );
760 }
761
762 $oldStatus = $subscription->status;
763
764 $pendingOrderIds = Order::query()
765 ->where('type', Status::ORDER_TYPE_RENEWAL)
766 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
767 ->where('parent_id', $subscription->parent_order_id)
768 ->pluck('id');
769
770 if ($pendingOrderIds->isNotEmpty()) {
771 // Re-assert payment_status at mutation time — a webhook may have paid
772 // this order between the select above and this update.
773 Order::query()
774 ->whereIn('id', $pendingOrderIds)
775 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
776 ->update([
777 'status' => Status::ORDER_CANCELED,
778 'payment_status' => Status::PAYMENT_FAILED,
779 ]);
780
781 // Only fail transactions for orders actually voided above — a
782 // paid-in-the-race order is excluded by the update's payment_status
783 // predicate, so it must be excluded here too.
784 $voidedOrderIds = Order::query()
785 ->whereIn('id', $pendingOrderIds)
786 ->where('status', Status::ORDER_CANCELED)
787 ->where('payment_status', Status::PAYMENT_FAILED)
788 ->pluck('id');
789
790 if ($voidedOrderIds->isNotEmpty()) {
791 OrderTransaction::query()
792 ->whereIn('order_id', $voidedOrderIds)
793 ->where('status', Status::TRANSACTION_PENDING)
794 ->update(['status' => Status::TRANSACTION_FAILED]);
795 }
796 }
797
798 // Overdue date must not survive reactivation, or it lands instantly due.
799 if ($subscription->next_billing_date && strtotime($subscription->next_billing_date) <= time()) {
800 $advancedDate = RenewalService::computeSkippedDate($subscription);
801 if ($advancedDate) {
802 $subscription->fill(['next_billing_date' => $advancedDate]);
803 }
804 }
805
806 // Not syncSubscriptionStates: its EOT check would flip status to completed.
807 $reactivationData = [
808 'status' => Status::SUBSCRIPTION_ACTIVE,
809 'canceled_at' => null,
810 ];
811
812 // Guess can itself be in the past (empty date + old last order) — advance past now.
813 if (empty($subscription->next_billing_date)
814 || strtotime($subscription->next_billing_date) <= time()) {
815 $guessedTs = strtotime($subscription->guessNextBillingDate());
816 $intervalDays = PaymentHelper::getIntervalDays($subscription->billing_interval);
817 if ($intervalDays > 0) {
818 while ($guessedTs <= time()) {
819 $guessedTs += $intervalDays * DAY_IN_SECONDS;
820 }
821 }
822 $reactivationData['next_billing_date'] = gmdate('Y-m-d H:i:s', $guessedTs);
823 }
824
825 $subscription->fill($reactivationData)->save();
826
827 $wpdb->query('COMMIT');
828 } catch (\Throwable $e) {
829 $wpdb->query('ROLLBACK');
830
831 return new \WP_Error('reactivation_failed', $e->getMessage());
832 }
833
834 do_action('fluent_cart/payments/subscription_status_changed', [
835 'subscription' => $subscription,
836 'order' => $subscription->order,
837 'customer' => $subscription->customer,
838 'old_status' => $oldStatus,
839 'new_status' => Status::SUBSCRIPTION_ACTIVE,
840 ]);
841
842 do_action('fluent_cart/payments/subscription_active', [
843 'subscription' => $subscription,
844 'order' => $subscription->order,
845 'customer' => $subscription->customer,
846 'old_status' => $oldStatus,
847 'new_status' => Status::SUBSCRIPTION_ACTIVE,
848 ]);
849
850 if (in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) {
851 (new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch();
852 }
853
854 do_action('fluent_cart/subscription/reactivated_locally', $subscription);
855
856 return $subscription;
857 }
858
859 /**
860 * Update subscription details (for manual subscriptions)
861 *
862 * Allowed fields for manual subscriptions:
863 * - recurring_total: Update the next invoice/payment amount (in cents)
864 * - bill_times: Update the number of billing cycles (0 = unlimited)
865 * - billing_interval: Change billing frequency (daily, weekly, monthly, etc.)
866 * - expire_at: Update expiration date
867 * - trial_days: Update trial period
868 * - next_billing_date: Update next billing date
869 *
870 * @param Subscription $subscription
871 * @param array $data
872 * @return true|\WP_Error
873 */
874 public static function updateSubscription(Subscription $subscription, array $data)
875 {
876 if (!$subscription->usesRenewalEngine()) {
877 return new \WP_Error(
878 'cannot_update_automatic',
879 __('Only store-billed (manual or auto-charge) subscriptions can be updated directly.', 'fluent-cart')
880 );
881 }
882
883 $allowedFields = [
884 'recurring_total',
885 'bill_times',
886 'billing_interval',
887 'next_billing_date',
888 'status'
889 ];
890
891 $updates = [];
892 $changes = [];
893
894 foreach ($data as $key => $value) {
895 if (!in_array($key, $allowedFields)) {
896 continue;
897 }
898
899 // Normalize recurring_total from frontend decimal to cents before comparison
900 if ($key === 'recurring_total') {
901 $value = (int) round(floatval($value) * 100);
902 }
903
904 $oldValue = $subscription->{$key};
905
906 // DB attributes come back as strings — normalize numeric fields on both
907 // sides or the strict compare below always reports a change.
908 if (in_array($key, ['recurring_total', 'bill_times'])) {
909 $oldValue = (int) $oldValue;
910 $value = intval($value);
911 }
912
913 if ($oldValue === $value) {
914 continue;
915 }
916
917 // Validate and convert numeric fields
918 if (in_array($key, ['recurring_total', 'bill_times'])) {
919 $value = $key === 'bill_times' ? intval($value) : $value; // recurring_total already converted above
920 if ($key === 'bill_times' && $value < 0) {
921 return new \WP_Error(
922 'invalid_value',
923 __('bill_times cannot be negative.', 'fluent-cart')
924 );
925 }
926 if ($key === 'bill_times' && $value > 0 && $value < $subscription->bill_count) {
927 return new \WP_Error(
928 'invalid_value',
929 sprintf(
930 __('bill_times cannot be less than the number of payments already made (%d).', 'fluent-cart'),
931 $subscription->bill_count
932 )
933 );
934 }
935 if ($key === 'recurring_total' && $value < 0) {
936 return new \WP_Error(
937 'invalid_value',
938 __('recurring_total cannot be negative.', 'fluent-cart')
939 );
940 }
941
942 if ($key === 'recurring_total') {
943 // Keep recurring_amount in sync: total minus existing tax
944 $recurringAmount = $value - ($subscription->recurring_tax_total ?? 0);
945 if ($recurringAmount < 0) {
946 return new \WP_Error(
947 'invalid_value',
948 __('recurring_total cannot be less than the existing tax total.', 'fluent-cart')
949 );
950 }
951 $updates['recurring_amount'] = $recurringAmount;
952 }
953 }
954
955 if ($key === 'billing_interval') {
956 $validIntervals = apply_filters('fluent_cart/subscription/allowed_intervals', ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'], [
957 'subscription' => $subscription,
958 'current_interval' => $subscription->billing_interval,
959 'new_interval' => $value
960 ]);
961
962 if (!in_array($value, $validIntervals)) {
963 return new \WP_Error(
964 'invalid_interval',
965 __('Invalid billing interval.', 'fluent-cart')
966 );
967 }
968
969 $incomingDate = isset($data['next_billing_date']) ? $data['next_billing_date'] : null;
970 $adminChangedDate = $incomingDate && $incomingDate !== $subscription->next_billing_date;
971 if (!$adminChangedDate && $subscription->next_billing_date) {
972 $oldInterval = $subscription->billing_interval;
973 $subscription->billing_interval = $value;
974 $updates['next_billing_date'] = $subscription->guessNextBillingDate(true);
975 $subscription->billing_interval = $oldInterval;
976 }
977 }
978
979 if ($key === 'status') {
980 $validStatuses = [
981 Status::SUBSCRIPTION_ACTIVE,
982 Status::SUBSCRIPTION_PAUSED,
983 Status::SUBSCRIPTION_TRIALING,
984 Status::SUBSCRIPTION_CANCELED,
985 Status::SUBSCRIPTION_EXPIRED,
986 Status::SUBSCRIPTION_COMPLETED,
987 Status::SUBSCRIPTION_PAST_DUE
988 ];
989 if (!in_array($value, $validStatuses)) {
990 return new \WP_Error(
991 'invalid_status',
992 __('Invalid subscription status.', 'fluent-cart')
993 );
994 }
995
996 // Sync companion fields; syncSubscriptionStates is intentionally
997 // not used here because its EOT check recalculates bill_count and
998 // can silently override the admin's explicit status choice.
999 if ($value === Status::SUBSCRIPTION_CANCELED && empty($subscription->canceled_at)) {
1000 $updates['canceled_at'] = gmdate('Y-m-d H:i:s');
1001 } elseif (in_array($value, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) {
1002 $updates['canceled_at'] = null;
1003 if (empty($subscription->next_billing_date) && empty($data['next_billing_date'])) {
1004 $updates['next_billing_date'] = $subscription->guessNextBillingDate();
1005 }
1006 } elseif (in_array($value, [Status::SUBSCRIPTION_COMPLETED, Status::SUBSCRIPTION_EXPIRED])) {
1007 $updates['next_billing_date'] = null;
1008 }
1009
1010 $terminalStatuses = [
1011 Status::SUBSCRIPTION_CANCELED,
1012 Status::SUBSCRIPTION_COMPLETED,
1013 Status::SUBSCRIPTION_EXPIRED,
1014 ];
1015 if (in_array($value, $terminalStatuses)) {
1016 self::voidPendingRenewals(
1017 $subscription,
1018 sprintf('Subscription marked as %s by admin.', $value)
1019 );
1020 }
1021
1022 // Same contract as pauseSubscription(): pausing retires the open
1023 // invoice (and its queued system charge) so nothing collects while
1024 // the subscription is paused.
1025 if ($value === Status::SUBSCRIPTION_PAUSED && $subscription->status !== Status::SUBSCRIPTION_PAUSED) {
1026 self::voidPendingRenewals(
1027 $subscription,
1028 __('Subscription paused; open renewal order voided.', 'fluent-cart')
1029 );
1030 }
1031 }
1032
1033 $updates[$key] = $value;
1034 $logOld = $key === 'recurring_total' ? number_format($oldValue / 100, 2) : $oldValue;
1035 $logNew = $key === 'recurring_total' ? number_format($value / 100, 2) : $value;
1036 $changes[] = sprintf('%s: %s → %s', $key, $logOld, $logNew);
1037 }
1038
1039 if (empty($updates)) {
1040 return new \WP_Error(
1041 'no_changes',
1042 __('No changes detected.', 'fluent-cart')
1043 );
1044 }
1045
1046 $oldStatus = $subscription->status;
1047
1048 foreach ($updates as $key => $value) {
1049 $subscription->{$key} = $value;
1050 }
1051
1052 $subscription->save();
1053
1054 // Sync pending renewal invoice if amount or due date changed
1055 $amountChanged = isset($updates['recurring_total']);
1056 $dueDateChanged = isset($updates['next_billing_date']);
1057
1058 if ($amountChanged || $dueDateChanged) {
1059 $pendingInvoice = Order::query()
1060 ->where('parent_id', $subscription->parent_order_id)
1061 ->where('type', 'renewal')
1062 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
1063 ->first();
1064
1065 if ($pendingInvoice) {
1066 if ($amountChanged) {
1067 $newTotal = $subscription->recurring_total;
1068 $newTax = $subscription->recurring_tax_total;
1069 $newSubtotal = $subscription->recurring_amount;
1070
1071 $pendingInvoice->subtotal = $newSubtotal;
1072 $pendingInvoice->tax_total = $newTax;
1073 $pendingInvoice->total_amount = $newTotal;
1074 $pendingInvoice->save();
1075
1076 // Same unit_price convention as RenewalService::createRenewalOrders():
1077 // gross per-unit for inclusive tax (behavior 2), net otherwise — the
1078 // re-pay checkout feeds unit_price back as item_price, so a net value
1079 // on an inclusive-tax invoice would drop the included tax.
1080 $unitPriceBase = $pendingInvoice->tax_behavior == 2 ? $newTotal : $newSubtotal;
1081
1082 OrderItem::query()
1083 ->where('order_id', $pendingInvoice->id)
1084 ->update([
1085 'subtotal' => $newSubtotal,
1086 'tax_amount' => $newTax,
1087 'line_total' => $newTotal,
1088 'unit_price' => $subscription->quantity > 1
1089 ? (int) round($unitPriceBase / $subscription->quantity)
1090 : $unitPriceBase,
1091 ]);
1092
1093 OrderTransaction::query()
1094 ->where('order_id', $pendingInvoice->id)
1095 ->where('status', Status::TRANSACTION_PENDING)
1096 ->update(['total' => $newTotal]);
1097 }
1098
1099 if ($dueDateChanged) {
1100 $pendingInvoice->updateMeta('due_date', $subscription->next_billing_date);
1101
1102 if ($subscription->isSystem()) {
1103 SystemChargeService::unscheduleCharges($pendingInvoice);
1104 SystemChargeService::scheduleCharge($pendingInvoice, $subscription);
1105 }
1106 }
1107
1108 $pendingInvoice->addLog(
1109 'Renewal order updated by subscription edit',
1110 'Pending renewal order synced after admin edited subscription details.',
1111 'info'
1112 );
1113 }
1114 }
1115
1116 $subscription->addLog(
1117 'Subscription updated',
1118 sprintf('Admin updated: %s', implode(', ', $changes)),
1119 'info'
1120 );
1121
1122 // Fires fluent_cart/subscription_updated once, with the original
1123 // subscription/updates/changes keys plus order/customer.
1124 self::dispatchStatusEvent($subscription, 'updated', [
1125 'updates' => $updates,
1126 'changes' => $changes,
1127 ]);
1128
1129 // Same contract as syncSubscriptionStates()'s no-status-change branch —
1130 // Pro's license-extension listener only reacts to this hook.
1131 do_action('fluent_cart/subscription/data_updated', [
1132 'subscription' => $subscription,
1133 'updated_data' => $updates
1134 ]);
1135
1136 if ($oldStatus !== $subscription->status) {
1137 do_action('fluent_cart/payments/subscription_status_changed', [
1138 'subscription' => $subscription,
1139 'order' => $subscription->order,
1140 'customer' => $subscription->customer,
1141 'old_status' => $oldStatus,
1142 'new_status' => $subscription->status,
1143 ]);
1144
1145 do_action('fluent_cart/payments/subscription_' . $subscription->status, [
1146 'subscription' => $subscription,
1147 'order' => $subscription->order,
1148 'customer' => $subscription->customer,
1149 'old_status' => $oldStatus,
1150 'new_status' => $subscription->status,
1151 ]);
1152
1153 if ($subscription->status === Status::SUBSCRIPTION_EXPIRED) {
1154 $subscription->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
1155 (new SubscriptionValidityExpired($subscription, $subscription->order, $subscription->customer))->dispatch();
1156 }
1157
1158 if ($subscription->status === Status::SUBSCRIPTION_COMPLETED) {
1159 (new SubscriptionEOT($subscription, $subscription->order))->dispatch();
1160 }
1161
1162 // Event was the only missing cancel side-effect on the edit path; the
1163 // event now drives both email and reminder-clear.
1164 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1165 self::finalizeCancellation($subscription, __('Canceled by admin edit', 'fluent-cart'));
1166 }
1167
1168 if ($subscription->status === Status::SUBSCRIPTION_ACTIVE &&
1169 in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) {
1170 (new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch();
1171 }
1172
1173 // Same contract as pauseSubscription()/resumeSubscription(): fire the
1174 // dedicated pause/resume events, and re-queue any system charge that was
1175 // skipped while paused so a resumed subscription cannot strand a
1176 // payment_scheduled invoice.
1177 if ($subscription->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) {
1178 self::dispatchStatusEvent($subscription, 'paused', ['old_status' => $oldStatus]);
1179 }
1180
1181 if ($subscription->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) {
1182 SystemChargeService::restoreScheduledChargesForSubscription($subscription);
1183 self::dispatchStatusEvent($subscription, 'resumed', ['old_status' => $oldStatus]);
1184 }
1185 }
1186
1187 if (isset($updates['bill_times']) && !isset($updates['status'])) {
1188 $preSyncStatus = $subscription->status;
1189 self::syncSubscriptionStates($subscription, []);
1190 if ($subscription->status === Status::SUBSCRIPTION_COMPLETED
1191 && $preSyncStatus !== Status::SUBSCRIPTION_COMPLETED
1192 ) {
1193 self::voidPendingRenewals(
1194 $subscription,
1195 __('Subscription completed — billing times reached.', 'fluent-cart')
1196 );
1197 }
1198 }
1199
1200 return true;
1201 }
1202
1203 /**
1204 * Single cancellation chokepoint. Voids open renewals and dispatches the
1205 * SubscriptionCanceled event so email + reminder-clear (both listen on the
1206 * event hook) fire once, regardless of which cancel path ran.
1207 *
1208 * @param bool $dispatchEvent fire SubscriptionCanceled (email/reminder-clear/automations)
1209 */
1210 public static function finalizeCancellation(Subscription $subscription, string $reason = '', bool $dispatchEvent = true): void
1211 {
1212 self::voidPendingRenewals($subscription, $reason ?: __('Subscription canceled', 'fluent-cart'));
1213
1214 if ($dispatchEvent) {
1215 (new SubscriptionCanceled($subscription, $subscription->order, $subscription->customer, $reason))->dispatch();
1216 }
1217 }
1218
1219 /**
1220 * Void all pending renewal invoices for a subscription.
1221 * Sets order status to canceled and payment_status to failed.
1222 */
1223 public static function voidPendingRenewals(Subscription $subscription, string $reason = ''): void
1224 {
1225 $pendingInvoices = Order::query()
1226 ->where('parent_id', $subscription->parent_order_id)
1227 ->where('type', 'renewal')
1228 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
1229 ->get();
1230
1231 foreach ($pendingInvoices as $invoice) {
1232 if ($subscription->isSystem()) {
1233 SystemChargeService::unscheduleCharges($invoice);
1234
1235 $chargeState = $subscription->getMeta('system_charge_state', []) ?: [];
1236 if ((int) Arr::get($chargeState, 'order_id') === (int) $invoice->id) {
1237 $subscription->deleteMeta('system_charge_state');
1238 }
1239 }
1240
1241 // Re-assert payment_status at mutation time — a webhook may have paid this
1242 // invoice between the select above and this update (same pattern as
1243 // reactivateSubscriptionLocally). A raced-paid invoice is left untouched.
1244 $voided = Order::query()
1245 ->where('id', $invoice->id)
1246 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
1247 ->update([
1248 'status' => Status::ORDER_CANCELED,
1249 'payment_status' => Status::PAYMENT_FAILED,
1250 ]);
1251
1252 if (!$voided) {
1253 continue;
1254 }
1255
1256 OrderTransaction::query()
1257 ->where('order_id', $invoice->id)
1258 ->where('status', Status::TRANSACTION_PENDING)
1259 ->update(['status' => Status::TRANSACTION_FAILED]);
1260
1261 $invoice->addLog(
1262 'Renewal order voided',
1263 $reason ?: 'Renewal order voided automatically.',
1264 'info'
1265 );
1266 }
1267 }
1268 }
1269