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.6 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 All 49 releases
fluent-cart / app / Modules / StoreManagedRenewal / Services / RenewalService.php

RenewalService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/StoreManagedRenewal/Services/RenewalService.php

955 lines 38.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\StoreManagedRenewal\Services;
4
5 use FluentCart\App\Helpers\AddressHelper;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\OrderItem;
9 use FluentCart\App\Models\OrderTaxRate;
10 use FluentCart\App\Models\OrderTransaction;
11 use FluentCart\App\Models\Subscription;
12 use FluentCart\App\Services\Payments\SubscriptionHelper;
13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 use FluentCart\App\Modules\Subscriptions\Services\SystemChargeService;
15 use FluentCart\Api\StoreSettings;
16 use FluentCart\Framework\Support\Arr;
17 use WP_Error;
18
19 class RenewalService
20 {
21 /**
22 * Create renewal invoice for a subscription with manual payment method.
23 * Invoices are created in advance (before the billing date) so customers
24 * have time to pay before their period ends.
25 *
26 * @param Subscription $subscription
27 * @return array|WP_Error Array with created order or empty array
28 */
29 public static function createRenewalOrders(Subscription $subscription)
30 {
31 $parentOrder = $subscription->order;
32
33 if (!$parentOrder) {
34 return new WP_Error('parent_order_not_found', __('Parent order not found for this subscription.', 'fluent-cart'));
35 }
36
37 if (!SubscriptionHelper::canProcessInMode($parentOrder->mode)) {
38 return new WP_Error('store_mode_mismatch', sprintf(
39 /* translators: 1: the subscription's payment mode (live/test), 2: the store's current mode (live/test) */
40 __('Renewal skipped — this subscription is in %1$s mode but the store is currently in %2$s mode.', 'fluent-cart'),
41 $parentOrder->mode,
42 (new StoreSettings())->get('order_mode')
43 ));
44 }
45
46 // Get original order item — use eager-loaded collection if available, otherwise query
47 if ($parentOrder->relationLoaded('order_items')) {
48 $parentOrderItem = $parentOrder->order_items->filter(function ($item) {
49 return $item->payment_type === Status::ORDER_TYPE_SUBSCRIPTION;
50 })->first();
51 } else {
52 $parentOrderItem = OrderItem::query()
53 ->where('order_id', $parentOrder->id)
54 ->where('payment_type', Status::ORDER_TYPE_SUBSCRIPTION)
55 ->first();
56 }
57
58 if (!$parentOrderItem) {
59 return new WP_Error('order_item_not_found', __('Original order item not found for this subscription.', 'fluent-cart'));
60 }
61
62 // Acquire a MySQL advisory lock to prevent concurrent duplicate invoice creation
63 global $wpdb;
64 $lockName = 'fc_renewal_' . $subscription->id;
65 $acquired = (bool) $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, 5)", $lockName));
66 if (!$acquired) {
67 return new WP_Error('lock_failed', __('Renewal creation already in progress for this subscription.', 'fluent-cart'));
68 }
69
70 // One subscription per parent order — an open renewal under this parent
71 // is this subscription's renewal.
72 $hasUnresolvedInvoice = Order::query()
73 ->where('parent_id', $parentOrder->id)
74 ->where('type', Status::ORDER_TYPE_RENEWAL)
75 ->whereIn('payment_status', [
76 Status::PAYMENT_PENDING,
77 Status::PAYMENT_SCHEDULED,
78 Status::PAYMENT_AUTHORIZED,
79 Status::PAYMENT_PARTIALLY_PAID,
80 ])
81 ->exists();
82
83 if ($hasUnresolvedInvoice) {
84 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
85 return [];
86 }
87
88 // The due date is the original next_billing_date — the date the customer must pay by
89 $dueDate = $subscription->next_billing_date;
90
91 // Get product and variation information
92 $product = $subscription->product;
93 $variation = $subscription->variation;
94
95 $taxTotal = $subscription->recurring_tax_total;
96 if (!$taxTotal && $parentOrder->tax_behavior == 2 && $parentOrderItem) {
97 $taxTotal = (int) Arr::get($parentOrderItem->other_info, 'recurring_tax', 0);
98 }
99
100 $total = $subscription->recurring_total;
101 $subtotal = $taxTotal ? ($total - $taxTotal) : $subscription->recurring_amount;
102
103 // unit_price is the catalog (gross) per-unit price for inclusive tax (behavior 2) and the
104 // net per-unit price for exclusive tax — the same convention initial orders use
105 // (OrderService stores recurring_amount = unit_price * qty). The re-pay checkout feeds this
106 // unit_price back as item_price; using the net $subtotal for an inclusive-tax subscription
107 // would make the checkout total drop the included tax (e.g. $100 renders as $90.91).
108 $unitPriceBase = ($parentOrder->tax_behavior == 2) ? $total : $subtotal;
109
110 $fulfillmentType = $parentOrderItem->fulfillment_type;
111
112 // System (auto-charged) subscriptions: the invoice waits in payment_scheduled
113 // for its due-date off-session charge — no pay-now email. Everything else is
114 // identical to a manual invoice, so a failed charge degrades cleanly to
115 // pending + the normal dunning flow.
116 $isSystemInvoice = $subscription->isSystem();
117
118 // Create invoice (child order) — created_at is now, due date stored as meta
119 $childOrderData = [
120 'parent_id' => $parentOrder->id,
121 'fulfillment_type' => $fulfillmentType,
122 'status' => 'pending',
123 'type' => Status::ORDER_TYPE_RENEWAL,
124 'mode' => $parentOrder->mode,
125 'shipping_status' => $fulfillmentType === 'physical' ? Status::SHIPPING_UNSHIPPED : '',
126 'customer_id' => $subscription->customer_id,
127 'payment_method' => $subscription->current_payment_method,
128 'payment_status' => $isSystemInvoice ? Status::PAYMENT_SCHEDULED : Status::PAYMENT_PENDING,
129 'currency' => $parentOrder->currency,
130 'tax_behavior' => $parentOrder->tax_behavior,
131 'subtotal' => $subtotal,
132 'tax_total' => $taxTotal,
133 'total_amount' => $total,
134 'total_paid' => 0,
135 'config' => [
136 'is_invoice' => true,
137 'invoice_source' => 'automatic_renewal'
138 ]
139 ];
140
141 $wpdb->query('START TRANSACTION');
142
143 try {
144 $childOrder = Order::query()->create($childOrderData);
145 if (!$childOrder) {
146 throw new \RuntimeException(__('Failed to create child order for subscription renewal.', 'fluent-cart'));
147 }
148
149 // Store due_date as meta — used for overdue calculations and reminder timing
150 $childOrder->updateMeta('due_date', $dueDate);
151
152 self::copyParentOrderSnapshot($parentOrder, $childOrder);
153
154 // Copy tax-rate rows from parent so invoice rendering has the breakdown
155 foreach ($parentOrder->orderTaxRates as $taxRate) {
156 OrderTaxRate::query()->create([
157 'order_id' => $childOrder->id,
158 'tax_rate_id' => $taxRate->tax_rate_id,
159 'shipping_tax' => $taxRate->shipping_tax,
160 'order_tax' => $taxRate->order_tax,
161 'total_tax' => $taxRate->total_tax,
162 'meta' => $taxRate->meta,
163 ]);
164 }
165
166 // Create order item
167 $orderItemData = [
168 'order_id' => $childOrder->id,
169 'post_id' => $subscription->product_id,
170 'object_id' => $subscription->variation_id,
171 'payment_type' => Status::ORDER_TYPE_SUBSCRIPTION,
172 'post_title' => $product && $product->post_title ? $product->post_title : $subscription->item_name,
173 'title' => $product && $variation ? $variation->variation_title : '',
174 'quantity' => $subscription->quantity,
175 'fulfillment_type' => $fulfillmentType,
176 'unit_price' => $subscription->quantity > 1 ? (int)round($unitPriceBase / $subscription->quantity) : $unitPriceBase,
177 'subtotal' => $subtotal,
178 'tax_amount' => $taxTotal,
179 'line_total' => $total,
180 'line_meta' => [],
181 'other_info' => []
182 ];
183 $orderItem = OrderItem::query()->create($orderItemData);
184 if (!$orderItem) {
185 throw new \RuntimeException(__('Failed to create order item for renewal order.', 'fluent-cart'));
186 }
187
188 // Create a pending transaction record
189 $transaction = OrderTransaction::query()->create([
190 'order_id' => $childOrder->id,
191 'subscription_id' => $subscription->id,
192 'order_type' => Status::ORDER_TYPE_RENEWAL,
193 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
194 'payment_method' => $subscription->current_payment_method,
195 'payment_mode' => $parentOrder->mode,
196 'status' => Status::TRANSACTION_PENDING,
197 'currency' => $parentOrder->currency,
198 'total' => $total,
199 'meta' => [
200 'is_invoice' => true,
201 'invoice_source' => 'automatic_renewal'
202 ]
203 ]);
204 if (!$transaction) {
205 throw new \RuntimeException(__('Failed to create transaction record for renewal order.', 'fluent-cart'));
206 }
207
208 $wpdb->query('COMMIT');
209 } catch (\Throwable $e) {
210 $wpdb->query('ROLLBACK');
211 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
212 return new WP_Error('invoice_creation_failed', $e->getMessage());
213 }
214
215 $childOrder->addLog(
216 'Renewal order created automatically',
217 sprintf(
218 'Subscription renewal order created. Due date: %s',
219 $dueDate
220 ),
221 'info'
222 );
223
224 $subscription->addLog(
225 'Upcoming renewal order created',
226 sprintf(
227 'Renewal order #%s created. Due date: %s',
228 $childOrder->invoice_no ?: $childOrder->id,
229 $dueDate
230 ),
231 'info'
232 );
233
234 if ($isSystemInvoice) {
235 // No pay-now email — the charge is coming automatically on the due date.
236 do_action('fluent_cart/subscriptions/system_renewal_scheduled', [
237 'subscription' => $subscription,
238 'order' => $childOrder,
239 'parent_order' => $parentOrder,
240 'customer' => $childOrder->customer,
241 'transaction' => $transaction
242 ]);
243
244 SystemChargeService::scheduleCharge($childOrder, $subscription);
245 } else {
246 do_action('fluent_cart/renewal_created', [
247 'subscription' => $subscription,
248 'order' => $childOrder,
249 'parent_order' => $parentOrder,
250 'customer' => $childOrder->customer,
251 'transaction' => $transaction
252 ]);
253 }
254
255 $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName));
256
257 return [$childOrder];
258 }
259
260 private static function copyParentOrderSnapshot(Order $parentOrder, Order $childOrder): void
261 {
262 $billingAddress = $parentOrder->billing_address;
263 $shippingAddress = $parentOrder->shipping_address;
264 $customer = $parentOrder->customer;
265
266 $fullName = '';
267 $email = '';
268 $firstName = '';
269 $lastName = '';
270
271 if ($customer) {
272 $fullName = trim($customer->first_name . ' ' . $customer->last_name);
273 $email = $customer->email;
274 $firstName = $customer->first_name;
275 $lastName = $customer->last_name;
276 }
277
278 $billingAddressData = $billingAddress ? [
279 'type' => 'billing',
280 'full_name' => $fullName,
281 'address_1' => $billingAddress->address_1,
282 'address_2' => $billingAddress->address_2,
283 'city' => $billingAddress->city,
284 'state' => $billingAddress->state,
285 'postcode' => $billingAddress->postcode,
286 'country' => $billingAddress->country,
287 'email' => $email,
288 'first_name' => $firstName,
289 'last_name' => $lastName
290 ] : [];
291
292 $shippingAddressData = $shippingAddress ? [
293 'type' => 'shipping',
294 'full_name' => $fullName,
295 'address_1' => $shippingAddress->address_1,
296 'address_2' => $shippingAddress->address_2,
297 'city' => $shippingAddress->city,
298 'state' => $shippingAddress->state,
299 'postcode' => $shippingAddress->postcode,
300 'country' => $shippingAddress->country,
301 'email' => $email,
302 'first_name' => $firstName,
303 'last_name' => $lastName
304 ] : [];
305
306 AddressHelper::insertOrderAddresses(
307 $childOrder->id,
308 $billingAddressData,
309 $shippingAddressData
310 );
311
312 AddressHelper::copyOrderAddressMeta($childOrder->id, 'billing', $billingAddress);
313 AddressHelper::copyOrderAddressMeta($childOrder->id, 'shipping', $shippingAddress);
314
315 foreach (['tax_id', 'vat_tax_id', 'business_info', 'store_business_info'] as $metaKey) {
316 $metaValue = $parentOrder->getMeta($metaKey, null);
317
318 if ($metaValue !== null && $metaValue !== '' && $metaValue !== []) {
319 $childOrder->updateMeta($metaKey, $metaValue);
320 }
321 }
322 }
323
324 /**
325 * Calculate how many invoices are needed for a subscription
326 *
327 * This method determines how many billing cycles have been missed
328 * and returns the number of invoices that need to be created
329 *
330 * @param Subscription $subscription
331 * @return int Number of invoices needed
332 */
333 /**
334 * Process multiple subscriptions and create renewal orders
335 *
336 * @param int $limit Maximum number of subscriptions to process
337 * @return array Results with processed and failed subscriptions
338 */
339 /**
340 * Cheap guard for the store-managed crons: does this site have any manual/system
341 * subscription at all? Skips the heavy renewal/overdue scans on automatic-only stores.
342 */
343 private static function hasStoreManagedSubscriptions(): bool
344 {
345 return Subscription::query()
346 ->whereIn('collection_method', ['manual', 'system'])
347 ->exists();
348 }
349
350 public static function processDueSubscriptions($limit = 50)
351 {
352 $results = [
353 'processed' => 0,
354 'failed' => 0,
355 'errors' => []
356 ];
357
358 // No store-managed subscriptions on this site — skip the expensive scan entirely.
359 if (!self::hasStoreManagedSubscriptions()) {
360 return $results;
361 }
362
363 $query = Subscription::query()
364 ->with(['order.order_items', 'product', 'variation']);
365
366 if (SubscriptionHelper::isModeGuardEnabled()) {
367 $storeMode = (new StoreSettings())->get('order_mode');
368 $query->whereHas('order', function ($query) use ($storeMode) {
369 $query->where('mode', $storeMode);
370 });
371 }
372
373 $subscriptions = $query
374 ->whereIn('collection_method', ['manual', 'system'])
375 ->whereNotIn('status', [
376 Status::SUBSCRIPTION_COMPLETED,
377 Status::SUBSCRIPTION_CANCELED,
378 Status::SUBSCRIPTION_EXPIRED,
379 Status::SUBSCRIPTION_PAUSED,
380 ])
381 ->whereNotNull('next_billing_date')
382 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
383 ->where(function ($query) {
384 self::applyRenewalCreationReadiness($query);
385 })
386 ->whereDoesntHave('order.children', function ($query) {
387 $query->where('type', Status::ORDER_TYPE_RENEWAL)
388 ->whereIn('payment_status', [
389 Status::PAYMENT_PENDING,
390 Status::PAYMENT_SCHEDULED,
391 Status::PAYMENT_AUTHORIZED,
392 Status::PAYMENT_PARTIALLY_PAID,
393 ]);
394 })
395 ->orderBy('next_billing_date', 'ASC')
396 ->orderBy('id', 'ASC')
397 ->limit($limit)
398 ->get();
399
400 foreach ($subscriptions as $subscription) {
401 try {
402 $invoices = self::createRenewalOrders($subscription);
403
404 if (is_wp_error($invoices)) {
405 $results['failed']++;
406 $results['errors'][] = [
407 'subscription_id' => $subscription->id,
408 'error' => $invoices->get_error_message()
409 ];
410
411 fluent_cart_error_log(
412 'Renewal order creation failed for subscription: ' . $subscription->id,
413 $invoices->get_error_message()
414 );
415 } else {
416 $results['processed'] += count($invoices);
417
418 fluent_cart_error_log(
419 'Renewal order creation success',
420 sprintf(
421 'Created %d renewal order(s) for subscription: %d',
422 count($invoices),
423 $subscription->id
424 )
425 );
426 }
427 } catch (\Exception $e) {
428 $results['failed']++;
429 $results['errors'][] = [
430 'subscription_id' => $subscription->id,
431 'error' => $e->getMessage()
432 ];
433
434 fluent_cart_error_log(
435 'Exception during invoice creation for subscription: ' . $subscription->id,
436 $e->getMessage()
437 );
438 }
439 }
440
441 return $results;
442 }
443
444 private static function applyRenewalCreationReadiness($query): void
445 {
446 $now = gmdate('Y-m-d H:i:s');
447
448 // `trialing` is live state cleared by the first paid renewal — historical
449 // trial_days must not route a post-trial subscription down the trial branch.
450 $query->where(function ($trialQuery) use ($now) {
451 $trialQuery->where('status', Status::SUBSCRIPTION_TRIALING)
452 ->where('next_billing_date', '<=', $now);
453 })->orWhere(function ($standardQuery) {
454 $standardQuery->where('status', '!=', Status::SUBSCRIPTION_TRIALING)
455 ->where(function ($windowQuery) {
456 self::applyAdvanceCreationWindow($windowQuery);
457 });
458 });
459 }
460
461 private static function applyAdvanceCreationWindow($query): void
462 {
463 $advanceDaysMap = self::getAdvanceCreationDaysMap();
464 $knownIntervals = array_keys($advanceDaysMap);
465 $index = 0;
466
467 foreach ($advanceDaysMap as $interval => $days) {
468 $method = $index === 0 ? 'where' : 'orWhere';
469 $threshold = gmdate('Y-m-d H:i:s', time() + ((int) $days * DAY_IN_SECONDS));
470
471 $query->{$method}(function ($intervalQuery) use ($interval, $threshold) {
472 $intervalQuery->where('billing_interval', $interval)
473 ->where('next_billing_date', '<=', $threshold);
474 });
475
476 $index++;
477 }
478
479 $defaultThreshold = gmdate('Y-m-d H:i:s', time() + (7 * DAY_IN_SECONDS));
480 $query->orWhere(function ($intervalQuery) use ($knownIntervals, $defaultThreshold) {
481 $intervalQuery->where(function ($unknownIntervalQuery) use ($knownIntervals) {
482 $unknownIntervalQuery->whereNotIn('billing_interval', $knownIntervals)
483 ->orWhereNull('billing_interval');
484 })->where('next_billing_date', '<=', $defaultThreshold);
485 });
486 }
487
488 /**
489 * Handle invoice payment - sync subscription state after manual invoice is paid.
490 *
491 * - Paid on or before due_date: next_billing_date = due_date + interval (cadence preserved)
492 * - Paid after due_date (late): next_billing_date = paid_at + interval
493 *
494 * Uses syncSubscriptionStates() to derive bill_count from actual succeeded transactions
495 * and handle EOT/status transitions consistently with automatic subscriptions.
496 *
497 * @param array $data Event data containing order, transaction, customer
498 */
499 public static function handleRenewalPaid(array $data): void
500 {
501 $order = $data['order'] ?? null;
502
503 if (!$order || $order->type !== Status::ORDER_TYPE_RENEWAL || !$order->parent_id) {
504 return;
505 }
506
507 $subscription = Subscription::query()
508 ->where('parent_order_id', $order->parent_id)
509 ->whereIn('collection_method', ['manual', 'system'])
510 ->whereIn('status', [
511 Status::SUBSCRIPTION_ACTIVE,
512 Status::SUBSCRIPTION_TRIALING,
513 Status::SUBSCRIPTION_PAST_DUE,
514 Status::SUBSCRIPTION_EXPIRED,
515 Status::SUBSCRIPTION_CANCELED,
516 Status::SUBSCRIPTION_FAILING,
517 Status::SUBSCRIPTION_EXPIRING,
518 Status::SUBSCRIPTION_PAUSED,
519 ])
520 ->first();
521
522 if (!$subscription) {
523 return;
524 }
525
526 // Idempotency: skip if subscription renewal was already processed for this invoice
527 if ($order->getMeta('renewal_processed')) {
528 return;
529 }
530
531 $dueDate = $order->getMeta('due_date');
532 $paidAt = time();
533 $schedule = SubscriptionHelper::getBillingSchedule($subscription);
534
535 if ($dueDate && $paidAt <= strtotime($dueDate)) {
536 $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($dueDate, $subscription->billing_interval, $schedule));
537 } else {
538 $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAt, $subscription->billing_interval, $schedule));
539 }
540
541 // syncSubscriptionStates derives bill_count from DB transactions, handles EOT
542 // (fires SubscriptionEOT internally when bill_count >= bill_times), and saves
543 $subscription = SubscriptionService::syncSubscriptionStates($subscription, [
544 'status' => Status::SUBSCRIPTION_ACTIVE,
545 'next_billing_date' => $nextBillingDate,
546 'current_payment_method' => $order->payment_method,
547 'canceled_at' => null,
548 ]);
549
550 $subscription->deleteMeta('pending_skip_until');
551
552 // Mark this invoice as processed — prevents duplicate handling if called again
553 $order->updateMeta('renewal_processed', 1);
554
555 // If EOT was reached, syncSubscriptionStates already dispatched SubscriptionEOT
556 if ($subscription->status === Status::SUBSCRIPTION_COMPLETED) {
557 return;
558 }
559
560 $subscription->addLog(
561 'Subscription renewed',
562 sprintf('Renewal order #%s paid — subscription renewed', $order->invoice_no ?: $order->id),
563 'success'
564 );
565
566 (new \FluentCart\App\Events\Subscription\SubscriptionRenewed(
567 $subscription,
568 $order,
569 $subscription->order,
570 $order->customer
571 ))->dispatch();
572 }
573
574 /**
575 * Process overdue invoices and transition subscription statuses
576 *
577 * Thresholds are anchored to the invoice due_date, not a multiple of the billing
578 * interval (a fixed 1×/2× interval would give a yearly plan a 365/730-day limbo):
579 * - Due date passed: active/trialing → past_due
580 * - Past the per-interval grace period: past_due → expired
581 *
582 * The grace period reuses SubscriptionHelper::getSubscriptionsGracePeriodDays()
583 * (filter fluent_cart/subscription/grace_period_days) — the same map the automatic
584 * expiry cron uses, so manual and automatic subscriptions expire on one contract.
585 *
586 * @param int $limit Maximum number of invoices to process
587 * @return array Results with past_due and expired counts
588 */
589 public static function processOverdueRenewals(int $limit = 50): array
590 {
591 $results = [
592 'past_due' => 0,
593 'expired' => 0,
594 'errors' => []
595 ];
596
597 // No store-managed subscriptions on this site — skip the expensive scan entirely.
598 if (!self::hasStoreManagedSubscriptions()) {
599 return $results;
600 }
601
602 $invoiceQuery = Order::query()
603 ->where('type', Status::ORDER_TYPE_RENEWAL);
604
605 if (SubscriptionHelper::isModeGuardEnabled()) {
606 $invoiceQuery->where('mode', (new StoreSettings())->get('order_mode'));
607 }
608
609 $pendingInvoices = $invoiceQuery
610 ->whereIn('payment_status', [
611 Status::PAYMENT_PENDING,
612 Status::PAYMENT_SCHEDULED,
613 Status::PAYMENT_AUTHORIZED,
614 Status::PAYMENT_PARTIALLY_PAID,
615 ])
616 ->whereHas('parentOrder.subscriptions', function ($query) {
617 $query->whereIn('collection_method', ['manual', 'system'])
618 ->whereNotIn('status', [
619 Status::SUBSCRIPTION_COMPLETED,
620 Status::SUBSCRIPTION_CANCELED,
621 Status::SUBSCRIPTION_EXPIRED,
622 ]);
623 })
624 ->orderBy('id', 'ASC')
625 ->limit($limit)
626 ->get();
627
628 $now = time();
629
630 foreach ($pendingInvoices as $order) {
631 try {
632 if (!$order->parent_id) {
633 continue;
634 }
635
636 $subscription = Subscription::query()
637 ->where('parent_order_id', $order->parent_id)
638 ->whereIn('collection_method', ['manual', 'system'])
639 ->whereNotIn('status', [
640 Status::SUBSCRIPTION_COMPLETED,
641 Status::SUBSCRIPTION_CANCELED,
642 Status::SUBSCRIPTION_EXPIRED,
643 ])
644 ->first();
645
646 if (!$subscription) {
647 continue;
648 }
649
650 $chargeState = $subscription->getMeta('system_charge_state', []) ?: [];
651 $chargeQueued = $subscription->isSystem() && SystemChargeService::hasQueuedCharge($order, $chargeState);
652
653 if ($order->payment_status === Status::PAYMENT_SCHEDULED && $subscription->isSystem()) {
654 $isSettling = Arr::get($chargeState, 'status') === 'processing'
655 && (int) Arr::get($chargeState, 'order_id') === (int) $order->id;
656
657 if ($isSettling || $chargeQueued) {
658 continue;
659 }
660 }
661
662 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval($subscription->billing_interval);
663 $dueDate = $order->getMeta('due_date') ?: $order->created_at;
664 $invoiceAge = ($now - strtotime($dueDate)) / 86400;
665
666 // A failed charge flips its invoice to `pending`, so a retrying system
667 // subscription goes past_due like any other unpaid one — but it must not
668 // EXPIRE while an attempt is still queued, or the retry fires against a
669 // dead subscription. Retries are spaced inside the grace period, so this
670 // only bites when a filter pushes an offset past it.
671 if ($chargeQueued && $invoiceAge >= $graceDays) {
672 continue;
673 }
674
675 // Past the per-interval grace period: past_due → expired
676 if ($invoiceAge >= $graceDays && $subscription->status === Status::SUBSCRIPTION_PAST_DUE ) {
677 // CAS the past_due → expired write inside syncSubscriptionStates; if a
678 // concurrent renewal payment reactivated the row, it returns null and we skip.
679 $expired = SubscriptionService::syncSubscriptionStates($subscription, [
680 'status' => Status::SUBSCRIPTION_EXPIRED,
681 'next_billing_date' => null,
682 ], Status::SUBSCRIPTION_PAST_DUE);
683
684 if (!$expired) {
685 continue;
686 }
687
688 $subscription->addLog(
689 'Subscription expired',
690 sprintf(
691 'Unpaid invoice #%s exceeded the %d-day grace period. Subscription expired.',
692 $order->invoice_no ?: $order->id,
693 $graceDays
694 ),
695 'error'
696 );
697
698 $results['expired']++;
699 continue;
700 }
701
702 // Due date passed: active/trialing → past_due
703 if ($invoiceAge >= 0 && in_array($subscription->status, [
704 Status::SUBSCRIPTION_ACTIVE,
705 Status::SUBSCRIPTION_TRIALING,
706 ])) {
707 $updated = Subscription::query()
708 ->where('id', $subscription->id)
709 ->whereIn('status', [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
710 ->update(['status' => Status::SUBSCRIPTION_PAST_DUE]);
711
712 if (!$updated) {
713 continue;
714 }
715
716 $subscription->status = Status::SUBSCRIPTION_PAST_DUE;
717
718 $subscription->addLog(
719 'Subscription marked as past due',
720 sprintf(
721 'Unpaid invoice #%s is past its due date',
722 $order->invoice_no ?: $order->id
723 ),
724 'warning'
725 );
726
727 do_action('fluent_cart/subscription_past_due', [
728 'subscription' => $subscription,
729 'order' => $order,
730 'customer' => $order->customer,
731 ]);
732
733 $results['past_due']++;
734 }
735 } catch (\Exception $e) {
736 $results['errors'][] = [
737 'order_id' => $order->id,
738 'error' => $e->getMessage()
739 ];
740
741 fluent_cart_error_log(
742 'Overdue invoice processing failed for order: ' . $order->id,
743 $e->getMessage()
744 );
745 }
746 }
747
748 return $results;
749 }
750
751 /**
752 * Advance next_billing_date one (or more, if overdue) whole intervals ahead,
753 * keeping day-of-cycle alignment. Pure — no persistence, no side effects.
754 *
755 * @return string|null Advanced GMT datetime, or null when there is no billing date
756 * or the interval cannot be resolved (unknown billing_interval).
757 */
758 public static function computeSkippedDate(Subscription $subscription): ?string
759 {
760 $oldDate = $subscription->next_billing_date;
761
762 if (!$oldDate) {
763 return null;
764 }
765
766 $schedule = SubscriptionHelper::getBillingSchedule($subscription);
767 $newTs = SubscriptionHelper::addBillingInterval($oldDate, $subscription->billing_interval, $schedule);
768
769 // Overdue dates: advance whole intervals until in the future. Guard the loop
770 // so a zero-progress interval (broken day-count filter) can never spin.
771 $now = time();
772 while ($newTs <= $now) {
773 $advanced = SubscriptionHelper::addBillingInterval($newTs, $subscription->billing_interval, $schedule);
774 if ($advanced <= $newTs) {
775 break;
776 }
777 $newTs = $advanced;
778 }
779
780 $newDate = gmdate('Y-m-d H:i:s', $newTs);
781
782 return $newDate === $oldDate ? null : $newDate;
783 }
784
785 /**
786 * Admin "Skip Next Period" for a store-billed subscription. Advances
787 * next_billing_date by one interval without creating an invoice.
788 *
789 * Atomic: the date advance is a compare-and-swap on the original
790 * next_billing_date, so two racing skips cannot both advance the same period,
791 * and the void, marker, and audit commit as one unit. The no-stacked-skip
792 * invariant is enforced here so the button and the API share one guard.
793 *
794 * @param string $note Optional admin reason for the skip, recorded in the audit trail and log.
795 * @return array{skipped: bool, old_next_billing_date?: string, new_next_billing_date?: string, reason?: string}
796 */
797 public static function skipNextPeriod(Subscription $subscription, string $note = ''): array
798 {
799 global $wpdb;
800
801 $oldDate = $subscription->next_billing_date;
802
803 if (!$oldDate) {
804 return ['skipped' => false, 'reason' => 'no_billing_date'];
805 }
806
807 // No stacking: a period already skipped-ahead cannot be skipped again until it elapses.
808 if ($subscription->hasPendingSkip()) {
809 return ['skipped' => false, 'reason' => 'already_pending'];
810 }
811
812 $newDate = self::computeSkippedDate($subscription);
813
814 if (!$newDate) {
815 // Interval could not be resolved (e.g. unknown billing_interval) — nothing to advance.
816 return ['skipped' => false, 'reason' => 'no_advance'];
817 }
818
819 $wpdb->query('START TRANSACTION');
820
821 try {
822 // Compare-and-swap: advance only while next_billing_date is still what we read.
823 // A concurrent skip that already moved it makes this a no-op (0 rows affected).
824 $affected = Subscription::query()
825 ->where('id', $subscription->id)
826 ->where('next_billing_date', $oldDate)
827 ->update(['next_billing_date' => $newDate]);
828
829 if (!$affected) {
830 $wpdb->query('ROLLBACK');
831 return ['skipped' => false, 'reason' => 'raced'];
832 }
833
834 // Keep the in-memory model in sync for callers that save() afterwards.
835 $subscription->fill(['next_billing_date' => $newDate]);
836
837 SubscriptionService::voidPendingRenewals($subscription, 'Billing period skipped by admin');
838
839 // Marker for hasPendingSkip(): while next_billing_date still equals this
840 // skipped-to date, the skip is pending and cannot be stacked again.
841 $subscription->updateMeta('pending_skip_until', $newDate);
842
843 // Append to skipped_periods meta — permanent audit trail of every skipped period
844 $actor = wp_get_current_user();
845 $skippedPeriods = $subscription->getMeta('skipped_periods', []) ?: [];
846 $skippedPeriods[] = [
847 'date' => $oldDate,
848 'skipped_at' => gmdate('Y-m-d H:i:s'),
849 'resumes_at' => $newDate,
850 'actor_id' => $actor->exists() ? $actor->ID : 0,
851 'actor_name' => $actor->exists() ? ($actor->display_name ?: 'FCT-BOT') : 'FCT-BOT',
852 'reason' => $note,
853 ];
854 $subscription->updateMeta('skipped_periods', $skippedPeriods);
855
856 $wpdb->query('COMMIT');
857 } catch (\Throwable $e) {
858 $wpdb->query('ROLLBACK');
859 throw $e;
860 }
861
862 $logBody = sprintf(
863 'Billing period skipped. next_billing_date advanced from %s to %s.',
864 $oldDate,
865 $newDate
866 );
867
868 if ($note !== '') {
869 $logBody .= sprintf(' Reason: %s', $note);
870 }
871
872 $subscription->addLog('Billing period skipped by admin', $logBody, 'info');
873
874 // New tag fluent_cart/subscription_period_skipped — no prior contract.
875 SubscriptionService::dispatchStatusEvent($subscription, 'period_skipped', [
876 'old_next_billing_date' => $oldDate,
877 'new_next_billing_date' => $newDate,
878 ]);
879
880 // Same contract as syncSubscriptionStates()'s no-status-change branch —
881 // Pro's license-extension listener only reacts to this hook.
882 do_action('fluent_cart/subscription/data_updated', [
883 'subscription' => $subscription,
884 'updated_data' => ['next_billing_date' => $newDate]
885 ]);
886
887 return [
888 'skipped' => true,
889 'old_next_billing_date' => $oldDate,
890 'new_next_billing_date' => $newDate,
891 ];
892 }
893
894 /**
895 * Advance a manual subscription's next_billing_date past a voided invoice's period.
896 *
897 * Voiding alone only cancels the invoice row; the subscription's next_billing_date
898 * is unchanged, so processDueSubscriptions regenerates the same invoice within the
899 * hour. Moving the billing date one interval past the voided period makes the void
900 * genuinely skip that period (same end state as skipNextPeriod, for a single
901 * admin-voided invoice).
902 *
903 * @param Order $voidedInvoice The renewal order that was just voided.
904 * @return void
905 */
906 public static function advanceAfterVoid(Order $voidedInvoice): void
907 {
908 if (!$voidedInvoice->parent_id) {
909 return;
910 }
911
912 $subscription = Subscription::query()
913 ->where('parent_order_id', $voidedInvoice->parent_id)
914 ->whereIn('collection_method', ['manual', 'system'])
915 ->first();
916
917 if (!$subscription || !$subscription->next_billing_date) {
918 return;
919 }
920
921 $dueDate = $voidedInvoice->getMeta('due_date') ?: $voidedInvoice->created_at;
922
923 // Only advance when the void targets the current/next billing period. A stale
924 // invoice from an already-passed period must not push the date forward again.
925 if (strtotime($subscription->next_billing_date) > strtotime($dueDate)) {
926 return;
927 }
928
929 $newDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($dueDate, $subscription->billing_interval, SubscriptionHelper::getBillingSchedule($subscription)));
930
931 $subscription->update(['next_billing_date' => $newDate]);
932 }
933
934 /**
935 * How many days before its due date a renewal invoice is generated, per billing
936 * interval. Single source of truth for both the scheduler and the admin info display.
937 * Filterable so developers can tune the advance window per interval.
938 *
939 * @param string $interval Billing interval being looked up ('' when the full map is wanted).
940 * @return array<string,int>
941 */
942 public static function getAdvanceCreationDaysMap(string $interval = ''): array
943 {
944 return apply_filters('fluent_cart/renewal/advance_creation_days', [
945 'daily' => 0,
946 'weekly' => 3,
947 'monthly' => 7,
948 'quarterly' => 15,
949 'half_yearly' => 15,
950 'yearly' => 15,
951 ], $interval);
952 }
953
954 }
955