PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/Subscriptions/Services/SubscriptionService.php +1156 -99 1.3.25 → 1.6.5 View file →
@@ -1,10 +1,17 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Modules\Subscriptions\Services;
4 4
5 +use FluentCart\App\App;
6 +use FluentCart\App\Events\Subscription\SubscriptionCanceled;
5 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;
6 11 use FluentCart\App\Events\Subscription\SubscriptionRenewed;
12 +use FluentCart\App\Events\Subscription\SubscriptionResumed;
13 +use FluentCart\App\Events\Subscription\SubscriptionUpdated;
7 14 use FluentCart\App\Events\Subscription\SubscriptionValidityExpired;
8 15 use FluentCart\App\Helpers\Status;
9 16 use FluentCart\App\Helpers\StatusHelper;
10 17 use FluentCart\App\Models\Order;
@@ -11,8 +18,10 @@
11 18 use FluentCart\App\Models\OrderItem;
12 19 use FluentCart\App\Models\OrderTaxRate;
13 20 use FluentCart\App\Models\OrderTransaction;
14 21 use FluentCart\App\Models\Subscription;
22 +use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService;
23 +use WP_Error;
15 24 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
16 25 use FluentCart\App\Services\DateTime\DateTime;
17 26 use FluentCart\App\Services\Payments\PaymentHelper;
18 27 use FluentCart\Framework\Support\Arr;
@@ -18,8 +27,33 @@
18 27 use FluentCart\Framework\Support\Arr;
19 28
20 29 class SubscriptionService
21 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 + */
22 56 public static function recordRenewalPayment($transactionData, $subscriptionModel = null, $subscriptionUpdateArgs = [])
23 57 {
24 58 if (!$subscriptionModel) {
25 59 $subscriptionModel = Subscription::query()->find($transactionData['subscription_id']);
@@ -30,10 +64,22 @@
30 64 }
31 65
32 66 $vendorTransactionId = $transactionData['vendor_charge_id'] ?? null;
33 67
68 + global $wpdb;
69 + $lockName = null;
70 +
34 71 if ($vendorTransactionId) {
35 - if (OrderTransaction::query()->where('vendor_charge_id', $vendorTransactionId)->exists()) {
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));
36 82 return new \WP_Error('transaction_exists', __('This transaction already exists for this subscription.', 'fluent-cart'));
37 83 }
38 84 }
39 85
@@ -42,8 +88,71 @@
42 88 if (!$parentOrder) {
43 89 return new \WP_Error('parent_order_not_found', __('Parent order not found for this subscription.', 'fluent-cart'));
44 90 }
45 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 +
46 155 $transactionDefaults = [
47 156 'order_id' => $parentOrder->id,
48 157 'subscription_id' => $subscriptionModel->id,
49 158 'order_type' => Status::ORDER_TYPE_RENEWAL,
@@ -57,9 +166,9 @@
57 166 ];
58 167
59 168 $transactionData = wp_parse_args($transactionData, $transactionDefaults);
60 169
61 - $createdAt = Arr::get($transactionData, 'created_at', DateTime::now()->format('Y-m-d H:i:s'));
170 + $createdAt = self::normalizeGatewayTime(Arr::get($transactionData, 'created_at'));
62 171
63 172 // Let's create the order item first
64 173 $variation = $subscriptionModel->variation;
65 174 $product = $subscriptionModel->product;
@@ -72,10 +181,16 @@
72 181 $taxTotal = Arr::get($transactionData, 'tax_total', 0);
73 182 if (!$taxTotal && $subscriptionModel->recurring_tax_total) {
74 183 $taxTotal = $subscriptionModel->recurring_tax_total;
75 184 }
76 -
77 - if (!$taxTotal && $parentOrder->tax_behavior == 2 && $parentOrderItem) {
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) {
78 193 $taxTotal = (int) Arr::get($parentOrderItem->other_info, 'recurring_tax', 0);
79 194 }
80 195
81 196 $subtotal = $transactionData['total'];
@@ -113,8 +228,10 @@
113 228 }
114 229
115 230 $fulfillmentType = $orderItem['fulfillment_type'];
116 231
232 + $wpdb->query('START TRANSACTION');
233 +
117 234 // Let's create the order first
118 235 $childOrderData = [
119 236 'parent_id' => $parentOrder->id,
120 237 'fulfillment_type' => $fulfillmentType,
@@ -130,100 +247,113 @@
130 247 'subtotal' => $subtotal,
131 248 'tax_total' => $taxTotal,
132 249 'total_amount' => $transactionData['total'],
133 250 'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0,
134 - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s'),
251 + 'completed_at' => $createdAt,
135 252 'created_at' => $createdAt,
136 253 'config' => []
137 254 ];
138 255
139 - $childOrder = Order::query()->create($childOrderData);
256 + try {
257 + $childOrder = Order::query()->create($childOrderData);
140 258
141 - if (!$childOrder) {
142 - return new \WP_Error('order_creation_failed', __('Failed to create child order for the subscription renewal.', 'fluent-cart'));
143 - }
259 + if (!$childOrder) {
260 + throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart'));
261 + }
144 262
145 - $billingAddress = $parentOrder->billing_address;
146 - $shippingAddress = $parentOrder->shipping_address;
263 + $billingAddress = $parentOrder->billing_address;
264 + $shippingAddress = $parentOrder->shipping_address;
147 265
148 - $customer = $parentOrder->customer;
266 + $customer = $parentOrder->customer;
149 267
150 - $fullName = '';
151 - $email = '';
152 - $firstName = '';
153 - $lastName = '';
154 - if ($customer) {
155 - $fullName = $customer->first_name . ' ' . $customer->last_name;
156 - $email = $customer->email;
157 - $firstName = $customer->first_name;
158 - $lastName = $customer->last_name;
159 - }
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 + }
160 278
161 - $billingAddressData = $billingAddress ? [
162 - 'type' => 'billing',
163 - 'full_name' => $fullName,
164 - 'address_1' => $billingAddress->address_1,
165 - 'address_2' => $billingAddress->address_2,
166 - 'city' => $billingAddress->city,
167 - 'state' => $billingAddress->state,
168 - 'postcode' => $billingAddress->postcode,
169 - 'country' => $billingAddress->country,
170 - 'email' => $email,
171 - 'first_name' => $firstName,
172 - 'last_name' => $lastName
173 - ] : [];
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 + ] : [];
174 292
175 - $shippingAddressData = $shippingAddress ? [
176 - 'type' => 'shipping',
177 - 'full_name' => $fullName,
178 - 'address_1' => $shippingAddress->address_1,
179 - 'address_2' => $shippingAddress->address_2,
180 - 'city' => $shippingAddress->city,
181 - 'state' => $shippingAddress->state,
182 - 'postcode' => $shippingAddress->postcode,
183 - 'country' => $shippingAddress->country,
184 - 'email' => $email,
185 - 'first_name' => $firstName,
186 - 'last_name' => $lastName
187 - ] : [];
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 + ] : [];
188 306
189 - \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses(
190 - $childOrder->id,
191 - $billingAddressData,
192 - $shippingAddressData
193 - );
307 + \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses(
308 + $childOrder->id,
309 + $billingAddressData,
310 + $shippingAddressData
311 + );
194 312
195 - // Copy tax ID meta from parent order if exists
196 - $parentTaxId = $parentOrder->getMeta('tax_id', '');
197 - if ($parentTaxId) {
198 - $childOrder->updateMeta('tax_id', $parentTaxId);
199 - }
313 + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress);
314 + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress);
200 315
201 - // Copy order tax rates from parent order
202 - $parentTaxRates = $parentOrder->orderTaxRates;
203 - foreach ($parentTaxRates as $taxRate) {
204 - OrderTaxRate::query()->create([
205 - 'order_id' => $childOrder->id,
206 - 'tax_rate_id' => $taxRate->tax_rate_id,
207 - 'shipping_tax' => $taxRate->shipping_tax,
208 - 'order_tax' => $taxRate->order_tax,
209 - 'total_tax' => $taxRate->total_tax,
210 - 'meta' => $taxRate->meta,
211 - ]);
212 - }
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 + }
213 321
214 - // Create Order Item
215 - $orderItem['order_id'] = $childOrder->id;
216 - $orderItem['created_at'] = $createdAt;
217 - OrderItem::query()->create($orderItem);
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 + }
218 334
219 - // let's create the transaction
220 - $transactionData['order_id'] = $childOrder->id;
335 + // Create Order Item
336 + $orderItem['order_id'] = $childOrder->id;
337 + $orderItem['created_at'] = $createdAt;
338 + OrderItem::query()->create($orderItem);
221 339
222 - $createdTransaction = OrderTransaction::query()->create($transactionData);
340 + // let's create the transaction
341 + $transactionData['order_id'] = $childOrder->id;
223 342
224 - $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs);
343 + $createdTransaction = OrderTransaction::query()->create($transactionData);
225 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 +
226 356 (new SubscriptionRenewed($subscriptionModel, $childOrder, $parentOrder, $childOrder->customer))->dispatch();
227 357
228 358 return $createdTransaction;
229 359 }
@@ -234,26 +364,12 @@
234 364 * - next_billing_date - You must provide this if you want to update the next billing date.
235 365 * * - Accepts all other filliable attributes of the Subscription model.
236 366 * @return mixed
237 367 */
238 - public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [])
368 + public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [], $expectedStatus = null)
239 369 {
240 - $billsCount = OrderTransaction::query()
241 - ->where('subscription_id', $subscriptionModel->id)
242 - ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
243 - ->where('status', Status::TRANSACTION_SUCCEEDED)
244 - ->where('total', '>', 0)
245 - ->count();
370 + $billsCount = $subscriptionModel->calculateBillCount();
246 371
247 -
248 - $earlyPaymentHistory = $subscriptionModel->getMeta('early_payment_history', []);
249 - foreach ($earlyPaymentHistory as $earlyPayment) {
250 - $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
251 - if ($paidCount > 1) {
252 - $billsCount += ($paidCount - 1);
253 - }
254 - }
255 -
256 372 $subscriptionUpdateArgs['bill_count'] = $billsCount;
257 373 $billTimes = $subscriptionModel->bill_times;
258 374 $oldStatus = $subscriptionModel->status;
259 375
@@ -278,10 +394,30 @@
278 394 }
279 395
280 396 $subscriptionModel->fill($subscriptionUpdateArgs);
281 397 $dirtyData = $subscriptionModel->getDirty();
282 - $subscriptionModel->save();
283 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 +
284 420 $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', []));
285 421
286 422 foreach ($meta as $key => $value) {
287 423 $subscriptionModel->updateMeta($key, $value);
@@ -286,8 +422,21 @@
286 422 foreach ($meta as $key => $value) {
287 423 $subscriptionModel->updateMeta($key, $value);
288 424 }
289 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 +
290 439 if ($oldStatus === $subscriptionModel->status) {
291 440 if ($dirtyData) {
292 441 do_action('fluent_cart/subscription/data_updated', [
293 442 'subscription' => $subscriptionModel,
@@ -327,8 +476,18 @@
327 476 'old_status' => $oldStatus,
328 477 'new_status' => $subscriptionModel->status
329 478 ]);
330 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 +
331 490 // note: we needed this event, currently being used in integrations
332 491 if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) {
333 492 $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
334 493 (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch();
@@ -333,8 +492,21 @@
333 492 $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
334 493 (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch();
335 494 }
336 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 +
337 509 return $subscriptionModel;
338 510 }
339 511
340 512
@@ -351,15 +523,23 @@
351 523 public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = [])
352 524 {
353 525 $renewalOrder = $transaction->order;
354 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.
355 537 $orderUpdateData = [
356 538 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED,
357 539 'type' => Status::ORDER_TYPE_RENEWAL,
358 540 'payment_method' => $transaction->payment_method,
359 - 'payment_status' => Status::PAYMENT_PAID,
360 - 'total_paid' => $transaction->total,
361 - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s')
541 + 'completed_at' => self::normalizeGatewayTime($settledAt)
362 542 ];
363 543
364 544 $renewalOrder->fill($orderUpdateData);
365 545 $renewalOrder->save();
@@ -376,11 +556,888 @@
376 556 $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData);
377 557
378 558 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
379 559
380 - if ($transaction->total > 0) {
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()) {
381 574 (new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch();
382 575 }
383 576
384 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 + }
385 1442 }
386 1443 }