PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
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 / Modules / Subscriptions / Services / SubscriptionService.php

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

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