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

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