PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
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 / Models / Subscription.php

Subscription.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.0, at app/Models/Subscription.php

1,500 lines 51.2 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\Models;
4
5 use FluentCart\Api\CurrencySettings;
6 use FluentCart\Api\StoreSettings;
7 use FluentCart\App\App;
8 use FluentCart\App\Helpers\AttributeHelper;
9 use FluentCart\App\Helpers\Helper;
10 use FluentCart\App\Helpers\Status;
11 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
12 use FluentCart\App\Models\Concerns\CanUpdateBatch;
13 use FluentCart\App\Models\Concerns\HasActivity;
14 use FluentCart\App\Services\Payments\PaymentHelper;
15 use FluentCart\App\Services\Payments\SubscriptionHelper;
16 use FluentCart\App\Services\TemplateService;
17 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
18 use FluentCart\Framework\Database\Orm\Relations\HasMany;
19 use FluentCart\Framework\Database\Orm\Relations\HasOne;
20 use FluentCart\Framework\Database\Orm\Relations\MorphMany;
21 use FluentCart\Framework\Support\Arr;
22 use FluentCartPro\App\Modules\Licensing\Models\License;
23
24 /**
25 * Meta Model - DB Model for Meta table
26 *
27 * Database Model
28 *
29 * @property string $uuid
30 *
31 * @package FluentCart\App\Models
32 *
33 * @version 1.0.0
34 */
35 class Subscription extends Model
36 {
37 use HasActivity, CanUpdateBatch;
38
39 protected $table = 'fct_subscriptions';
40
41 protected $primaryKey = 'id';
42
43 protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'permissions', 'display_item_name', 'system_charge_state'];
44
45 protected $guarded = ['id'];
46
47 protected $fillable = [
48 'customer_id',
49 'parent_order_id',
50 'product_id',
51 'item_name',
52 'variation_id',
53 'billing_interval',
54 'signup_fee',
55 'quantity',
56 'recurring_amount',
57 'recurring_tax_total',
58 'recurring_total',
59 'bill_times',
60 'bill_count',
61 'expire_at',
62 'trial_ends_at',
63 'canceled_at',
64 'restored_at',
65 'collection_method',
66 'trial_days',
67 'vendor_customer_id',
68 'vendor_plan_id',
69 'vendor_subscription_id',
70 'next_billing_date',
71 'status',
72 'original_plan',
73 'vendor_response',
74 'current_payment_method',
75 'config'
76 ];
77
78 public static function boot()
79 {
80 parent::boot();
81 static::creating(function ($model) {
82 if (empty($model->uuid)) {
83 $model->uuid = md5(time() . wp_generate_uuid4());
84 }
85 });
86 }
87
88 public function getNextBillingDateAttribute($value)
89 {
90 if (empty($value) || $value === '0000-00-00 00:00:00' || $value === '0000-00-00') {
91 return null;
92 }
93 return $value;
94 }
95
96 public function getCanceledAtAttribute($value)
97 {
98 if (empty($value) || $value === '0000-00-00 00:00:00' || $value === '0000-00-00') {
99 return null;
100 }
101 return $value;
102 }
103
104 public function getExpireAtAttribute($value)
105 {
106 if (empty($value) || $value === '0000-00-00 00:00:00' || $value === '0000-00-00') {
107 return null;
108 }
109 return $value;
110 }
111
112 public function meta()
113 {
114 return $this->hasMany(SubscriptionMeta::class, 'subscription_id', 'id');
115 }
116
117 public function customer(): BelongsTo
118 {
119 return $this->belongsTo(Customer::class, 'customer_id', 'id');
120 }
121
122 public function product(): BelongsTo
123 {
124 return $this->belongsTo(Product::class, 'product_id', 'ID');
125 }
126
127 public function variation(): BelongsTo
128 {
129 return $this->belongsTo(ProductVariation::class, 'variation_id');
130 }
131
132 public function labels(): MorphMany
133 {
134 return $this->morphMany(LabelRelationship::class, 'labelable');
135 }
136
137 public function license(): ?HasOne
138 {
139 if (!class_exists(License::class)) {
140 return null;
141 }
142 return $this->hasOne(License::class, 'subscription_id', 'id');
143 }
144
145 public function licenses(): ?HasMany
146 {
147 if (!class_exists(License::class)) {
148 return null;
149 }
150 return $this->hasMany(License::class, 'subscription_id', 'id');
151 }
152
153 public function transactions(): HasMany
154 {
155 return $this->hasMany(OrderTransaction::class, 'subscription_id', 'id');
156 }
157
158 public function billing_addresses(): HasMany
159 {
160 return $this->hasMany(CustomerAddresses::class, 'customer_id', 'customer_id')->where('type', 'billing');
161 }
162
163 public function getConfigAttribute($value)
164 {
165 if (is_string($value)) {
166 $decoded = json_decode($value, true);
167 return is_array($decoded) ? $decoded : $value;
168 }
169 return $value ?: [];
170 }
171
172 public function setConfigAttribute($value)
173 {
174 if (is_array($value)) {
175 $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
176 } else {
177 $value = '[]';
178 }
179
180 $this->attributes['config'] = $value;
181 }
182
183 /**
184 * Customer-facing display name. When the config['item_attributes'] snapshot
185 * resolves it returns the product name with the labeled combination
186 * ("Cake - Flavor: Vanilla | Weight: 500 g"); otherwise the raw item_name
187 * (simple / pre-snapshot subscriptions).
188 *
189 * Presentation-only — it does NOT override the item_name column, so internal
190 * and payment-gateway reads of $subscription->item_name keep the raw stored
191 * value. Use this only at customer-facing display sites.
192 *
193 * The model is passed to the resolver so attribute-display filters (e.g. for
194 * simple-variation / third-party attributes) get the item context they need.
195 *
196 * @return string
197 */
198 public function getDisplayItemNameAttribute()
199 {
200 $itemAttributes = Arr::get($this->config, 'item_attributes', []);
201
202 if (!$itemAttributes) {
203 return $this->item_name;
204 }
205
206 $attributeDisplayTitleString = AttributeHelper::getDisplayAttributesString($itemAttributes, $this, 'subscription');
207
208 if ($attributeDisplayTitleString === '') {
209 return $this->item_name;
210 }
211
212 // Standalone label has no separate product line, so prefix the product
213 // name: "<product> - <attributes>".
214 $postTitle = $this->product ? $this->product->post_title : '';
215
216 return $postTitle !== '' ? $postTitle . ' - ' . $attributeDisplayTitleString : $attributeDisplayTitleString;
217 }
218
219 public function getUrlAttribute($value)
220 {
221 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
222 'vendor_subscription_id' => $this->vendor_subscription_id,
223 'payment_mode' => (new StoreSettings())->get('order_mode'),
224 'subscription' => $this
225 ]);
226
227 }
228
229
230 // use this to override the status of the subscription for any custom use case
231
232 /**
233 * current use case: If the orignal plan(product variation) has no trial days but the subscription status is 'trialing'
234 * it can happens upon discount applied / proration on plan change,
235 * use overriden status to show the correct status for customer
236 */
237 public function getOverriddenStatusAttribute($value)
238 {
239 $variation = ProductVariation::find($this->variation_id);
240 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
241 return Status::SUBSCRIPTION_ACTIVE;
242 }
243
244 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') !== 'yes' && $this->status == Status::SUBSCRIPTION_ACTIVE && $this->trial_days && (strtotime($this->created_at) + ($this->trial_days * 86400)) > time()) {
245 return Status::SUBSCRIPTION_TRIALING;
246 }
247
248 return $this->status;
249 }
250
251 /**
252 * Auto-charge bookkeeping for system subscriptions (attempt count, next retry,
253 * last error, processing marker). Null for every other collection method —
254 * guarded before the meta lookup so manual/automatic subscriptions pay nothing.
255 */
256 public function getSystemChargeStateAttribute()
257 {
258 if ($this->collection_method !== 'system') {
259 return null;
260 }
261
262 $meta = $this->meta->where('meta_key', 'system_charge_state')->first();
263
264 if (!$meta) {
265 return null;
266 }
267
268 return is_string($meta->meta_value) ? json_decode($meta->meta_value, true) : $meta->meta_value;
269 }
270
271 public function getHasPendingSkipAttribute(): bool
272 {
273 return $this->hasPendingSkip();
274 }
275
276 public function getLastSkippedPeriodAttribute()
277 {
278 $skipped = $this->getMeta('skipped_periods', []);
279
280 if (!is_array($skipped) || empty($skipped)) {
281 return null;
282 }
283
284 return end($skipped) ?: null;
285 }
286
287 public function getBillingInfoAttribute($value)
288 {
289 $billingInfo = '';
290 $metaKey = 'active_payment_method';
291 $meta = $this->meta->where('meta_key', $metaKey)->first();
292 $billingInfo = $meta ? (is_string($meta->meta_value) ? json_decode($meta->meta_value, true) : $meta->meta_value) : [];
293 return $billingInfo;
294 }
295
296
297 public function getPaymentMethodText()
298 {
299 $info = Arr::get($this->billingInfo, 'details');
300 if (Arr::get($info, 'brand') && Arr::get($info, 'last_4')) {
301 return sprintf('%1$s ***%2$s', esc_html($info['brand']), esc_html($info['last_4']));
302 }
303
304 return Arr::get($info, 'method', '');
305 }
306
307 public function product_detail(): BelongsTo
308 {
309 return $this->belongsTo(ProductDetail::class, 'variation_id', 'id');
310 }
311
312 public function order(): BelongsTo
313 {
314 return $this->belongsTo(Order::class, 'parent_order_id', 'id');
315 }
316
317 public function getBusinessInfoAttribute(): array
318 {
319 if ($this->relationLoaded('order') && $this->order) {
320 return $this->order->getBusinessInfo();
321 }
322 return [];
323 }
324
325 public function getIsReverseChargeTaxOrderAttribute(): bool
326 {
327 if ($this->relationLoaded('order') && $this->order) {
328 return $this->order->isReverseChargeTaxOrder();
329 }
330 return false;
331 }
332
333 /**
334 * Get the currency for the subscription
335 *
336 * @return string
337 */
338 public function getCurrencyAttribute(): string
339 {
340 $currency = '';
341
342 if (empty($this->config)) {
343 // get from store settings
344 $currency = CurrencySettings::get('currency');
345 return strtoupper($currency);
346 }
347
348 $definedCurrency = Arr::get($this->config, 'currency', '');
349
350 if(empty($definedCurrency)) {
351 $currency = CurrencySettings::get('currency');
352 return strtoupper($currency);
353 }
354
355 return strtoupper($definedCurrency);
356 }
357
358 /**
359 * Get subscription payment info if available
360 *
361 * @return string
362 */
363 public function getPaymentInfoAttribute(): string
364 {
365 return $this->getSubscriptionInfo();
366 }
367
368 /**
369 * Get subscription permissions for the current user
370 * Returns what actions can be performed on this subscription
371 *
372 * @return array
373 */
374 public function getPermissionsAttribute(): array
375 {
376 $status = strtolower($this->status);
377 $hasVendorId = !empty($this->vendor_subscription_id);
378 $terminalStatuses = [
379 Status::SUBSCRIPTION_CANCELED,
380 Status::SUBSCRIPTION_EXPIRED,
381 Status::SUBSCRIPTION_COMPLETED,
382 ];
383
384 $canEdit = $this->usesRenewalEngine() && !in_array($status, $terminalStatuses);
385 $canCancel = !in_array($status, $terminalStatuses);
386
387 // One open-invoice lookup shared by the invoice actions below. Only runs
388 // for store-billed subscriptions in states where any of them can apply.
389 $hasOpenInvoice = false;
390 $chargeableStatuses = [
391 Status::SUBSCRIPTION_ACTIVE,
392 Status::SUBSCRIPTION_TRIALING,
393 Status::SUBSCRIPTION_PAST_DUE,
394 Status::SUBSCRIPTION_EXPIRED,
395 ];
396 if ($this->usesRenewalEngine() && in_array($status, $chargeableStatuses) && $this->parent_order_id) {
397 $hasOpenInvoice = Order::query()
398 ->where('parent_id', $this->parent_order_id)
399 ->where('type', Status::ORDER_TYPE_RENEWAL)
400 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
401 ->exists();
402 }
403
404 $canManageRenewal = $this->usesRenewalEngine()
405 && in_array($status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
406 && $this->next_billing_date
407 && !$hasOpenInvoice;
408
409 // Admin "Charge Now": system subscription with an open invoice whose charge
410 // is not currently settling at the gateway (processing marker).
411 $chargeState = $this->isSystem() ? ($this->system_charge_state ?: []) : [];
412 $canChargeNow = $this->isSystem()
413 && $hasOpenInvoice
414 && in_array($status, $chargeableStatuses)
415 && Arr::get($chargeState, 'status') !== 'processing';
416
417 return [
418 'canEdit' => $canEdit,
419 'canPause' => $this->canPause(),
420 'canResume' => $this->canResume(),
421 'canFetch' => !$this->usesRenewalEngine() && $hasVendorId,
422 'canCancel' => $canCancel,
423 // Admin one-click reactivate is for store-billed subscriptions only (the REST
424 // endpoint rejects automatic); automatic reactivation runs through the gateway
425 // URL flow, gated by canReactivate().
426 'canAdminReactivate' => $this->usesRenewalEngine() && $this->canReactivate(),
427 'canCreateRenewal' => $canManageRenewal,
428 'canSkipRenewal' => $canManageRenewal && !$this->hasPendingSkip(),
429 'canChargeNow' => $canChargeNow,
430 // Surfaced in the Edit modal: an already-issued renewal invoice is
431 // re-synced to the edited amount when it exists.
432 'hasPendingRenewal' => $hasOpenInvoice,
433 ];
434 }
435
436 /**
437 * Check if this is a manual subscription
438 *
439 * @return bool
440 */
441 public function isManual(): bool
442 {
443 return $this->collection_method === 'manual';
444 }
445
446 /**
447 * Check if this is a system (auto-charged, store-billed) subscription
448 *
449 * @return bool
450 */
451 public function isSystem(): bool
452 {
453 return $this->collection_method === 'system';
454 }
455
456 /**
457 * Manual and system subscriptions are both billed by FluentCart's invoice
458 * engine (renewal invoices, overdue escalation, admin invoice actions).
459 * System additionally auto-charges a stored token per invoice.
460 *
461 * @return bool
462 */
463 public function usesRenewalEngine(): bool
464 {
465 return in_array($this->collection_method, ['manual', 'system'], true);
466 }
467
468 /**
469 * Store-billed (manual/system) with a future due date has nothing to charge yet —
470 * reactivation should flip the subscription active locally instead of checkout.
471 *
472 * @return bool
473 */
474 public function shouldSubscriptionActiveLocally(): bool
475 {
476 return $this->usesRenewalEngine() && $this->next_billing_date && strtotime($this->next_billing_date) > time();
477 }
478
479 /**
480 * Helper method to get subscription info
481 *
482 * @return string
483 */
484 private function getSubscriptionInfo(): string
485 {
486 $subscriptionInfo = '';
487
488 $otherInfo = [
489 'repeat_interval' => $this->billing_interval ?? '',
490 'times' => $this->bill_times ?? 0,
491 'recurring_total' => $this->recurring_total ?? 0,
492 'trial_days' => $this->trial_days ?? 0,
493 ];
494
495 $recurringTotal = $this->recurring_total ?? 0;
496
497 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal, $this->currency) ?? '';
498 }
499
500 public function addLog($title, $description = '', $type = 'info', $by = '')
501 {
502 $logData = [
503 'module_type' => 'FluentCart\App\Models\Subscription',
504 'module_id' => $this->id,
505 'module_name' => 'subscription',
506 ];
507
508 if ($by) {
509 $logData['created_by'] = $by;
510 }
511
512 fluent_cart_add_log($title, $description, $type, $logData);
513 }
514
515 public function getDownloads()
516 {
517 if (!$this->variation_id || $this->status !== Status::SUBSCRIPTION_ACTIVE) {
518 return [];
519 }
520
521 $variationTitles = ProductVariation::pluck('variation_title', 'id');
522 $productTitles = Product::pluck('post_title', 'ID');
523
524 $downloads = ProductDownload::query()->where('post_id', $this->product_id)->get();
525
526 $downloads->filter(function ($download) {
527 if (empty($download->product_variation_id)) {
528 return true;
529 }
530 $ids = $download->product_variation_id;
531
532 if (!is_array($ids)) {
533 return true;
534 }
535 return empty($ids) || in_array($this->variation_id, $ids);
536 });
537
538 return $downloads
539 ->map(function ($download) use ($variationTitles, $productTitles) {
540 $variationIds = $download->product_variation_id;
541
542 $download->product_title = $productTitles[$download->post_id] ?? '';
543 $download->variation_ids = $variationIds;
544 $download->variation_titles = array_map(
545 fn($id) => $variationTitles[$id] ?? null,
546 $variationIds
547 );
548 unset($download->product_variation_id);
549 return $download;
550 });
551 }
552
553 public function getMeta($metaKey, $default = null)
554 {
555 $exist = SubscriptionMeta::query()
556 ->where('subscription_id', $this->id)
557 ->where('meta_key', $metaKey)
558 ->first();
559
560 if ($exist) {
561 return $exist->meta_value;
562 }
563
564 return $default;
565 }
566
567 public function updateMeta($metaKey, $metaValue)
568 {
569 $exist = SubscriptionMeta::query()
570 ->where('subscription_id', $this->id)
571 ->where('meta_key', $metaKey)
572 ->first();
573
574 if ($exist) {
575 $exist->meta_value = $metaValue;
576 $exist->save();
577 } else {
578 SubscriptionMeta::query()->create([
579 'subscription_id' => $this->id,
580 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
581 'meta_key' => $metaKey,
582 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
583 'meta_value' => $metaValue
584 ]);
585 }
586
587 return true;
588 }
589
590 public function deleteMeta($metaKey)
591 {
592 return SubscriptionMeta::query()
593 ->where('subscription_id', $this->id)
594 ->where('meta_key', $metaKey)
595 ->delete();
596 }
597
598 public function getLatestTransaction()
599 {
600 return OrderTransaction::query()
601 ->where('subscription_id', $this->id)
602 ->orderBy('id', 'DESC')
603 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
604 ->first();
605 }
606
607 public function canUpgrade()
608 {
609 return Meta::query()->where('meta_key', 'variant_upgrade_path')
610 ->where('object_id', $this->variation_id)
611 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
612 }
613
614 public function canUpdatePaymentMethod()
615 {
616 $gateway = App::gateway($this->current_payment_method);
617 if (!$gateway || !in_array('card_update', $gateway->supportedFeatures)) {
618 return false;
619 }
620
621 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_INTENDED, Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRING]); // past_due, is fallback for existing subscriptions, on new subscriptions update it will be expiring
622 }
623
624 public function canSwitchPaymentMethod()
625 {
626 // Switching moves the subscription onto ANOTHER gateway's vendor subscription
627 // (see PayPal SubscriptionManager::switchPaymentMethod — it creates a live
628 // PayPal subscription). A store-billed subscription is already owned by the
629 // invoice engine, so a vendor subscription would bill it a second time. The
630 // customer changes the card on file instead (canUpdatePaymentMethod).
631 if ($this->usesRenewalEngine()) {
632 return false;
633 }
634
635 $gateway = App::gateway($this->current_payment_method);
636
637 if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) {
638 return false;
639 }
640
641 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
642 }
643
644 public function switchablePaymentMethods()
645 {
646 if (!$this->canSwitchPaymentMethod()) {
647 return [];
648 }
649
650 $gateway = App::gateway($this->current_payment_method);
651 if (!$gateway || empty($gateway->supportedFeatures['switch_payment_method'])) {
652 return [];
653 }
654
655 return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []);
656 }
657
658 public function canPause()
659 {
660 // Store-billed (manual/system) subscriptions can always be paused
661 // (unless already paused/canceled/expired)
662 if ($this->usesRenewalEngine()) {
663 return in_array($this->status, [
664 Status::SUBSCRIPTION_ACTIVE,
665 Status::SUBSCRIPTION_TRIALING,
666 Status::SUBSCRIPTION_PAST_DUE,
667 Status::SUBSCRIPTION_EXPIRING
668 ]);
669 }
670
671 // Automatic subscriptions require gateway support
672 $gateway = App::gateway($this->current_payment_method);
673
674 if (!$gateway) {
675 return false;
676 }
677
678 // Check if gateway supports pause
679 if (!in_array('pause_subscription', $gateway->supportedFeatures)) {
680 return false;
681 }
682
683 // Default behavior for automatic subscriptions
684 return in_array($this->status, [
685 Status::SUBSCRIPTION_ACTIVE,
686 Status::SUBSCRIPTION_TRIALING
687 ]) && !in_array($this->status, [
688 Status::SUBSCRIPTION_PAUSED,
689 Status::SUBSCRIPTION_CANCELED,
690 Status::SUBSCRIPTION_EXPIRED,
691 Status::SUBSCRIPTION_COMPLETED
692 ]);
693 }
694
695 /**
696 * A skip is pending when the current upcoming period was reached by an admin
697 * skip that has not yet elapsed — next_billing_date still equals the value the
698 * last skip set. Blocks stacking another skip onto the same pending window.
699 *
700 * @return bool
701 */
702 public function hasPendingSkip(): bool
703 {
704 if (!$this->next_billing_date) {
705 return false;
706 }
707
708 $skippedTo = $this->getMeta('pending_skip_until');
709
710 if (!$skippedTo) {
711 return false;
712 }
713
714 return $skippedTo === $this->next_billing_date
715 && strtotime($this->next_billing_date) > time();
716 }
717
718 public function canResume()
719 {
720 // Store-billed (manual/system) subscriptions can be resumed from paused state
721 if ($this->usesRenewalEngine()) {
722 return $this->status === Status::SUBSCRIPTION_PAUSED;
723 }
724
725
726 $gateway = App::gateway($this->current_payment_method);
727
728 if (!$gateway) {
729 return false;
730 }
731
732 if (!in_array('resume_subscription', $gateway->supportedFeatures)) {
733 return false;
734 }
735
736 // Default behavior
737 return $this->status === Status::SUBSCRIPTION_PAUSED;
738 }
739
740 public function pauseSubscription($reason = '')
741 {
742 return SubscriptionService::pauseSubscription($this, $reason);
743 }
744
745 public function resumeSubscription($reason = '')
746 {
747 return SubscriptionService::resumeSubscription($this, $reason);
748 }
749
750 public function canUpdateDetails()
751 {
752 // Only store-billed (manual/system) subscriptions can be fully edited by
753 // admin — edits to a system subscription take effect on its next invoice.
754 return $this->usesRenewalEngine();
755 }
756
757 /**
758 * Update subscription details (for manual subscriptions)
759 *
760 * Allowed fields for manual subscriptions:
761 * - recurring_total: Update the next invoice/payment amount (in cents)
762 * - bill_times: Update the number of billing cycles (0 = unlimited)
763 * - billing_interval: Change billing frequency (daily, weekly, monthly, etc.)
764 * - expire_at: Update expiration date
765 * - trial_days: Update trial period
766 * - next_billing_date: Update next billing date
767 *
768 * @param array $data
769 * @return true|\WP_Error
770 */
771 public function updateSubscription(array $data)
772 {
773 return SubscriptionService::updateSubscription($this, $data);
774 }
775
776 /**
777 * Whether this subscription can be reactivated.
778 *
779 * Status-based for BOTH manual and automatic subscriptions — no gateway
780 * supportedFeatures branch on purpose. Manual reactivation is a local status
781 * flip; automatic reactivation runs through the Pro re-checkout flow
782 * (SubscriptionRenewalHandler builds an instant cart and the customer pays
783 * again), which works with any gateway. Gating on a gateway feature here
784 * would hide the customer-facing reactivate URL for Stripe/PayPal/etc.
785 *
786 * @return bool
787 */
788 public function canReactivate()
789 {
790 if (!App::isProActive()) {
791 return false;
792 }
793
794 if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) {
795 return false;
796 }
797
798 // Paused is intentionally excluded — a paused subscription resumes (see
799 // canResume()); reactivation is for terminal/lapsed states only.
800 $canReactivate = in_array($this->status, [
801 Status::SUBSCRIPTION_CANCELED,
802 Status::SUBSCRIPTION_FAILING,
803 Status::SUBSCRIPTION_EXPIRED,
804 Status::SUBSCRIPTION_EXPIRING,
805 Status::SUBSCRIPTION_PAST_DUE,
806 ]);
807
808 return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
809 'subscription' => $this
810 ]);
811 }
812
813 /**
814 * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
815 * @return bool
816 */
817 public function canReactive()
818 {
819 return $this->canReactivate();
820 }
821
822 public function getReactivationNonceAction()
823 {
824 return 'fluent_cart_reactivate_subscription_' . $this->uuid;
825 }
826
827 public function getReactivateUrl()
828 {
829 if (!$this->canReactive()) {
830 return '';
831 }
832
833 return add_query_arg([
834 'fluent-cart' => 'reactivate-subscription',
835 'subscription_hash' => $this->uuid,
836 '_wpnonce' => wp_create_nonce($this->getReactivationNonceAction()),
837 ], home_url('/'));
838 }
839
840 public function getReactivateUrlAttribute()
841 {
842 return $this->getReactivateUrl();
843 }
844
845 public function getViewUrl($type = 'customer')
846 {
847 if ($type == 'customer') {
848 return TemplateService::getCustomerProfileUrl('subscription/' . $this->uuid);
849 }
850
851 return TemplateService::getAdminUrl('subscriptions/' . $this->id . '/view');
852
853 }
854
855 public function hasAccessValidity()
856 {
857 $validAccessStatuses = [
858 Status::SUBSCRIPTION_ACTIVE,
859 Status::SUBSCRIPTION_TRIALING,
860 Status::SUBSCRIPTION_COMPLETED
861 ];
862
863 if (in_array($this->status, $validAccessStatuses)) {
864 return true;
865 }
866
867 // Past-due keeps access while the unpaid invoice is inside its dunning
868 // grace window; the expiry crons flip it to expired past that.
869 if ($this->status === Status::SUBSCRIPTION_PAST_DUE) {
870 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
871 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
872
873 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
874 }
875
876 $invalidStatuses = [
877 Status::SUBSCRIPTION_EXPIRED,
878 Status::SUBSCRIPTION_INTENDED,
879 Status::SUBSCRIPTION_PENDING
880 ];
881
882 if (in_array($this->status, $invalidStatuses)) {
883 return false;
884 }
885
886 $nextBillingDate = $this->next_billing_date;
887
888 if (!$nextBillingDate) {
889 $nextBillingDate = $this->guessNextBillingDate();
890 }
891
892 // now check the dates
893 if (strtotime($nextBillingDate) > time()) {
894 return true;
895 }
896
897 return false;
898 }
899
900 public function reSyncFromRemote()
901 {
902 if ($gateway = App::gateway($this->current_payment_method)) {
903 if ($gateway->has('subscriptions')) {
904 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
905 }
906 }
907
908 return new \WP_Error('invalid_payment_method', __('This payment method does not support remote resync', 'fluent-cart'));
909 }
910
911 public function cancelRemoteSubscription($args = [])
912 {
913 $args = wp_parse_args($args, [
914 'reason' => '',
915 'fire_hooks' => true,
916 'note' => '',
917 'effective_from' => ''
918 ]);
919
920 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
921 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
922 }
923
924 $gateway = App::gateway($this->current_payment_method);
925
926 // No vendor subscription (store-billed, or a vendor id that never landed) —
927 // nothing to cancel at the gateway.
928 if (!$this->vendor_subscription_id) {
929 $vendorCanceled = null;
930 $updateData = [
931 'canceled_at' => gmdate('Y-m-d H:i:s', time())
932 ];
933 } elseif ($gateway && $gateway->has('subscriptions')) {
934 $cancelArgs = [
935 'subscription_id' => $this->id,
936 'parent_order_id' => $this->parent_order_id,
937 'mode' => $this->order->mode,
938 ];
939 $effectiveFrom = Arr::get($args, 'effective_from', '');
940 if ($effectiveFrom) {
941 $cancelArgs['effective_from'] = $effectiveFrom;
942 }
943 $vendorCanceled = $gateway->subscriptions->cancel($this->vendor_subscription_id, $cancelArgs);
944
945 if (is_wp_error($vendorCanceled)) {
946 return $vendorCanceled;
947 }
948
949 $updateData = array_filter($vendorCanceled);
950 } else {
951 // Vendor subscription exists but this gateway cannot cancel it — it stays live.
952 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
953 $updateData = [
954 'canceled_at' => gmdate('Y-m-d H:i:s', time())
955 ];
956 }
957
958 $updateData['status'] = Status::SUBSCRIPTION_CANCELED;
959
960 if (empty($updateData['canceled_at']) && !$this->canceled_at) {
961 $updateData['canceled_at'] = gmdate('Y-m-d H:i:s', time());
962 }
963
964 if ($this->status === Status::SUBSCRIPTION_COMPLETED) {
965 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
966 $updateData['canceled_at'] = NULL;
967 }
968
969 $config = $this->config;
970 if ($args['reason']) {
971 $config['cancellation_reason'] = $args['reason'];
972 }
973 $updateData['config'] = $config;
974
975 if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
976 $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
977 }
978
979 // A completed (EOT) subscription has no upcoming billing — the immediate-cancel
980 // date above must not resurrect one (SubscriptionEOT cancels remote subscriptions
981 // with effective_from=immediately after syncSubscriptionStates nulled the date).
982 if (Arr::get($updateData, 'status') === Status::SUBSCRIPTION_COMPLETED) {
983 $updateData['next_billing_date'] = NULL;
984 }
985
986 $this->fill($updateData);
987 $this->save();
988
989 $note = $args['note'];
990
991 if (!$note) {
992 $note = 'on customer request';
993 }
994
995 // Single cancel chokepoint — void open renewals, clear reminders, email once.
996 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
997 SubscriptionService::finalizeCancellation($this, $note, (bool) $args['fire_hooks']);
998 }
999
1000 if ($args['note']) {
1001 $this->order->note = $note;
1002 $this->order->save();
1003 }
1004
1005 return [
1006 'subscription' => $this,
1007 'vendor_result' => $vendorCanceled
1008 ];
1009 }
1010
1011
1012 public function getCurrentRenewalAmount()
1013 {
1014 $currentRecurringAmount = (int)Arr::get($this->config, 'current_renewal_amount');
1015 if ($currentRecurringAmount) {
1016 return $currentRecurringAmount;
1017 }
1018
1019 return $this->recurring_total;
1020 }
1021
1022 /**
1023 * Cycles the remote (vendor) plan must bill at INITIAL checkout.
1024 * With a simulated trial the first installment is already collected outside
1025 * the remote recurring cycles (one-time charge, paid/free trial cycle), so
1026 * the remote plan only needs bill_times - 1.
1027 *
1028 * Only valid at initial checkout — do NOT use for renewals/reactivation
1029 * (payment-method switching also sets is_trial_days_simulated; renewal flows
1030 * must use getRequiredBillTimes() which is bill_count based).
1031 *
1032 * @return int 0 means unlimited
1033 */
1034 public function getInitialRemoteBillTimes()
1035 {
1036 $billTimes = (int)$this->bill_times;
1037
1038 if (!$billTimes) {
1039 return 0;
1040 }
1041
1042 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') === 'yes') {
1043 // never return 0 here — 0 means unlimited to the gateways
1044 $billTimes = max(1, $billTimes - 1);
1045 }
1046
1047 return $billTimes;
1048 }
1049
1050 public function getRequiredBillTimes()
1051 {
1052 $billTimes = (int)$this->bill_times;
1053
1054 if ($billTimes > 0) {
1055 $billTimes = $billTimes - $this->bill_count;
1056 if ($billTimes <= 0) {
1057 $transacactionsCount = $this->calculateBillCount();
1058
1059 if ($transacactionsCount != $this->bill_count) {
1060 $this->bill_count = $transacactionsCount;
1061 $this->save();
1062 }
1063
1064 $revisedBillTimes = $this->bill_times - $this->bill_count;
1065 if ($revisedBillTimes <= 0) {
1066 return -1;
1067 }
1068
1069 return $revisedBillTimes;
1070 }
1071 }
1072
1073 return $billTimes;
1074 }
1075
1076 /**
1077 * Canonical bill_count formula. Every writer of bill_count must go through
1078 * this — a separate ad hoc count (e.g. StripeGateway\SubscriptionsManager
1079 * previously) silently drops the offset/deduction corrections below and
1080 * reports a wrong count until the next recompute.
1081 *
1082 * total > 0 CHARGE transactions linked to this subscription, adjusted for
1083 * the two one-time corrections decided at creation (see
1084 * CheckoutProcessor::syncInitialCycleCounting):
1085 * - billed_cycles_offset: free simulated-trial first cycle consumed a
1086 * cycle without producing a total > 0 transaction.
1087 * - billed_cycles_deduction: real-trial signup-fee-only charge is a
1088 * total > 0 transaction but isn't a billed cycle.
1089 */
1090 public function calculateBillCount()
1091 {
1092 $transacactionsCount = OrderTransaction::query()
1093 ->where('subscription_id', $this->id)
1094 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1095 ->where('status', Status::TRANSACTION_SUCCEEDED)
1096 ->where('total', '>', 0)
1097 ->count();
1098
1099 $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
1100 foreach ((array)$earlyPaymentHistory as $earlyPayment) {
1101 $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
1102 if ($paidCount > 1) {
1103 $transacactionsCount += ($paidCount - 1);
1104 }
1105 }
1106
1107 $transacactionsCount += (int) $this->getMeta('billed_cycles_offset', 0);
1108 $transacactionsCount -= (int) $this->getMeta('billed_cycles_deduction', 0);
1109
1110 return $transacactionsCount;
1111 }
1112
1113 /**
1114 * Installment / split-pay plan: a finite-term subscription (a lifetime
1115 * license paid off in a fixed number of charges), as opposed to an
1116 * open-ended recurring subscription. The canonical structural signal is
1117 * bill_times > 0 (0 = infinite/open-ended). Reused across analytics,
1118 * filters and lifecycle handling — do NOT reintroduce title-string
1119 * ("Split") matching, which the data does not reliably carry.
1120 *
1121 * @return bool
1122 */
1123 public function isInstallment()
1124 {
1125 return (int) $this->bill_times > 0;
1126 }
1127
1128 /**
1129 * Installments still owed: 0 for open-ended plans, or once the term is
1130 * fully paid.
1131 *
1132 * @return int
1133 */
1134 public function installmentsRemaining()
1135 {
1136 if (!$this->isInstallment()) {
1137 return 0;
1138 }
1139
1140 return max(0, (int) $this->bill_times - (int) $this->bill_count);
1141 }
1142
1143 /**
1144 * Has a finite installment plan collected every scheduled charge (end of
1145 * term)? Open-ended plans never reach term end.
1146 *
1147 * @return bool
1148 */
1149 public function hasReachedTermEnd()
1150 {
1151 return $this->isInstallment() && (int) $this->bill_count >= (int) $this->bill_times;
1152 }
1153
1154 /**
1155 * Full committed price of an installment contract: recurring_total x
1156 * bill_times, in cents. 0 for open-ended plans (no fixed total). This is
1157 * the per-row form of the SUM(recurring_total * bill_times) used by the
1158 * subscription analytics aggregate.
1159 *
1160 * @return int
1161 */
1162 public function totalContractValue()
1163 {
1164 if (!$this->isInstallment()) {
1165 return 0;
1166 }
1167
1168 return (int) $this->recurring_total * (int) $this->bill_times;
1169 }
1170
1171 /**
1172 * Filter by plan type: 'installment' (finite term, bill_times > 0),
1173 * 'recurring' (open-ended, bill_times = 0) or anything else (no filter).
1174 * The bill_times threshold is kept identical to isInstallment() so the SQL
1175 * and PHP definitions never drift apart.
1176 */
1177 public function scopeOfPlanType($query, $planType)
1178 {
1179 if ($planType === 'installment') {
1180 return $query->where('bill_times', '>', 0);
1181 }
1182 if ($planType === 'recurring') {
1183 return $query->where('bill_times', '<=', 0);
1184 }
1185
1186 return $query;
1187 }
1188
1189 public function getReactivationTrialDays()
1190 {
1191 if (!$this->hasAccessValidity()) {
1192 return 0;
1193 }
1194
1195 $lastPaidTransaction = OrderTransaction::query()
1196 ->where('subscription_id', $this->id)
1197 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1198 ->where('status', Status::TRANSACTION_SUCCEEDED)
1199 ->where('total', '>', 0)
1200 ->orderBy('id', 'DESC')
1201 ->first();
1202
1203 if ($lastPaidTransaction && $lastPaidTransaction->getMaxRefundableAmount() === 0) {
1204 return 0;
1205 }
1206
1207 $nextBillingDate = $this->guessNextBillingDate(true);
1208
1209 // @todo: Temporary fix for next billing date mismatch issue from migration
1210
1211 // $nextBillingDate = $this->next_billing_date;
1212 //
1213 // if (!$nextBillingDate) {
1214 // $nextBillingDate = $this->guessNextBillingDate(true);
1215 // }
1216
1217 $nextBillingDate = strtotime($nextBillingDate);
1218
1219 $currentDate = time();
1220 $trialDays = floor(($nextBillingDate - $currentDate) / DAY_IN_SECONDS); // Convert seconds to days
1221
1222 if ($trialDays <= 1) {
1223 $trialDays = 0; // Ensure trial days are not negative
1224 }
1225
1226 return $trialDays;
1227 }
1228
1229
1230 public function guessNextBillingDate($forced = false)
1231 {
1232 if ($this->next_billing_date && !$forced) {
1233 return $this->next_billing_date;
1234 }
1235
1236 // preserve it during reactivation to maintain the billing cycle
1237 if ($this->next_billing_date && $this->status === Status::SUBSCRIPTION_CANCELED) {
1238 return $this->next_billing_date;
1239 }
1240
1241 // we have to create a next billing date somehow!!
1242 $theLastOrder = Order::query()
1243 ->where(function ($q) {
1244 $q->where('parent_id', $this->parent_order_id)
1245 ->orWhere('id', $this->parent_order_id);
1246 })
1247 ->orderBy('id', 'DESC')
1248 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1249 ->first();
1250
1251 if ($theLastOrder) {
1252 $days = PaymentHelper::getIntervalDays($this->billing_interval);
1253 if ($theLastOrder->type == 'renewal') {
1254 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1255 } else {
1256 if ($this->trial_days) {
1257 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
1258 } else {
1259 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1260 }
1261 }
1262 } else {
1263 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
1264 }
1265
1266 return $nextBillingDate;
1267 }
1268
1269 /**
1270 * Check and expire subscriptions past their grace period
1271 *
1272 * This method is called by the hourly scheduler to automatically expire
1273 * subscriptions that have missed payments and are past their grace period.
1274 *
1275 * Processes all candidates in batches to avoid memory issues.
1276 * The query example works as follows:
1277 * SELECT * FROM subscriptions WHERE
1278 status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1279 AND next_billing_date IS NOT NULL
1280 AND id > 0 -- last processed ID for batch cursor
1281 AND next_billing_date < DATE_SUB(
1282 '2026-02-17 10:00:00',
1283 INTERVAL (
1284 CASE billing_interval
1285 WHEN 'daily' THEN 1
1286 WHEN 'weekly' THEN 3
1287 WHEN 'monthly' THEN 7
1288 WHEN 'quarterly' THEN 15
1289 WHEN 'half_yearly' THEN 15
1290 WHEN 'yearly' THEN 15
1291 ELSE 7
1292 END
1293 ) DAY
1294 )
1295 ORDER BY id ASC
1296 LIMIT 100;
1297 *
1298 * @param int $batchSize Number of subscriptions to process per batch
1299 * @return array Statistics about processed subscriptions
1300 */
1301 public static function checkAndExpireSubscriptions($batchSize = 100)
1302 {
1303 $stats = [
1304 'checked' => 0,
1305 'validity_expired' => 0,
1306 'batches' => 0,
1307 'expired_ids' => [],
1308 ];
1309
1310 $lastId = 0;
1311
1312 do {
1313 $currentTime = time();
1314 $now = gmdate('Y-m-d H:i:s', $currentTime);
1315
1316 $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays();
1317
1318 $cutoffDates = [];
1319 foreach ($gracePeriodDays as $interval => $days) {
1320 $cutoffDates[$interval] = gmdate('Y-m-d H:i:s', $currentTime - ((int)$days * DAY_IN_SECONDS));
1321 }
1322
1323 // Fallback cutoff for unknown/null billing intervals.
1324 $defaultGraceDays = 7;
1325 $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
1326 $knownIntervals = array_keys($cutoffDates);
1327
1328 // Include canceled subscriptions to check if validity is yet to expired
1329 // Exclude store-billed (manual/system) subscriptions — their expiry is
1330 // handled by the invoice-based overdue flow
1331 $subscriptions = Subscription::query()
1332 ->whereIn('status', [
1333 Status::SUBSCRIPTION_ACTIVE,
1334 Status::SUBSCRIPTION_TRIALING,
1335 Status::SUBSCRIPTION_CANCELED,
1336 Status::SUBSCRIPTION_EXPIRING,
1337 Status::SUBSCRIPTION_PAST_DUE
1338 ])
1339 ->whereNotIn('collection_method', ['manual', 'system'])
1340 ->whereNotNull('next_billing_date')
1341 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
1342 ->where('id', '>', $lastId)
1343 ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
1344 $query->where(function ($subQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1345 $subQuery->whereIn('status', [
1346 Status::SUBSCRIPTION_ACTIVE,
1347 Status::SUBSCRIPTION_TRIALING,
1348 Status::SUBSCRIPTION_EXPIRING,
1349 Status::SUBSCRIPTION_PAST_DUE,
1350 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1351 $index = 0;
1352
1353 // OR together one (interval + its cutoff) clause per known interval.
1354 foreach ($cutoffDates as $interval => $cutoff) {
1355 $method = $index === 0 ? 'where' : 'orWhere';
1356
1357 $dateQuery->{$method}(function ($intervalQuery) use ($interval, $cutoff) {
1358 $intervalQuery->where('billing_interval', $interval)
1359 ->where('next_billing_date', '<', $cutoff);
1360 });
1361
1362 $index++;
1363 }
1364
1365 // Unknown/null intervals fall back to the default cutoff.
1366 $dateQuery->orWhere(function ($intervalQuery) use ($knownIntervals, $defaultCutoff) {
1367 $intervalQuery->where(function ($unknownIntervalQuery) use ($knownIntervals) {
1368 $unknownIntervalQuery->whereNotIn('billing_interval', $knownIntervals)
1369 ->orWhereNull('billing_interval');
1370 })->where('next_billing_date', '<', $defaultCutoff);
1371 });
1372 });
1373 // Branch B: canceled subs expire the moment their paid period ends (no grace).
1374 })->orWhere(function ($subQuery) use ($now) {
1375 $subQuery->where('status', Status::SUBSCRIPTION_CANCELED)
1376 ->where('next_billing_date', '<', $now);
1377 });
1378 })
1379 ->orderBy('id', 'ASC')
1380 ->limit($batchSize)
1381 ->with(['order', 'customer'])
1382 ->get();
1383
1384 if ($subscriptions->isEmpty()) {
1385 break;
1386 }
1387
1388 $stats['batches']++;
1389 $stats['checked'] += $subscriptions->count();
1390
1391 foreach ($subscriptions as $subscription) {
1392 $nextBillingTimestamp = strtotime($subscription->next_billing_date);
1393
1394 // Skip unparseable/invalid dates.
1395 if (!$nextBillingTimestamp || $nextBillingTimestamp <= 0) {
1396 continue;
1397 }
1398
1399 // Re-validate in PHP (SQL was a coarse filter) and derive the exact cutoff used as a write guard below.
1400 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1401 // Superseded by an upgrade -> the new sub owns validity, leave this one alone.
1402 if (isset($subscription->config['upgraded_to_sub_id'])) {
1403 continue;
1404 }
1405
1406 // Already processed in a prior run.
1407 if ($subscription->getMeta('validity_expired_at')) {
1408 continue;
1409 }
1410
1411 // Paid period not over yet.
1412 if ($nextBillingTimestamp >= $currentTime) {
1413 continue;
1414 }
1415
1416 $cutoff = $now;
1417 } else {
1418 $graceDays = $gracePeriodDays[$subscription->billing_interval] ?? $defaultGraceDays;
1419 $graceDays = max(0, (int)$graceDays);
1420 $cutoffTimestamp = $currentTime - ($graceDays * DAY_IN_SECONDS);
1421
1422 // Still inside the grace window.
1423 if ($nextBillingTimestamp >= $cutoffTimestamp) {
1424 continue;
1425 }
1426
1427 $cutoff = gmdate('Y-m-d H:i:s', $cutoffTimestamp);
1428 }
1429
1430 // Null out next_billing_date so the row can't be re-selected/re-processed.
1431 $updateData = [
1432 'next_billing_date' => NULL,
1433 'updated_at' => gmdate('Y-m-d H:i:s', $currentTime),
1434 ];
1435
1436 // Canceled subs keep their status; only billing statuses flip to EXPIRED.
1437 if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
1438 $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
1439 }
1440
1441 // Optimistic-lock write: only apply if status + past-cutoff still hold, so a concurrent
1442 // renewal/cancel between SELECT and UPDATE can't be overwritten with a stale decision.
1443 $updated = Subscription::query()
1444 ->where('id', $subscription->id)
1445 ->where('status', $subscription->status)
1446 ->where('next_billing_date', '<', $cutoff)
1447 ->update($updateData);
1448
1449 if (!$updated) {
1450 continue;
1451 }
1452
1453 $subscription = Subscription::query()
1454 ->with(['order', 'customer'])
1455 ->find($subscription->id);
1456
1457 if (!$subscription) {
1458 continue;
1459 }
1460
1461 // Idempotency marker + audit timestamp for this expiry.
1462 $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s', $currentTime));
1463
1464 $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
1465 $subscription,
1466 $subscription->order,
1467 $subscription->customer
1468 );
1469
1470 $event->dispatch();
1471
1472 $stats['validity_expired']++;
1473 $stats['expired_ids'][] = $subscription->id;
1474 }
1475
1476 $lastId = $subscriptions->last()->id;
1477
1478 unset($subscriptions);
1479 } while (true);
1480
1481 if ($stats['checked'] > 0) {
1482 $expiredList = !empty($stats['expired_ids']) ? ' (IDs: ' . implode(', ', $stats['expired_ids']) . ')' : '';
1483 fluent_cart_add_log(
1484 'Subscription Validity Expiration Check',
1485 sprintf(
1486 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d%s',
1487 $stats['checked'],
1488 $stats['validity_expired'],
1489 $stats['batches'],
1490 $expiredList
1491 ),
1492 'info',
1493 $stats
1494 );
1495 }
1496
1497 return $stats;
1498 }
1499
1500 }