PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Modules / Subscriptions / Services / SubscriptionService.php

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

1,444 lines 62.1 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 = self::normalizeGatewayTime(Arr::get($transactionData, 'created_at'));
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' => $createdAt,
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 = [], $expectedStatus = null)
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
399 if ($expectedStatus !== null) {
400 // Compare-and-swap: only write if the row still holds the expected status, so a
401 // concurrent transition (e.g. a renewal payment reactivating a past_due row) is
402 // never clobbered. On a lost race, skip all side effects below.
403 $writable = $dirtyData;
404 unset($writable['meta']);
405
406 $affected = Subscription::query()
407 ->where('id', $subscriptionModel->id)
408 ->where('status', $expectedStatus)
409 ->update($writable);
410
411 if (!$affected) {
412 return null;
413 }
414
415 $subscriptionModel->syncOriginal();
416 } else {
417 $subscriptionModel->save();
418 }
419
420 $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', []));
421
422 foreach ($meta as $key => $value) {
423 $subscriptionModel->updateMeta($key, $value);
424 }
425
426 // The gateway on file just changed (a renewal invoice paid through a different
427 // gateway, an admin edit). `system` is only meaningful while that gateway can
428 // token-charge, so re-derive it — otherwise the subscription keeps claiming
429 // auto-charge against a gateway that will refuse every attempt.
430 if (isset($dirtyData['current_payment_method'])) {
431 SystemChargeService::reconcileGatewayCapability($subscriptionModel);
432 }
433
434 // validity_expired_at should only exist when status IS expired
435 if ($subscriptionModel->status !== Status::SUBSCRIPTION_EXPIRED) {
436 $subscriptionModel->deleteMeta('validity_expired_at');
437 }
438
439 if ($oldStatus === $subscriptionModel->status) {
440 if ($dirtyData) {
441 do_action('fluent_cart/subscription/data_updated', [
442 'subscription' => $subscriptionModel,
443 'updated_data' => $dirtyData
444 ]);
445 }
446
447 return $subscriptionModel; // No change in status
448 }
449
450 if ($isEot) {
451 (new SubscriptionEOT($subscriptionModel, $subscriptionModel->order))->dispatch();
452 }
453
454 do_action('fluent_cart/payments/subscription_status_changed', [
455 'subscription' => $subscriptionModel,
456 'order' => $subscriptionModel->order,
457 'customer' => $subscriptionModel->customer,
458 'old_status' => $oldStatus,
459 'new_status' => $subscriptionModel->status
460 ]);
461
462 /**
463 * lists of hooks for this action
464 * fluent_cart/payments/subscription_canceled
465 * fluent_cart/payments/subscription_active
466 * fluent_cart/payments/subscription_paused
467 * fluent_cart/payments/subscription_expired
468 * fluent_cart/payments/subscription_failing
469 * fluent_cart/payments/subscription_expiring
470 * fluent_cart/payments/subscription_completed
471 **/
472 do_action('fluent_cart/payments/subscription_' . $subscriptionModel->status, [
473 'subscription' => $subscriptionModel,
474 'order' => $subscriptionModel->order,
475 'customer' => $subscriptionModel->customer,
476 'old_status' => $oldStatus,
477 'new_status' => $subscriptionModel->status
478 ]);
479
480 // Gateway-originated cancel (webhook) reaches only the raw status bus above;
481 // route it through the chokepoint so void + native event fire like every
482 // other path. The old_status === status early return keeps this once-only.
483 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED) {
484 self::finalizeCancellation(
485 $subscriptionModel,
486 Arr::get($subscriptionUpdateArgs, 'reason', __('Canceled at gateway', 'fluent-cart'))
487 );
488 }
489
490 // note: we needed this event, currently being used in integrations
491 if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) {
492 $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
493 (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch();
494 }
495
496 if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE &&
497 in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) {
498 (new SubscriptionReactivated($subscriptionModel, $subscriptionModel->order, $subscriptionModel->customer, $oldStatus))->dispatch();
499 }
500
501 if ($subscriptionModel->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) {
502 self::dispatchStatusEvent($subscriptionModel, 'paused', ['old_status' => $oldStatus]);
503 }
504
505 if ($subscriptionModel->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) {
506 self::dispatchStatusEvent($subscriptionModel, 'resumed', ['old_status' => $oldStatus]);
507 }
508
509 return $subscriptionModel;
510 }
511
512
513 /**
514 *
515 * Use this method when you are reactivating a expired subscription manually by creating order, transaction etc.
516 * Make sure you already handle your transaction statuses!
517 *
518 * @param \FluentCart\App\Models\Subscription $subscriptionModel
519 * @param \FluentCart\App\Models\OrderTransaction $transaction
520 * @param $args
521 * @return mixed
522 */
523 public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = [])
524 {
525 $renewalOrder = $transaction->order;
526
527 $settledAt = Arr::get((array) $transaction->meta, 'settled_at');
528
529 // payment_status and total_paid are deliberately NOT set here — every caller has
530 // already marked the transaction succeeded, and syncOrderStatuses() below derives
531 // both from the transactions and claims the pending → paid transition atomically.
532 // Pre-setting them destroyed that transition, which (a) suppressed
533 // fluent_cart/renewal_paid, so RenewalService::handleRenewalPaid() never
534 // advanced next_billing_date (the customer was re-invoiced forever), and
535 // (b) bypassed the atomic claim that stops a webhook and a browser confirmation
536 // from both processing the same renewal payment.
537 $orderUpdateData = [
538 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED,
539 'type' => Status::ORDER_TYPE_RENEWAL,
540 'payment_method' => $transaction->payment_method,
541 'completed_at' => self::normalizeGatewayTime($settledAt)
542 ];
543
544 $renewalOrder->fill($orderUpdateData);
545 $renewalOrder->save();
546
547 if ($billingInfo = Arr::get($args, 'billing_info', [])) {
548 $subscriptionModel->updateMeta('active_payment_method', $billingInfo);
549 }
550
551 $updateData = wp_parse_args(Arr::get($args, 'subscription_args', []), [
552 'status' => Status::SUBSCRIPTION_ACTIVE,
553 'current_payment_method' => $transaction->payment_method,
554 ]);
555
556 $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData);
557
558 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
559
560 // Single-event contract for renewal processing — exactly one owner per
561 // subscription type, so SubscriptionRenewed fires exactly once:
562 //
563 // store-billed (manual/system) → RenewalService::handleRenewalPaid(),
564 // reached through the fluent_cart/renewal_paid hook that
565 // syncOrderStatuses() fires above. It advances next_billing_date,
566 // derives bill_count / EOT, and dispatches the event.
567 // gateway-billed (automatic) → here. The invoice engine does not handle
568 // these, so this is their only dispatch point.
569 //
570 // Keyed on the collection method rather than on the `renewal_processed`
571 // marker: handleRenewalPaid() stamps that marker before its EOT early-return,
572 // so a marker check would swallow the event on a final installment.
573 if ($transaction->total > 0 && !$subscriptionModel->usesRenewalEngine()) {
574 (new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch();
575 }
576
577 return $subscriptionModel;
578 }
579
580 /**
581 * A gateway-supplied charge time (meta.settled_at / created_at) normalized to a
582 * GMT datetime string, falling back to now when absent or unparseable — a
583 * malformed timestamp must never fatal a webhook.
584 *
585 * @param mixed $time
586 * @return string
587 */
588 private static function normalizeGatewayTime($time)
589 {
590 if ($time) {
591 try {
592 return DateTime::anyTimeToGmt($time)->format('Y-m-d H:i:s');
593 } catch (\Exception $e) {
594 // fall through to now
595 }
596 }
597
598 return DateTime::now()->format('Y-m-d H:i:s');
599 }
600
601 /**
602 * Single dispatch point for subscription lifecycle status events.
603 *
604 * Every confirmed transition — manual local update, gateway sync response, or
605 * gateway webhook/confirmation — routes through here so the first-class event
606 * (and the hook it fires) happens exactly once, whatever path caused the change.
607 *
608 * @param Subscription $subscription
609 * @param string $event One of: paused, resumed, updated, period_skipped
610 * @param array $context order, customer, old_status, reason, updates, changes,
611 * old_next_billing_date, new_next_billing_date
612 * @return void
613 */
614 public static function dispatchStatusEvent(Subscription $subscription, string $event, array $context = [])
615 {
616 $order = Arr::get($context, 'order') ?: $subscription->order;
617 $customer = Arr::get($context, 'customer') ?: ($order ? $order->customer : null);
618 $oldStatus = Arr::get($context, 'old_status');
619 $reason = (string) Arr::get($context, 'reason', '');
620
621 switch ($event) {
622 case 'paused':
623 (new SubscriptionPaused($subscription, $order, $customer, $oldStatus, $reason))->dispatch();
624 break;
625 case 'resumed':
626 (new SubscriptionResumed($subscription, $order, $customer, $oldStatus, $reason))->dispatch();
627 break;
628 case 'updated':
629 (new SubscriptionUpdated($subscription, $order, $customer, Arr::get($context, 'updates', []), Arr::get($context, 'changes', [])))->dispatch();
630 break;
631 case 'period_skipped':
632 (new SubscriptionPeriodSkipped($subscription, $order, $customer, Arr::get($context, 'old_next_billing_date'), Arr::get($context, 'new_next_billing_date')))->dispatch();
633 break;
634 }
635 }
636
637 /**
638 * Pause a subscription
639 *
640 * For manual subscriptions, this just updates the local status.
641 * For automatic subscriptions, delegates to the gateway.
642 *
643 * @param Subscription $subscription
644 * @param string $reason
645 * @return true|\WP_Error
646 */
647 public static function pauseSubscription(Subscription $subscription, $reason = '')
648 {
649 if (!$subscription->canPause()) {
650 return new \WP_Error(
651 'cannot_pause',
652 __('This subscription cannot be paused.', 'fluent-cart')
653 );
654 }
655
656 // Store-billed (manual/system) subscriptions: local status update.
657 if ($subscription->usesRenewalEngine()) {
658 $oldStatus = $subscription->status;
659 $subscription->status = Status::SUBSCRIPTION_PAUSED;
660 $subscription->save();
661
662 self::voidPendingRenewals(
663 $subscription,
664 'Subscription paused; open renewal order voided.'
665 );
666
667 $subscription->addLog(
668 'Subscription paused',
669 $reason ?: __('Subscription paused manually', 'fluent-cart'),
670 'info'
671 );
672
673 // Fires fluent_cart/subscription_paused once, with the original
674 // subscription/reason keys plus order/customer/old_status.
675 self::dispatchStatusEvent($subscription, 'paused', [
676 'old_status' => $oldStatus,
677 'reason' => $reason,
678 ]);
679
680 return true;
681 }
682
683 // Automatic subscriptions: delegate to gateway
684 $gateway = App::gateway($subscription->current_payment_method);
685
686 if (!$gateway || !in_array('pause_subscription', $gateway->supportedFeatures)) {
687 return new \WP_Error(
688 'unsupported_pause',
689 __('Current payment method does not support pausing.', 'fluent-cart')
690 );
691 }
692
693 if (method_exists($gateway->subscriptions, 'pause')) {
694 return $gateway->subscriptions->pause($subscription, $reason);
695 }
696
697 return new \WP_Error(
698 'unsupported_pause',
699 __('Current payment method does not support pausing.', 'fluent-cart')
700 );
701 }
702
703 /**
704 * Resume a paused subscription
705 *
706 * For manual subscriptions, this updates status back to active.
707 * For automatic subscriptions, delegates to the gateway.
708 *
709 * @param Subscription $subscription
710 * @param string $reason
711 * @return true|\WP_Error
712 */
713 public static function resumeSubscription(Subscription $subscription, $reason = '')
714 {
715 if (!$subscription->canResume()) {
716 return new \WP_Error(
717 'cannot_resume',
718 __('This subscription cannot be resumed.', 'fluent-cart')
719 );
720 }
721
722 // Store-billed (manual/system) subscriptions: local status update.
723 if ($subscription->usesRenewalEngine()) {
724 $oldStatus = $subscription->status;
725 $subscription->status = Status::SUBSCRIPTION_ACTIVE;
726 $subscription->save();
727
728 SystemChargeService::restoreScheduledChargesForSubscription($subscription);
729
730 $subscription->addLog(
731 'Subscription resumed',
732 $reason ?: __('Subscription resumed manually', 'fluent-cart'),
733 'info'
734 );
735
736 // Fires fluent_cart/subscription_resumed once, with the original
737 // subscription/reason keys plus order/customer/old_status.
738 self::dispatchStatusEvent($subscription, 'resumed', [
739 'old_status' => $oldStatus,
740 'reason' => $reason,
741 ]);
742
743 return true;
744 }
745
746 // Automatic subscriptions: delegate to gateway
747 $gateway = App::gateway($subscription->current_payment_method);
748
749 if (!$gateway || !in_array('resume_subscription', $gateway->supportedFeatures)) {
750 return new \WP_Error(
751 'unsupported_resume',
752 __('Current payment method does not support resuming.', 'fluent-cart')
753 );
754 }
755
756 if (method_exists($gateway->subscriptions, 'resume')) {
757 return $gateway->subscriptions->resume($subscription, $reason);
758 }
759
760 return new \WP_Error(
761 'unsupported_resume',
762 __('Current payment method does not support resuming.', 'fluent-cart')
763 );
764 }
765
766 /**
767 * Reactivate a canceled/expired store-billed (manual or system) subscription locally —
768 * no gateway/checkout involved. Voids any pending renewal invoice from the missed
769 * period and advances next_billing_date so the overdue scanner doesn't immediately
770 * re-flag it. Shared by the admin reactivate endpoint and the customer-dashboard
771 * future-dated reactivation short-circuit.
772 *
773 * @param Subscription $subscription
774 * @return Subscription|\WP_Error
775 */
776 public static function reactivateSubscriptionLocally(Subscription $subscription)
777 {
778 if (!$subscription->usesRenewalEngine()) {
779 return new \WP_Error(
780 'unsupported_local_reactivation',
781 __('This subscription must be reactivated through its payment gateway.', 'fluent-cart')
782 );
783 }
784
785 if (!$subscription->canReactivate()) {
786 return new \WP_Error(
787 'cannot_reactivate',
788 __('This subscription cannot be reactivated.', 'fluent-cart')
789 );
790 }
791
792 global $wpdb;
793
794 $wpdb->query('START TRANSACTION');
795
796 try {
797 // Lock subscription before orders — skipNextPeriod locks in this order too;
798 // diverging risks a deadlock on the same rows.
799 $locked = Subscription::query()
800 ->where('id', $subscription->id)
801 ->lockForUpdate()
802 ->first();
803
804 // Re-check under the lock: canReactivate() ran on pre-lock state.
805 $subscription->fill([
806 'status' => $locked ? $locked->status : $subscription->status,
807 'next_billing_date' => $locked ? $locked->next_billing_date : $subscription->next_billing_date,
808 ]);
809
810 if (!$locked || !$subscription->canReactivate()) {
811 $wpdb->query('ROLLBACK');
812 return new \WP_Error(
813 'cannot_reactivate',
814 __('This subscription cannot be reactivated.', 'fluent-cart')
815 );
816 }
817
818 $oldStatus = $subscription->status;
819
820 $pendingOrderIds = Order::query()
821 ->where('type', Status::ORDER_TYPE_RENEWAL)
822 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
823 ->where('parent_id', $subscription->parent_order_id)
824 ->pluck('id');
825
826 if ($pendingOrderIds->isNotEmpty()) {
827 // Re-assert payment_status at mutation time — a webhook may have paid
828 // this order between the select above and this update.
829 Order::query()
830 ->whereIn('id', $pendingOrderIds)
831 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
832 ->update([
833 'status' => Status::ORDER_CANCELED,
834 'payment_status' => Status::PAYMENT_FAILED,
835 ]);
836
837 // Only fail transactions for orders actually voided above — a
838 // paid-in-the-race order is excluded by the update's payment_status
839 // predicate, so it must be excluded here too.
840 $voidedOrderIds = Order::query()
841 ->whereIn('id', $pendingOrderIds)
842 ->where('status', Status::ORDER_CANCELED)
843 ->where('payment_status', Status::PAYMENT_FAILED)
844 ->pluck('id');
845
846 if ($voidedOrderIds->isNotEmpty()) {
847 OrderTransaction::query()
848 ->whereIn('order_id', $voidedOrderIds)
849 ->where('status', Status::TRANSACTION_PENDING)
850 ->update(['status' => Status::TRANSACTION_FAILED]);
851 }
852 }
853
854 // Overdue date must not survive reactivation, or it lands instantly due.
855 if ($subscription->next_billing_date && strtotime($subscription->next_billing_date) <= time()) {
856 $advancedDate = RenewalService::computeSkippedDate($subscription);
857 if ($advancedDate) {
858 $subscription->fill(['next_billing_date' => $advancedDate]);
859 }
860 }
861
862 // Not syncSubscriptionStates: its EOT check would flip status to completed.
863 $reactivationData = [
864 'status' => Status::SUBSCRIPTION_ACTIVE,
865 'canceled_at' => null,
866 ];
867
868 // Guess can itself be in the past (empty date + old last order) — advance past now.
869 if (empty($subscription->next_billing_date)
870 || strtotime($subscription->next_billing_date) <= time()) {
871 $guessedTs = strtotime($subscription->guessNextBillingDate());
872 $intervalDays = PaymentHelper::getIntervalDays($subscription->billing_interval);
873 if ($intervalDays > 0) {
874 while ($guessedTs <= time()) {
875 $guessedTs += $intervalDays * DAY_IN_SECONDS;
876 }
877 }
878 $reactivationData['next_billing_date'] = gmdate('Y-m-d H:i:s', $guessedTs);
879 }
880
881 $subscription->fill($reactivationData)->save();
882
883 $wpdb->query('COMMIT');
884 } catch (\Throwable $e) {
885 $wpdb->query('ROLLBACK');
886
887 return new \WP_Error('reactivation_failed', $e->getMessage());
888 }
889
890 do_action('fluent_cart/payments/subscription_status_changed', [
891 'subscription' => $subscription,
892 'order' => $subscription->order,
893 'customer' => $subscription->customer,
894 'old_status' => $oldStatus,
895 'new_status' => Status::SUBSCRIPTION_ACTIVE,
896 ]);
897
898 do_action('fluent_cart/payments/subscription_active', [
899 'subscription' => $subscription,
900 'order' => $subscription->order,
901 'customer' => $subscription->customer,
902 'old_status' => $oldStatus,
903 'new_status' => Status::SUBSCRIPTION_ACTIVE,
904 ]);
905
906 if (in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) {
907 (new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch();
908 }
909
910 do_action('fluent_cart/subscription/reactivated_locally', $subscription);
911
912 return $subscription;
913 }
914
915 /**
916 * Update subscription details (for manual subscriptions)
917 *
918 * Allowed fields for manual subscriptions:
919 * - recurring_total: Update the next invoice/payment amount (in cents)
920 * - bill_times: Update the number of billing cycles (0 = unlimited)
921 * - billing_interval: Change billing frequency (daily, weekly, monthly, etc.)
922 * - expire_at: Update expiration date
923 * - trial_days: Update trial period
924 * - next_billing_date: Update next billing date
925 *
926 * @param Subscription $subscription
927 * @param array $data
928 * @return true|\WP_Error
929 */
930 public static function updateSubscription(Subscription $subscription, array $data)
931 {
932 if (!$subscription->usesRenewalEngine()) {
933 return new \WP_Error(
934 'cannot_update_automatic',
935 __('Only store-billed (manual or auto-charge) subscriptions can be updated directly.', 'fluent-cart')
936 );
937 }
938
939 $allowedFields = [
940 'recurring_total',
941 'bill_times',
942 'billing_interval',
943 'next_billing_date',
944 'status'
945 ];
946
947 $updates = [];
948 $changes = [];
949
950 foreach ($data as $key => $value) {
951 if (!in_array($key, $allowedFields)) {
952 continue;
953 }
954
955 // Normalize recurring_total from frontend decimal to cents before comparison
956 if ($key === 'recurring_total') {
957 $value = (int) round(floatval($value) * 100);
958 }
959
960 $oldValue = $subscription->{$key};
961
962 // DB attributes come back as strings — normalize numeric fields on both
963 // sides or the strict compare below always reports a change.
964 if (in_array($key, ['recurring_total', 'bill_times'])) {
965 $oldValue = (int) $oldValue;
966 $value = intval($value);
967 }
968
969 if ($oldValue === $value) {
970 continue;
971 }
972
973 // Validate and convert numeric fields
974 if (in_array($key, ['recurring_total', 'bill_times'])) {
975 $value = $key === 'bill_times' ? intval($value) : $value; // recurring_total already converted above
976 if ($key === 'bill_times' && $value < 0) {
977 return new \WP_Error(
978 'invalid_value',
979 __('bill_times cannot be negative.', 'fluent-cart')
980 );
981 }
982 if ($key === 'bill_times' && $value > 0 && $value < $subscription->bill_count) {
983 return new \WP_Error(
984 'invalid_value',
985 sprintf(
986 __('bill_times cannot be less than the number of payments already made (%d).', 'fluent-cart'),
987 $subscription->bill_count
988 )
989 );
990 }
991 if ($key === 'recurring_total' && $value < 0) {
992 return new \WP_Error(
993 'invalid_value',
994 __('recurring_total cannot be negative.', 'fluent-cart')
995 );
996 }
997
998 if ($key === 'recurring_total') {
999 // Keep recurring_amount in sync: total minus existing tax
1000 $recurringAmount = $value - ($subscription->recurring_tax_total ?? 0);
1001 if ($recurringAmount < 0) {
1002 return new \WP_Error(
1003 'invalid_value',
1004 __('recurring_total cannot be less than the existing tax total.', 'fluent-cart')
1005 );
1006 }
1007 $updates['recurring_amount'] = $recurringAmount;
1008 }
1009 }
1010
1011 if ($key === 'billing_interval') {
1012 $validIntervals = apply_filters('fluent_cart/subscription/allowed_intervals', ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'], [
1013 'subscription' => $subscription,
1014 'current_interval' => $subscription->billing_interval,
1015 'new_interval' => $value
1016 ]);
1017
1018 if (!in_array($value, $validIntervals)) {
1019 return new \WP_Error(
1020 'invalid_interval',
1021 __('Invalid billing interval.', 'fluent-cart')
1022 );
1023 }
1024
1025 $incomingDate = isset($data['next_billing_date']) ? $data['next_billing_date'] : null;
1026 $adminChangedDate = $incomingDate && $incomingDate !== $subscription->next_billing_date;
1027 if (!$adminChangedDate && $subscription->next_billing_date) {
1028 $oldInterval = $subscription->billing_interval;
1029 $subscription->billing_interval = $value;
1030 $updates['next_billing_date'] = $subscription->guessNextBillingDate(true);
1031 $subscription->billing_interval = $oldInterval;
1032 }
1033 }
1034
1035 if ($key === 'status') {
1036 $validStatuses = [
1037 Status::SUBSCRIPTION_ACTIVE,
1038 Status::SUBSCRIPTION_PAUSED,
1039 Status::SUBSCRIPTION_TRIALING,
1040 Status::SUBSCRIPTION_CANCELED,
1041 Status::SUBSCRIPTION_EXPIRED,
1042 Status::SUBSCRIPTION_COMPLETED,
1043 Status::SUBSCRIPTION_PAST_DUE
1044 ];
1045 if (!in_array($value, $validStatuses)) {
1046 return new \WP_Error(
1047 'invalid_status',
1048 __('Invalid subscription status.', 'fluent-cart')
1049 );
1050 }
1051
1052 // Sync companion fields; syncSubscriptionStates is intentionally
1053 // not used here because its EOT check recalculates bill_count and
1054 // can silently override the admin's explicit status choice.
1055 if ($value === Status::SUBSCRIPTION_CANCELED && empty($subscription->canceled_at)) {
1056 $updates['canceled_at'] = gmdate('Y-m-d H:i:s');
1057 } elseif (in_array($value, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) {
1058 $updates['canceled_at'] = null;
1059 if (empty($subscription->next_billing_date) && empty($data['next_billing_date'])) {
1060 $updates['next_billing_date'] = $subscription->guessNextBillingDate();
1061 }
1062 } elseif (in_array($value, [Status::SUBSCRIPTION_COMPLETED, Status::SUBSCRIPTION_EXPIRED])) {
1063 $updates['next_billing_date'] = null;
1064 }
1065
1066 $terminalStatuses = [
1067 Status::SUBSCRIPTION_CANCELED,
1068 Status::SUBSCRIPTION_COMPLETED,
1069 Status::SUBSCRIPTION_EXPIRED,
1070 ];
1071 if (in_array($value, $terminalStatuses)) {
1072 self::voidPendingRenewals(
1073 $subscription,
1074 sprintf('Subscription marked as %s by admin.', $value)
1075 );
1076 }
1077
1078 // Same contract as pauseSubscription(): pausing retires the open
1079 // invoice (and its queued system charge) so nothing collects while
1080 // the subscription is paused.
1081 if ($value === Status::SUBSCRIPTION_PAUSED && $subscription->status !== Status::SUBSCRIPTION_PAUSED) {
1082 self::voidPendingRenewals(
1083 $subscription,
1084 __('Subscription paused; open renewal order voided.', 'fluent-cart')
1085 );
1086 }
1087 }
1088
1089 $updates[$key] = $value;
1090 $logOld = $key === 'recurring_total' ? number_format($oldValue / 100, 2) : $oldValue;
1091 $logNew = $key === 'recurring_total' ? number_format($value / 100, 2) : $value;
1092 $changes[] = sprintf('%s: %s → %s', $key, $logOld, $logNew);
1093 }
1094
1095 if (empty($updates)) {
1096 return new \WP_Error(
1097 'no_changes',
1098 __('No changes detected.', 'fluent-cart')
1099 );
1100 }
1101
1102 $oldStatus = $subscription->status;
1103
1104 foreach ($updates as $key => $value) {
1105 $subscription->{$key} = $value;
1106 }
1107
1108 $subscription->save();
1109
1110 // Sync pending renewal invoice if amount or due date changed
1111 $amountChanged = isset($updates['recurring_total']);
1112 $dueDateChanged = isset($updates['next_billing_date']);
1113
1114 if ($amountChanged || $dueDateChanged) {
1115 $pendingInvoice = Order::query()
1116 ->where('parent_id', $subscription->parent_order_id)
1117 ->where('type', 'renewal')
1118 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
1119 ->first();
1120
1121 if ($pendingInvoice) {
1122 if ($amountChanged) {
1123 $newTotal = $subscription->recurring_total;
1124 $newTax = $subscription->recurring_tax_total;
1125 $newSubtotal = $subscription->recurring_amount;
1126
1127 $pendingInvoice->subtotal = $newSubtotal;
1128 $pendingInvoice->tax_total = $newTax;
1129 $pendingInvoice->total_amount = $newTotal;
1130 $pendingInvoice->save();
1131
1132 // Same unit_price convention as RenewalService::createRenewalOrders():
1133 // gross per-unit for inclusive tax (behavior 2), net otherwise — the
1134 // re-pay checkout feeds unit_price back as item_price, so a net value
1135 // on an inclusive-tax invoice would drop the included tax.
1136 $unitPriceBase = $pendingInvoice->tax_behavior == 2 ? $newTotal : $newSubtotal;
1137
1138 OrderItem::query()
1139 ->where('order_id', $pendingInvoice->id)
1140 ->update([
1141 'subtotal' => $newSubtotal,
1142 'tax_amount' => $newTax,
1143 'line_total' => $newTotal,
1144 'unit_price' => $subscription->quantity > 1
1145 ? (int) round($unitPriceBase / $subscription->quantity)
1146 : $unitPriceBase,
1147 ]);
1148
1149 OrderTransaction::query()
1150 ->where('order_id', $pendingInvoice->id)
1151 ->where('status', Status::TRANSACTION_PENDING)
1152 ->update(['total' => $newTotal]);
1153 }
1154
1155 if ($dueDateChanged) {
1156 $pendingInvoice->updateMeta('due_date', $subscription->next_billing_date);
1157
1158 if ($subscription->isSystem()) {
1159 SystemChargeService::unscheduleCharges($pendingInvoice);
1160 SystemChargeService::scheduleCharge($pendingInvoice, $subscription);
1161 }
1162 }
1163
1164 $pendingInvoice->addLog(
1165 'Renewal order updated by subscription edit',
1166 'Pending renewal order synced after admin edited subscription details.',
1167 'info'
1168 );
1169 }
1170 }
1171
1172 $subscription->addLog(
1173 'Subscription updated',
1174 sprintf('Admin updated: %s', implode(', ', $changes)),
1175 'info'
1176 );
1177
1178 // Fires fluent_cart/subscription_updated once, with the original
1179 // subscription/updates/changes keys plus order/customer.
1180 self::dispatchStatusEvent($subscription, 'updated', [
1181 'updates' => $updates,
1182 'changes' => $changes,
1183 ]);
1184
1185 // Same contract as syncSubscriptionStates()'s no-status-change branch —
1186 // Pro's license-extension listener only reacts to this hook.
1187 do_action('fluent_cart/subscription/data_updated', [
1188 'subscription' => $subscription,
1189 'updated_data' => $updates
1190 ]);
1191
1192 if ($oldStatus !== $subscription->status) {
1193 do_action('fluent_cart/payments/subscription_status_changed', [
1194 'subscription' => $subscription,
1195 'order' => $subscription->order,
1196 'customer' => $subscription->customer,
1197 'old_status' => $oldStatus,
1198 'new_status' => $subscription->status,
1199 ]);
1200
1201 do_action('fluent_cart/payments/subscription_' . $subscription->status, [
1202 'subscription' => $subscription,
1203 'order' => $subscription->order,
1204 'customer' => $subscription->customer,
1205 'old_status' => $oldStatus,
1206 'new_status' => $subscription->status,
1207 ]);
1208
1209 if ($subscription->status === Status::SUBSCRIPTION_EXPIRED) {
1210 $subscription->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
1211 (new SubscriptionValidityExpired($subscription, $subscription->order, $subscription->customer))->dispatch();
1212 }
1213
1214 if ($subscription->status === Status::SUBSCRIPTION_COMPLETED) {
1215 (new SubscriptionEOT($subscription, $subscription->order))->dispatch();
1216 }
1217
1218 // Event was the only missing cancel side-effect on the edit path; the
1219 // event now drives both email and reminder-clear.
1220 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1221 self::finalizeCancellation($subscription, __('Canceled by admin edit', 'fluent-cart'));
1222 }
1223
1224 if ($subscription->status === Status::SUBSCRIPTION_ACTIVE &&
1225 in_array($oldStatus, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_EXPIRED])) {
1226 (new SubscriptionReactivated($subscription, $subscription->order, $subscription->customer, $oldStatus))->dispatch();
1227 }
1228
1229 // Same contract as pauseSubscription()/resumeSubscription(): fire the
1230 // dedicated pause/resume events, and re-queue any system charge that was
1231 // skipped while paused so a resumed subscription cannot strand a
1232 // payment_scheduled invoice.
1233 if ($subscription->status === Status::SUBSCRIPTION_PAUSED && $oldStatus === Status::SUBSCRIPTION_ACTIVE) {
1234 self::dispatchStatusEvent($subscription, 'paused', ['old_status' => $oldStatus]);
1235 }
1236
1237 if ($subscription->status === Status::SUBSCRIPTION_ACTIVE && $oldStatus === Status::SUBSCRIPTION_PAUSED) {
1238 SystemChargeService::restoreScheduledChargesForSubscription($subscription);
1239 self::dispatchStatusEvent($subscription, 'resumed', ['old_status' => $oldStatus]);
1240 }
1241 }
1242
1243 if (isset($updates['bill_times']) && !isset($updates['status'])) {
1244 $preSyncStatus = $subscription->status;
1245 self::syncSubscriptionStates($subscription, []);
1246 if ($subscription->status === Status::SUBSCRIPTION_COMPLETED
1247 && $preSyncStatus !== Status::SUBSCRIPTION_COMPLETED
1248 ) {
1249 self::voidPendingRenewals(
1250 $subscription,
1251 __('Subscription completed — billing times reached.', 'fluent-cart')
1252 );
1253 }
1254 }
1255
1256 return true;
1257 }
1258
1259 /**
1260 * Correct the gateway identifiers on an automatic subscription.
1261 *
1262 * Deliberately separate from updateSubscription(): nothing here touches
1263 * billing state, so no renewal is voided, no invoice re-synced and no
1264 * status event dispatched. Only the two identifier columns move.
1265 *
1266 * @param array $data vendor_subscription_id and/or vendor_customer_id
1267 * @return true|\WP_Error
1268 */
1269 public static function updateVendorIds(Subscription $subscription, array $data)
1270 {
1271 if (!$subscription->canEditVendorIds()) {
1272 return new \WP_Error(
1273 'cannot_edit_vendor_ids',
1274 __('Vendor IDs can only be edited on an active gateway-billed subscription.', 'fluent-cart')
1275 );
1276 }
1277
1278 $updates = [];
1279 $changes = [];
1280
1281 foreach (['vendor_subscription_id', 'vendor_customer_id'] as $field) {
1282 if (!array_key_exists($field, $data)) {
1283 continue;
1284 }
1285
1286 $value = trim((string) $data[$field]);
1287 $oldValue = (string) $subscription->{$field};
1288
1289 if ($oldValue === $value) {
1290 continue;
1291 }
1292
1293 $updates[$field] = $value;
1294 $changes[] = sprintf(
1295 '%1$s: %2$s → %3$s',
1296 $field,
1297 $oldValue !== '' ? $oldValue : '(none)',
1298 $value !== '' ? $value : '(none)'
1299 );
1300 }
1301
1302 if (empty($updates)) {
1303 return new \WP_Error(
1304 'no_changes',
1305 __('No changes detected.', 'fluent-cart')
1306 );
1307 }
1308
1309 // fct_subscriptions indexes vendor_subscription_id but does not enforce
1310 // uniqueness, and every gateway IPN resolves its subscription through
1311 // that column — a duplicate would silently route webhooks into the wrong
1312 // row. A gateway never reissues an id inside its own account, so the
1313 // collision that matters is same-gateway.
1314 //
1315 // Claim it with one statement rather than SELECT-then-save: the anti-join
1316 // makes "nobody else holds this id" part of the UPDATE itself, so two
1317 // concurrent edits racing for the same id cannot both pass the check.
1318 // Zero affected rows means the other one won.
1319 if (!empty($updates['vendor_subscription_id'])) {
1320 if (!self::claimVendorSubscriptionId($subscription, $updates)) {
1321 return new \WP_Error(
1322 'vendor_subscription_id_taken',
1323 __('Another subscription on this payment method is already using this Vendor Subscription ID.', 'fluent-cart')
1324 );
1325 }
1326
1327 $subscription->fill($updates)->syncOriginal();
1328 } else {
1329 $subscription->fill($updates)->save();
1330 }
1331
1332 $subscription->addLog(
1333 'Vendor IDs updated',
1334 sprintf('Admin updated: %s', implode(', ', $changes)),
1335 'info'
1336 );
1337
1338 return true;
1339 }
1340
1341 /**
1342 * Write the vendor identifiers only if no other subscription on the same
1343 * payment method already holds the incoming vendor_subscription_id.
1344 *
1345 * The anti-join makes the check part of the write, so the check-then-write
1346 * window a separate SELECT would leave open does not exist.
1347 *
1348 * @return bool false when another row already holds the id
1349 */
1350 private static function claimVendorSubscriptionId(Subscription $subscription, array $updates): bool
1351 {
1352 $newId = $updates['vendor_subscription_id'];
1353 $method = (string) $subscription->current_payment_method;
1354
1355 $values = ['s.vendor_subscription_id' => $newId];
1356
1357 if (array_key_exists('vendor_customer_id', $updates)) {
1358 $values['s.vendor_customer_id'] = $updates['vendor_customer_id'];
1359 }
1360
1361 $values['s.updated_at'] = DateTime::gmtNow()->format('Y-m-d H:i:s');
1362
1363 $affected = Subscription::query()
1364 ->getConnection()
1365 ->table('fct_subscriptions as s')
1366 ->leftJoin('fct_subscriptions as o', function ($join) use ($newId, $method) {
1367 $join->on('o.id', '<>', 's.id')
1368 ->where('o.vendor_subscription_id', '=', $newId)
1369 ->where('o.current_payment_method', '=', $method);
1370 })
1371 ->where('s.id', $subscription->id)
1372 ->whereNull('o.id')
1373 ->update($values);
1374
1375 return (int) $affected > 0;
1376 }
1377
1378 /**
1379 * Single cancellation chokepoint. Voids open renewals and dispatches the
1380 * SubscriptionCanceled event so email + reminder-clear (both listen on the
1381 * event hook) fire once, regardless of which cancel path ran.
1382 *
1383 * @param bool $dispatchEvent fire SubscriptionCanceled (email/reminder-clear/automations)
1384 */
1385 public static function finalizeCancellation(Subscription $subscription, string $reason = '', bool $dispatchEvent = true): void
1386 {
1387 self::voidPendingRenewals($subscription, $reason ?: __('Subscription canceled', 'fluent-cart'));
1388
1389 if ($dispatchEvent) {
1390 (new SubscriptionCanceled($subscription, $subscription->order, $subscription->customer, $reason))->dispatch();
1391 }
1392 }
1393
1394 /**
1395 * Void all pending renewal invoices for a subscription.
1396 * Sets order status to canceled and payment_status to failed.
1397 */
1398 public static function voidPendingRenewals(Subscription $subscription, string $reason = ''): void
1399 {
1400 $pendingInvoices = Order::query()
1401 ->where('parent_id', $subscription->parent_order_id)
1402 ->where('type', 'renewal')
1403 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
1404 ->get();
1405
1406 foreach ($pendingInvoices as $invoice) {
1407 if ($subscription->isSystem()) {
1408 SystemChargeService::unscheduleCharges($invoice);
1409
1410 $chargeState = $subscription->getMeta('system_charge_state', []) ?: [];
1411 if ((int) Arr::get($chargeState, 'order_id') === (int) $invoice->id) {
1412 $subscription->deleteMeta('system_charge_state');
1413 }
1414 }
1415
1416 // Re-assert payment_status at mutation time — a webhook may have paid this
1417 // invoice between the select above and this update (same pattern as
1418 // reactivateSubscriptionLocally). A raced-paid invoice is left untouched.
1419 $voided = Order::query()
1420 ->where('id', $invoice->id)
1421 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
1422 ->update([
1423 'status' => Status::ORDER_CANCELED,
1424 'payment_status' => Status::PAYMENT_FAILED,
1425 ]);
1426
1427 if (!$voided) {
1428 continue;
1429 }
1430
1431 OrderTransaction::query()
1432 ->where('order_id', $invoice->id)
1433 ->where('status', Status::TRANSACTION_PENDING)
1434 ->update(['status' => Status::TRANSACTION_FAILED]);
1435
1436 $invoice->addLog(
1437 'Renewal order voided',
1438 $reason ?: 'Renewal order voided automatically.',
1439 'info'
1440 );
1441 }
1442 }
1443 }
1444