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

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