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

855 lines 26.7 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\Events\Subscription\SubscriptionCanceled;
9 use FluentCart\App\Helpers\Helper;
10 use FluentCart\App\Helpers\Status;
11 use FluentCart\App\Models\Concerns\CanUpdateBatch;
12 use FluentCart\App\Models\Concerns\HasActivity;
13 use FluentCart\App\Services\Payments\PaymentHelper;
14 use FluentCart\App\Services\Payments\SubscriptionHelper;
15 use FluentCart\App\Services\TemplateService;
16 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
17 use FluentCart\Framework\Database\Orm\Relations\HasMany;
18 use FluentCart\Framework\Database\Orm\Relations\HasOne;
19 use FluentCart\Framework\Database\Orm\Relations\MorphMany;
20 use FluentCart\Framework\Support\Arr;
21 use FluentCartPro\App\Modules\Licensing\Models\License;
22
23 /**
24 * Meta Model - DB Model for Meta table
25 *
26 * Database Model
27 *
28 * @package FluentCart\App\Models
29 *
30 * @version 1.0.0
31 */
32 class Subscription extends Model
33 {
34 use HasActivity, CanUpdateBatch;
35
36 protected $table = 'fct_subscriptions';
37
38 protected $primaryKey = 'id';
39
40 protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url'];
41
42 protected $guarded = ['id'];
43
44 protected $fillable = [
45 'customer_id',
46 'parent_order_id',
47 'product_id',
48 'item_name',
49 'variation_id',
50 'billing_interval',
51 'signup_fee',
52 'quantity',
53 'recurring_amount',
54 'recurring_tax_total',
55 'recurring_total',
56 'bill_times',
57 'bill_count',
58 'expire_at',
59 'trial_ends_at',
60 'canceled_at',
61 'restored_at',
62 'collection_method',
63 'trial_days',
64 'vendor_customer_id',
65 'vendor_plan_id',
66 'vendor_subscription_id',
67 'next_billing_date',
68 'status',
69 'original_plan',
70 'vendor_response',
71 'current_payment_method',
72 'config'
73 ];
74
75 public static function boot()
76 {
77 parent::boot();
78 static::creating(function ($model) {
79 if (empty($model->uuid)) {
80 $model->uuid = md5(time() . wp_generate_uuid4());
81 }
82 });
83 }
84
85 public function meta()
86 {
87 return $this->hasMany(SubscriptionMeta::class, 'subscription_id', 'id');
88 }
89
90 public function customer(): BelongsTo
91 {
92 return $this->belongsTo(Customer::class, 'customer_id', 'id');
93 }
94
95 public function product(): BelongsTo
96 {
97 return $this->belongsTo(Product::class, 'product_id', 'ID');
98 }
99
100 public function variation(): BelongsTo
101 {
102 return $this->belongsTo(ProductVariation::class, 'variation_id');
103 }
104
105 public function labels(): MorphMany
106 {
107 return $this->morphMany(LabelRelationship::class, 'labelable');
108 }
109
110 public function license(): ?HasOne
111 {
112 if (!class_exists(License::class)) {
113 return null;
114 }
115 return $this->hasOne(License::class, 'subscription_id', 'id');
116 }
117
118 public function licenses(): ?HasMany
119 {
120 if (!class_exists(License::class)) {
121 return null;
122 }
123 return $this->hasMany(License::class, 'subscription_id', 'id');
124 }
125
126 public function transactions(): HasMany
127 {
128 return $this->hasMany(OrderTransaction::class, 'subscription_id', 'id');
129 }
130
131 public function billing_addresses(): HasMany
132 {
133 return $this->hasMany(CustomerAddresses::class, 'customer_id', 'customer_id')->where('type', 'billing');
134 }
135
136 public function getConfigAttribute($value)
137 {
138 if (is_string($value)) {
139 $decoded = json_decode($value, true);
140 return $decoded ?: $value;
141 }
142 return $value ?: [];
143 }
144
145 public function setConfigAttribute($value)
146 {
147 if (is_array($value)) {
148 $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
149 } else {
150 $value = '[]';
151 }
152
153 $this->attributes['config'] = $value;
154 }
155
156 public function getUrlAttribute($value)
157 {
158 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
159 'vendor_subscription_id' => $this->vendor_subscription_id,
160 'payment_mode' => (new StoreSettings())->get('order_mode'),
161 'subscription' => $this
162 ]);
163
164 }
165
166
167 // use this to override the status of the subscription for any custom use case
168
169 /**
170 * current use case: If the orignal plan(product variation) has no trial days but the subscription status is 'trialing'
171 * it can happens upon discount applied / proration on plan change,
172 * use overriden status to show the correct status for customer
173 */
174 public function getOverriddenStatusAttribute($value)
175 {
176 $variation = ProductVariation::find($this->variation_id);
177 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
178 return Status::SUBSCRIPTION_ACTIVE;
179 }
180
181 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()) {
182 return Status::SUBSCRIPTION_TRIALING;
183 }
184
185 return $this->status;
186 }
187
188 public function getBillingInfoAttribute($value)
189 {
190 $billingInfo = '';
191 $metaKey = 'active_payment_method';
192 $meta = $this->meta->where('meta_key', $metaKey)->first();
193 $billingInfo = $meta ? (is_string($meta->meta_value) ? json_decode($meta->meta_value, true) : $meta->meta_value) : [];
194 return $billingInfo;
195 }
196
197
198 public function getPaymentMethodText()
199 {
200 $info = Arr::get($this->billingInfo, 'details');
201 if (Arr::get($info, 'brand') && Arr::get($info, 'last_4')) {
202 return sprintf('%1$s ***%2$s', esc_html($info['brand']), esc_html($info['last_4']));
203 }
204
205 return Arr::get($info, 'method', '');
206 }
207
208 public function product_detail(): BelongsTo
209 {
210 return $this->belongsTo(ProductDetail::class, 'variation_id', 'id');
211 }
212
213 public function order(): BelongsTo
214 {
215 return $this->belongsTo(Order::class, 'parent_order_id', 'id');
216 }
217
218 /**
219 * Get the currency for the subscription
220 *
221 * @return string
222 */
223 public function getCurrencyAttribute(): string
224 {
225 $currency = '';
226
227 if (empty($this->config)) {
228 // get from store settings
229 $currency = CurrencySettings::get('currency');
230 return strtoupper($currency);
231 }
232
233 $definedCurrency = Arr::get($this->config, 'currency', '');
234
235 if(empty($definedCurrency)) {
236 $currency = CurrencySettings::get('currency');
237 return strtoupper($currency);
238 }
239
240 return strtoupper($definedCurrency);
241 }
242
243 /**
244 * Get subscription payment info if available
245 *
246 * @return string
247 */
248 public function getPaymentInfoAttribute(): string
249 {
250 return $this->getSubscriptionInfo();
251 }
252
253 /**
254 * Helper method to get subscription info
255 *
256 * @return string
257 */
258 private function getSubscriptionInfo(): string
259 {
260 $subscriptionInfo = '';
261
262 $otherInfo = [
263 'repeat_interval' => $this->billing_interval ?? '',
264 'times' => $this->bill_times ?? 0,
265 'recurring_total' => $this->recurring_total ?? 0,
266 'trial_days' => $this->trial_days ?? 0,
267 ];
268
269 $recurringTotal = $this->recurring_total ?? 0;
270
271 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal) ?? '';
272 }
273
274 public function addLog($title, $description = '', $type = 'info', $by = '')
275 {
276 $logData = [
277 'module_type' => 'FluentCart\App\Models\Subscription',
278 'module_id' => $this->id,
279 'module_name' => 'subscription',
280 ];
281
282 if ($by) {
283 $logData['created_by'] = $by;
284 }
285
286 fluent_cart_add_log($title, $description, $type, $logData);
287 }
288
289 public function getDownloads()
290 {
291 if (!$this->variation_id || $this->status !== Status::SUBSCRIPTION_ACTIVE) {
292 return [];
293 }
294
295 $variationTitles = ProductVariation::pluck('variation_title', 'id');
296 $productTitles = Product::pluck('post_title', 'ID');
297
298 $downloads = ProductDownload::query()->where('post_id', $this->product_id)->get();
299
300 $downloads->filter(function ($download) {
301 if (empty($download->product_variation_id)) {
302 return true;
303 }
304 $ids = $download->product_variation_id;
305
306 if (!is_array($ids)) {
307 return true;
308 }
309 return empty($ids) || in_array($this->variation_id, $ids);
310 });
311
312 return $downloads
313 ->map(function ($download) use ($variationTitles, $productTitles) {
314 $variationIds = $download->product_variation_id;
315
316 $download->product_title = $productTitles[$download->post_id] ?? '';
317 $download->variation_ids = $variationIds;
318 $download->variation_titles = array_map(
319 fn($id) => $variationTitles[$id] ?? null,
320 $variationIds
321 );
322 unset($download->product_variation_id);
323 return $download;
324 });
325 }
326
327 public function getMeta($metaKey, $default = null)
328 {
329 $exist = SubscriptionMeta::query()
330 ->where('subscription_id', $this->id)
331 ->where('meta_key', $metaKey)
332 ->first();
333
334 if ($exist) {
335 return $exist->meta_value;
336 }
337
338 return $default;
339 }
340
341 public function updateMeta($metaKey, $metaValue)
342 {
343 $exist = SubscriptionMeta::query()
344 ->where('subscription_id', $this->id)
345 ->where('meta_key', $metaKey)
346 ->first();
347
348 if ($exist) {
349 $exist->meta_value = $metaValue;
350 $exist->save();
351 } else {
352 SubscriptionMeta::query()->create([
353 'subscription_id' => $this->id,
354 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
355 'meta_key' => $metaKey,
356 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
357 'meta_value' => $metaValue
358 ]);
359 }
360
361 return true;
362 }
363
364 public function deleteMeta($metaKey)
365 {
366 return SubscriptionMeta::query()
367 ->where('subscription_id', $this->id)
368 ->where('meta_key', $metaKey)
369 ->delete();
370 }
371
372 public function getLatestTransaction()
373 {
374 return OrderTransaction::query()
375 ->where('subscription_id', $this->id)
376 ->orderBy('id', 'DESC')
377 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
378 ->first();
379 }
380
381 public function canUpgrade()
382 {
383 return Meta::query()->where('meta_key', 'variant_upgrade_path')
384 ->where('object_id', $this->variation_id)
385 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
386 }
387
388 public function canUpdatePaymentMethod()
389 {
390 $gateway = App::gateway($this->current_payment_method);
391 if ($gateway && !in_array('card_update', $gateway->supportedFeatures)) {
392 return false;
393 }
394
395 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
396 }
397
398 public function canSwitchPaymentMethod()
399 {
400 $gateway = App::gateway($this->current_payment_method);
401
402 if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) {
403 return false;
404 }
405
406 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
407 }
408
409 public function switchablePaymentMethods()
410 {
411 $gateway = App::gateway($this->current_payment_method);
412 if ($gateway && empty($gateway->supportedFeatures['switch_payment_method'])) {
413 return [];
414 }
415
416 return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []);
417 }
418
419 public function canReactive()
420 {
421 if (!App::isProActive()) {
422 return '';
423 }
424
425 if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) {
426 return '';
427 }
428
429 if (isset($this->config['cancellation_reason']) && $this->config['cancellation_reason'] === 'refunded') {
430 return '';
431 }
432
433 $canReactivate = in_array($this->status, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRED, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_PAST_DUE]);
434
435 return apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
436 'subscription' => $this
437 ]);
438 }
439
440 public function getReactivateUrl()
441 {
442 if (!$this->canReactive()) {
443 return '';
444 }
445
446 return add_query_arg([
447 'fluent-cart' => 'reactivate-subscription',
448 'subscription_hash' => $this->uuid,
449 ], home_url('/'));
450 }
451
452 public function getReactivateUrlAttribute()
453 {
454 return $this->getReactivateUrl();
455 }
456
457 public function getViewUrl($type = 'customer')
458 {
459 if ($type == 'customer') {
460 return TemplateService::getCustomerProfileUrl('subscription/' . $this->uuid);
461 }
462
463 return TemplateService::getAdminUrl('subscriptions/' . $this->id . '/view');
464
465 }
466
467 public function hasAccessValidity()
468 {
469 $validAccessStatuses = [
470 Status::SUBSCRIPTION_ACTIVE,
471 Status::SUBSCRIPTION_TRIALING,
472 Status::SUBSCRIPTION_COMPLETED
473 ];
474
475 if (in_array($this->status, $validAccessStatuses)) {
476 return true;
477 }
478
479 $invalidStatuses = [
480 Status::SUBSCRIPTION_EXPIRED,
481 Status::SUBSCRIPTION_PAST_DUE,
482 Status::SUBSCRIPTION_INTENDED,
483 Status::SUBSCRIPTION_PENDING
484 ];
485
486 if (in_array($this->status, $invalidStatuses)) {
487 return false;
488 }
489
490 $nextBillingDate = $this->next_billing_date;
491
492 if (!$nextBillingDate) {
493 $nextBillingDate = $this->guessNextBillingDate();
494 }
495
496 // now check the dates
497 if (strtotime($nextBillingDate) > time()) {
498 return true;
499 }
500
501 return false;
502 }
503
504 public function reSyncFromRemote()
505 {
506 if ($gateway = App::gateway($this->current_payment_method)) {
507 if ($gateway->has('subscriptions')) {
508 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
509 }
510 }
511
512 return new \WP_Error('invalid_payment_method', __('This payment method does not support remote resync', 'fluent-cart'));
513 }
514
515 public function cancelRemoteSubscription($args = [])
516 {
517 $args = wp_parse_args($args, [
518 'reason' => '',
519 'fire_hooks' => true,
520 'note' => ''
521 ]);
522
523 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
524 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
525 }
526
527 $gateway = App::gateway($this->current_payment_method);
528
529 if ($gateway && $gateway->has('subscriptions')) {
530 $vendorCanceled = $gateway->subscriptions->cancel($this->vendor_subscription_id, [
531 'subscription_id' => $this->id,
532 'parent_order_id' => $this->parent_order_id,
533 'mode' => $this->order->mode
534 ]);
535
536 if (is_wp_error($vendorCanceled)) {
537 return $vendorCanceled;
538 }
539
540 $updateData = array_filter($vendorCanceled);
541 } else {
542 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
543 $updateData = [
544 'canceled_at' => gmdate('Y-m-d H:i:s', time())
545 ];
546 }
547
548 $updateData['status'] = Status::SUBSCRIPTION_CANCELED;
549
550 if (empty($updateData['canceled_at']) && !$this->canceled_at) {
551 $updateData['canceled_at'] = gmdate('Y-m-d H:i:s', time());
552 }
553
554 if ($this->status === Status::SUBSCRIPTION_COMPLETED) {
555 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
556 $updateData['canceled_at'] = NULL;
557 }
558
559 $config = $this->config;
560 if ($args['reason']) {
561 $config['cancellation_reason'] = $args['reason'];
562 }
563 $updateData['config'] = $config;
564
565 $this->fill($updateData);
566 $this->save();
567
568 $note = $args['note'];
569
570 if (!$note) {
571 $note = 'on customer request';
572 }
573
574 if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) {
575 (new SubscriptionCanceled($this, $this->order, $this->order->customer, $note))->dispatch();
576 }
577
578 if ($args['note']) {
579 $this->order->note = $note;
580 $this->order->save();
581 }
582
583 return [
584 'subscription' => $this,
585 'vendor_result' => $vendorCanceled
586 ];
587 }
588
589
590 public function getCurrentRenewalAmount()
591 {
592 $currentRecurringAmount = (int)Arr::get($this->config, 'current_renewal_amount');
593 if ($currentRecurringAmount) {
594 return $currentRecurringAmount;
595 }
596
597 return $this->recurring_total;
598 }
599
600 public function getRequiredBillTimes()
601 {
602 $billTimes = (int)$this->bill_times;
603
604 if ($billTimes > 0) {
605 $billTimes = $billTimes - $this->bill_count;
606 if ($billTimes <= 0) {
607 $transacactionsCount = OrderTransaction::query()
608 ->where('subscription_id', $this->id)
609 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
610 ->where('status', Status::TRANSACTION_SUCCEEDED)
611 ->where('total', '>', 0)
612 ->count();
613
614 $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
615 foreach ($earlyPaymentHistory as $earlyPayment) {
616 $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
617 if ($paidCount > 1) {
618 $transacactionsCount += ($paidCount - 1);
619 }
620 }
621
622 if ($transacactionsCount != $this->bill_count) {
623 $this->bill_count = $transacactionsCount;
624 $this->save();
625 }
626
627 $revisedBillTimes = $this->bill_times - $this->bill_count;
628 if ($revisedBillTimes <= 0) {
629 return -1;
630 }
631
632 return $revisedBillTimes;
633 }
634 }
635
636 return $billTimes;
637 }
638
639 public function getReactivationTrialDays()
640 {
641 if (!$this->hasAccessValidity()) {
642 return 0;
643 }
644
645 $nextBillingDate = $this->guessNextBillingDate(true);
646
647 // @todo: Temporary fix for next billing date mismatch issue from migration
648
649 // $nextBillingDate = $this->next_billing_date;
650 //
651 // if (!$nextBillingDate) {
652 // $nextBillingDate = $this->guessNextBillingDate(true);
653 // }
654
655 $nextBillingDate = strtotime($nextBillingDate);
656
657 $currentDate = time();
658 $trialDays = floor(($nextBillingDate - $currentDate) / DAY_IN_SECONDS); // Convert seconds to days
659
660 if ($trialDays <= 1) {
661 $trialDays = 0; // Ensure trial days are not negative
662 }
663
664 return $trialDays;
665 }
666
667
668 public function guessNextBillingDate($forced = false)
669 {
670 if ($this->next_billing_date && !$forced) {
671 return $this->next_billing_date;
672 }
673
674 // preserve it during reactivation to maintain the billing cycle
675 if ($this->next_billing_date && $this->status === Status::SUBSCRIPTION_CANCELED) {
676 return $this->next_billing_date;
677 }
678
679 // we have to create a next billing date somehow!!
680 $theLastOrder = Order::query()
681 ->where(function ($q) {
682 $q->where('parent_id', $this->parent_order_id)
683 ->orWhere('id', $this->parent_order_id);
684 })
685 ->orderBy('id', 'DESC')
686 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
687 ->first();
688
689 if ($theLastOrder) {
690 $days = PaymentHelper::getIntervalDays($this->billing_interval);
691 if ($theLastOrder->type == 'renewal') {
692 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
693 } else {
694 if ($this->trial_days) {
695 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
696 } else {
697 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
698 }
699 }
700 } else {
701 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
702 }
703
704 return $nextBillingDate;
705 }
706
707 /**
708 * Check and expire subscriptions past their grace period
709 *
710 * This method is called by the hourly scheduler to automatically expire
711 * subscriptions that have missed payments and are past their grace period.
712 *
713 * Processes all candidates in batches to avoid memory issues.
714 * The query example works as follows:
715 * SELECT * FROM subscriptions WHERE
716 status IN ('active', 'trialing', 'canceled')
717 AND next_billing_date IS NOT NULL
718 AND id > 0 -- last processed ID for batch cursor
719 AND next_billing_date < DATE_SUB(
720 '2026-02-17 10:00:00',
721 INTERVAL (
722 CASE billing_interval
723 WHEN 'daily' THEN 1
724 WHEN 'weekly' THEN 3
725 WHEN 'monthly' THEN 7
726 WHEN 'quarterly' THEN 15
727 WHEN 'half_yearly' THEN 15
728 WHEN 'yearly' THEN 15
729 ELSE 7
730 END
731 ) DAY
732 )
733 ORDER BY id ASC
734 LIMIT 100;
735 *
736 * @param int $batchSize Number of subscriptions to process per batch
737 * @return array Statistics about processed subscriptions
738 */
739 public static function checkAndExpireSubscriptions($batchSize = 100)
740 {
741 $stats = [
742 'checked' => 0,
743 'validity_expired' => 0,
744 'batches' => 0,
745 ];
746
747 $lastId = 0;
748
749 $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays();
750
751 $caseSql = 'CASE billing_interval ';
752 $bindings = [];
753
754 foreach ($gracePeriodDays as $interval => $days) {
755 $caseSql .= 'WHEN ? THEN ? ';
756 $bindings[] = $interval;
757 $bindings[] = $days;
758 }
759
760 $caseSql .= 'ELSE ? END';
761 $bindings[] = 7;
762
763 $cutoffSql = "DATE_SUB(?, INTERVAL ($caseSql) DAY)";
764
765 do {
766 // Include canceled subscriptions to check if validity is yet to expired
767 $subscriptions = Subscription::query()
768 ->whereIn('status', [
769 Status::SUBSCRIPTION_ACTIVE,
770 Status::SUBSCRIPTION_TRIALING,
771 Status::SUBSCRIPTION_CANCELED,
772 ])
773 ->whereNotNull('next_billing_date')
774 ->where('id', '>', $lastId)
775 ->whereRaw(
776 "next_billing_date < $cutoffSql",
777 array_merge(
778 [gmdate('Y-m-d H:i:s', time())],
779 $bindings
780 )
781 )
782 ->orderBy('id', 'ASC')
783 ->limit($batchSize)
784 ->with(['order', 'customer'])
785 ->get();
786
787 if ($subscriptions->isEmpty()) {
788 break; // No more subscriptions to process
789 }
790
791 $stats['batches']++;
792 $stats['checked'] += $subscriptions->count();
793
794 foreach ($subscriptions as $subscription) {
795 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
796 if (isset($subscription->config['upgraded_to_sub_id'])) {
797 continue;
798 }
799 }
800 $gracePeriod = $gracePeriodDays[$subscription->billing_interval] ?? 7;
801 $cutoff = gmdate('Y-m-d H:i:s', time() - ($gracePeriod * DAY_IN_SECONDS));
802
803 if ($subscription->next_billing_date < $cutoff) {
804 $updateData = [
805 'next_billing_date' => NULL,
806 ];
807
808 // Only change status to EXPIRED for active/trialing subscriptions
809 if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
810 $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
811 }
812
813 $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s'));
814
815 $subscription->fill($updateData);
816 $subscription->save();
817
818 $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
819 $subscription,
820 $subscription->order,
821 $subscription->customer,
822 );
823
824 $event->dispatch();
825
826 $stats['validity_expired']++;
827 }
828
829 }
830
831 $lastId = $subscriptions->last()->id;
832
833 unset($subscriptions);
834
835 } while (true);
836
837 if ($stats['checked'] > 0) {
838 fluent_cart_add_log(
839 'Subscription Validity Expiration Check',
840 sprintf(
841 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d',
842 $stats['checked'],
843 $stats['validity_expired'],
844 $stats['batches']
845 ),
846 'info',
847 $stats
848 );
849 }
850
851 return $stats;
852 }
853
854 }
855