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 +1143 -83 1.5.4 → 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,9 +181,9 @@
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 -
185 +
77 186 // A subscription item may be inclusive even when the parent order is mixed (behavior=3).
78 187 // Check the per-item line_meta to determine the actual inclusion for this item.
79 188 $isItemInclusive = $parentOrder->tax_behavior === 2
80 189 || ($parentOrder->tax_behavior === 3 && $parentOrderItem !== null
@@ -119,8 +228,10 @@
119 228 }
120 229
121 230 $fulfillmentType = $orderItem['fulfillment_type'];
122 231
232 + $wpdb->query('START TRANSACTION');
233 +
123 234 // Let's create the order first
124 235 $childOrderData = [
125 236 'parent_id' => $parentOrder->id,
126 237 'fulfillment_type' => $fulfillmentType,
@@ -136,100 +247,113 @@
136 247 'subtotal' => $subtotal,
137 248 'tax_total' => $taxTotal,
138 249 'total_amount' => $transactionData['total'],
139 250 'total_paid' => $transactionData['status'] === Status::TRANSACTION_SUCCEEDED ? $transactionData['total'] : 0,
140 - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s'),
251 + 'completed_at' => $createdAt,
141 252 'created_at' => $createdAt,
142 253 'config' => []
143 254 ];
144 255
145 - $childOrder = Order::query()->create($childOrderData);
256 + try {
257 + $childOrder = Order::query()->create($childOrderData);
146 258
147 - if (!$childOrder) {
148 - return new \WP_Error('order_creation_failed', __('Failed to create child order for the subscription renewal.', 'fluent-cart'));
149 - }
259 + if (!$childOrder) {
260 + throw new \RuntimeException(__('Failed to create child order for the subscription renewal.', 'fluent-cart'));
261 + }
150 262
151 - $billingAddress = $parentOrder->billing_address;
152 - $shippingAddress = $parentOrder->shipping_address;
263 + $billingAddress = $parentOrder->billing_address;
264 + $shippingAddress = $parentOrder->shipping_address;
153 265
154 - $customer = $parentOrder->customer;
266 + $customer = $parentOrder->customer;
155 267
156 - $fullName = '';
157 - $email = '';
158 - $firstName = '';
159 - $lastName = '';
160 - if ($customer) {
161 - $fullName = $customer->first_name . ' ' . $customer->last_name;
162 - $email = $customer->email;
163 - $firstName = $customer->first_name;
164 - $lastName = $customer->last_name;
165 - }
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 + }
166 278
167 - $billingAddressData = $billingAddress ? [
168 - 'type' => 'billing',
169 - 'full_name' => $fullName,
170 - 'address_1' => $billingAddress->address_1,
171 - 'address_2' => $billingAddress->address_2,
172 - 'city' => $billingAddress->city,
173 - 'state' => $billingAddress->state,
174 - 'postcode' => $billingAddress->postcode,
175 - 'country' => $billingAddress->country,
176 - 'email' => $email,
177 - 'first_name' => $firstName,
178 - 'last_name' => $lastName
179 - ] : [];
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 + ] : [];
180 292
181 - $shippingAddressData = $shippingAddress ? [
182 - 'type' => 'shipping',
183 - 'full_name' => $fullName,
184 - 'address_1' => $shippingAddress->address_1,
185 - 'address_2' => $shippingAddress->address_2,
186 - 'city' => $shippingAddress->city,
187 - 'state' => $shippingAddress->state,
188 - 'postcode' => $shippingAddress->postcode,
189 - 'country' => $shippingAddress->country,
190 - 'email' => $email,
191 - 'first_name' => $firstName,
192 - 'last_name' => $lastName
193 - ] : [];
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 + ] : [];
194 306
195 - \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses(
196 - $childOrder->id,
197 - $billingAddressData,
198 - $shippingAddressData
199 - );
307 + \FluentCart\App\Helpers\AddressHelper::insertOrderAddresses(
308 + $childOrder->id,
309 + $billingAddressData,
310 + $shippingAddressData
311 + );
200 312
201 - // Copy tax ID meta from parent order if exists
202 - $parentTaxId = $parentOrder->getMeta('tax_id', '');
203 - if ($parentTaxId) {
204 - $childOrder->updateMeta('tax_id', $parentTaxId);
205 - }
313 + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress);
314 + \FluentCart\App\Helpers\AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress);
206 315
207 - // Copy order tax rates from parent order
208 - $parentTaxRates = $parentOrder->orderTaxRates;
209 - foreach ($parentTaxRates as $taxRate) {
210 - OrderTaxRate::query()->create([
211 - 'order_id' => $childOrder->id,
212 - 'tax_rate_id' => $taxRate->tax_rate_id,
213 - 'shipping_tax' => $taxRate->shipping_tax,
214 - 'order_tax' => $taxRate->order_tax,
215 - 'total_tax' => $taxRate->total_tax,
216 - 'meta' => $taxRate->meta,
217 - ]);
218 - }
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 + }
219 321
220 - // Create Order Item
221 - $orderItem['order_id'] = $childOrder->id;
222 - $orderItem['created_at'] = $createdAt;
223 - 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 + }
224 334
225 - // let's create the transaction
226 - $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);
227 339
228 - $createdTransaction = OrderTransaction::query()->create($transactionData);
340 + // let's create the transaction
341 + $transactionData['order_id'] = $childOrder->id;
229 342
230 - $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateArgs);
343 + $createdTransaction = OrderTransaction::query()->create($transactionData);
231 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 +
232 356 (new SubscriptionRenewed($subscriptionModel, $childOrder, $parentOrder, $childOrder->customer))->dispatch();
233 357
234 358 return $createdTransaction;
235 359 }
@@ -240,9 +364,9 @@
240 364 * - next_billing_date - You must provide this if you want to update the next billing date.
241 365 * * - Accepts all other filliable attributes of the Subscription model.
242 366 * @return mixed
243 367 */
244 - public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [])
368 + public static function syncSubscriptionStates(Subscription $subscriptionModel, $subscriptionUpdateArgs = [], $expectedStatus = null)
245 369 {
246 370 $billsCount = $subscriptionModel->calculateBillCount();
247 371
248 372 $subscriptionUpdateArgs['bill_count'] = $billsCount;
@@ -270,10 +394,30 @@
270 394 }
271 395
272 396 $subscriptionModel->fill($subscriptionUpdateArgs);
273 397 $dirtyData = $subscriptionModel->getDirty();
274 - $subscriptionModel->save();
275 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 +
276 420 $meta = array_filter(Arr::get($subscriptionUpdateArgs, 'meta', []));
277 421
278 422 foreach ($meta as $key => $value) {
279 423 $subscriptionModel->updateMeta($key, $value);
@@ -278,8 +422,16 @@
278 422 foreach ($meta as $key => $value) {
279 423 $subscriptionModel->updateMeta($key, $value);
280 424 }
281 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 +
282 434 // validity_expired_at should only exist when status IS expired
283 435 if ($subscriptionModel->status !== Status::SUBSCRIPTION_EXPIRED) {
284 436 $subscriptionModel->deleteMeta('validity_expired_at');
285 437 }
@@ -324,8 +476,18 @@
324 476 'old_status' => $oldStatus,
325 477 'new_status' => $subscriptionModel->status
326 478 ]);
327 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 +
328 490 // note: we needed this event, currently being used in integrations
329 491 if ($subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) {
330 492 $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
331 493 (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch();
@@ -330,8 +492,21 @@
330 492 $subscriptionModel->updateMeta('validity_expired_at', DateTime::now()->format('Y-m-d H:i:s'));
331 493 (new SubscriptionValidityExpired($subscriptionModel,$subscriptionModel->order,$subscriptionModel->customer))->dispatch();
332 494 }
333 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 +
334 509 return $subscriptionModel;
335 510 }
336 511
337 512
@@ -348,15 +523,23 @@
348 523 public static function recordManualRenewal(Subscription $subscriptionModel, OrderTransaction $transaction, $args = [])
349 524 {
350 525 $renewalOrder = $transaction->order;
351 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.
352 537 $orderUpdateData = [
353 538 'status' => $renewalOrder->fulfillment_type === 'physical' ? Status::ORDER_PROCESSING : Status::ORDER_COMPLETED,
354 539 'type' => Status::ORDER_TYPE_RENEWAL,
355 540 'payment_method' => $transaction->payment_method,
356 - 'payment_status' => Status::PAYMENT_PAID,
357 - 'total_paid' => $transaction->total,
358 - 'completed_at' => DateTime::now()->format('Y-m-d H:i:s')
541 + 'completed_at' => self::normalizeGatewayTime($settledAt)
359 542 ];
360 543
361 544 $renewalOrder->fill($orderUpdateData);
362 545 $renewalOrder->save();
@@ -373,11 +556,888 @@
373 556 $subscriptionModel = self::syncSubscriptionStates($subscriptionModel, $updateData);
374 557
375 558 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
376 559
377 - 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()) {
378 574 (new SubscriptionRenewed($subscriptionModel, $renewalOrder, $subscriptionModel->order, $renewalOrder->customer))->dispatch();
379 575 }
380 576
381 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 + }
382 1442 }
383 1443 }