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

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