PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.23
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.23
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 trunk All 48 releases
fluent-cart / app / Models / Subscription.php

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

860 lines 26.9 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 $cancelArgs = [
531 'subscription_id' => $this->id,
532 'parent_order_id' => $this->parent_order_id,
533 'mode' => $this->order->mode,
534 ];
535 $effectiveFrom = Arr::get($args, 'effective_from', '');
536 if ($effectiveFrom) {
537 $cancelArgs['effective_from'] = $effectiveFrom;
538 }
539 $vendorCanceled = $gateway->subscriptions->cancel($this->vendor_subscription_id, $cancelArgs);
540
541 if (is_wp_error($vendorCanceled)) {
542 return $vendorCanceled;
543 }
544
545 $updateData = array_filter($vendorCanceled);
546 } else {
547 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
548 $updateData = [
549 'canceled_at' => gmdate('Y-m-d H:i:s', time())
550 ];
551 }
552
553 $updateData['status'] = Status::SUBSCRIPTION_CANCELED;
554
555 if (empty($updateData['canceled_at']) && !$this->canceled_at) {
556 $updateData['canceled_at'] = gmdate('Y-m-d H:i:s', time());
557 }
558
559 if ($this->status === Status::SUBSCRIPTION_COMPLETED) {
560 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
561 $updateData['canceled_at'] = NULL;
562 }
563
564 $config = $this->config;
565 if ($args['reason']) {
566 $config['cancellation_reason'] = $args['reason'];
567 }
568 $updateData['config'] = $config;
569
570 $this->fill($updateData);
571 $this->save();
572
573 $note = $args['note'];
574
575 if (!$note) {
576 $note = 'on customer request';
577 }
578
579 if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) {
580 (new SubscriptionCanceled($this, $this->order, $this->order->customer, $note))->dispatch();
581 }
582
583 if ($args['note']) {
584 $this->order->note = $note;
585 $this->order->save();
586 }
587
588 return [
589 'subscription' => $this,
590 'vendor_result' => $vendorCanceled
591 ];
592 }
593
594
595 public function getCurrentRenewalAmount()
596 {
597 $currentRecurringAmount = (int)Arr::get($this->config, 'current_renewal_amount');
598 if ($currentRecurringAmount) {
599 return $currentRecurringAmount;
600 }
601
602 return $this->recurring_total;
603 }
604
605 public function getRequiredBillTimes()
606 {
607 $billTimes = (int)$this->bill_times;
608
609 if ($billTimes > 0) {
610 $billTimes = $billTimes - $this->bill_count;
611 if ($billTimes <= 0) {
612 $transacactionsCount = OrderTransaction::query()
613 ->where('subscription_id', $this->id)
614 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
615 ->where('status', Status::TRANSACTION_SUCCEEDED)
616 ->where('total', '>', 0)
617 ->count();
618
619 $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
620 foreach ($earlyPaymentHistory as $earlyPayment) {
621 $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
622 if ($paidCount > 1) {
623 $transacactionsCount += ($paidCount - 1);
624 }
625 }
626
627 if ($transacactionsCount != $this->bill_count) {
628 $this->bill_count = $transacactionsCount;
629 $this->save();
630 }
631
632 $revisedBillTimes = $this->bill_times - $this->bill_count;
633 if ($revisedBillTimes <= 0) {
634 return -1;
635 }
636
637 return $revisedBillTimes;
638 }
639 }
640
641 return $billTimes;
642 }
643
644 public function getReactivationTrialDays()
645 {
646 if (!$this->hasAccessValidity()) {
647 return 0;
648 }
649
650 $nextBillingDate = $this->guessNextBillingDate(true);
651
652 // @todo: Temporary fix for next billing date mismatch issue from migration
653
654 // $nextBillingDate = $this->next_billing_date;
655 //
656 // if (!$nextBillingDate) {
657 // $nextBillingDate = $this->guessNextBillingDate(true);
658 // }
659
660 $nextBillingDate = strtotime($nextBillingDate);
661
662 $currentDate = time();
663 $trialDays = floor(($nextBillingDate - $currentDate) / DAY_IN_SECONDS); // Convert seconds to days
664
665 if ($trialDays <= 1) {
666 $trialDays = 0; // Ensure trial days are not negative
667 }
668
669 return $trialDays;
670 }
671
672
673 public function guessNextBillingDate($forced = false)
674 {
675 if ($this->next_billing_date && !$forced) {
676 return $this->next_billing_date;
677 }
678
679 // preserve it during reactivation to maintain the billing cycle
680 if ($this->next_billing_date && $this->status === Status::SUBSCRIPTION_CANCELED) {
681 return $this->next_billing_date;
682 }
683
684 // we have to create a next billing date somehow!!
685 $theLastOrder = Order::query()
686 ->where(function ($q) {
687 $q->where('parent_id', $this->parent_order_id)
688 ->orWhere('id', $this->parent_order_id);
689 })
690 ->orderBy('id', 'DESC')
691 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
692 ->first();
693
694 if ($theLastOrder) {
695 $days = PaymentHelper::getIntervalDays($this->billing_interval);
696 if ($theLastOrder->type == 'renewal') {
697 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
698 } else {
699 if ($this->trial_days) {
700 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
701 } else {
702 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
703 }
704 }
705 } else {
706 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
707 }
708
709 return $nextBillingDate;
710 }
711
712 /**
713 * Check and expire subscriptions past their grace period
714 *
715 * This method is called by the hourly scheduler to automatically expire
716 * subscriptions that have missed payments and are past their grace period.
717 *
718 * Processes all candidates in batches to avoid memory issues.
719 * The query example works as follows:
720 * SELECT * FROM subscriptions WHERE
721 status IN ('active', 'trialing', 'canceled')
722 AND next_billing_date IS NOT NULL
723 AND id > 0 -- last processed ID for batch cursor
724 AND next_billing_date < DATE_SUB(
725 '2026-02-17 10:00:00',
726 INTERVAL (
727 CASE billing_interval
728 WHEN 'daily' THEN 1
729 WHEN 'weekly' THEN 3
730 WHEN 'monthly' THEN 7
731 WHEN 'quarterly' THEN 15
732 WHEN 'half_yearly' THEN 15
733 WHEN 'yearly' THEN 15
734 ELSE 7
735 END
736 ) DAY
737 )
738 ORDER BY id ASC
739 LIMIT 100;
740 *
741 * @param int $batchSize Number of subscriptions to process per batch
742 * @return array Statistics about processed subscriptions
743 */
744 public static function checkAndExpireSubscriptions($batchSize = 100)
745 {
746 $stats = [
747 'checked' => 0,
748 'validity_expired' => 0,
749 'batches' => 0,
750 ];
751
752 $lastId = 0;
753
754 $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays();
755
756 $caseSql = 'CASE billing_interval ';
757 $bindings = [];
758
759 foreach ($gracePeriodDays as $interval => $days) {
760 $caseSql .= 'WHEN ? THEN ? ';
761 $bindings[] = $interval;
762 $bindings[] = $days;
763 }
764
765 $caseSql .= 'ELSE ? END';
766 $bindings[] = 7;
767
768 $cutoffSql = "DATE_SUB(?, INTERVAL ($caseSql) DAY)";
769
770 do {
771 // Include canceled subscriptions to check if validity is yet to expired
772 $subscriptions = Subscription::query()
773 ->whereIn('status', [
774 Status::SUBSCRIPTION_ACTIVE,
775 Status::SUBSCRIPTION_TRIALING,
776 Status::SUBSCRIPTION_CANCELED,
777 ])
778 ->whereNotNull('next_billing_date')
779 ->where('id', '>', $lastId)
780 ->whereRaw(
781 "next_billing_date < $cutoffSql",
782 array_merge(
783 [gmdate('Y-m-d H:i:s', time())],
784 $bindings
785 )
786 )
787 ->orderBy('id', 'ASC')
788 ->limit($batchSize)
789 ->with(['order', 'customer'])
790 ->get();
791
792 if ($subscriptions->isEmpty()) {
793 break; // No more subscriptions to process
794 }
795
796 $stats['batches']++;
797 $stats['checked'] += $subscriptions->count();
798
799 foreach ($subscriptions as $subscription) {
800 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
801 if (isset($subscription->config['upgraded_to_sub_id'])) {
802 continue;
803 }
804 }
805 $gracePeriod = $gracePeriodDays[$subscription->billing_interval] ?? 7;
806 $cutoff = gmdate('Y-m-d H:i:s', time() - ($gracePeriod * DAY_IN_SECONDS));
807
808 if ($subscription->next_billing_date < $cutoff) {
809 $updateData = [
810 'next_billing_date' => NULL,
811 ];
812
813 // Only change status to EXPIRED for active/trialing subscriptions
814 if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
815 $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
816 }
817
818 $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s'));
819
820 $subscription->fill($updateData);
821 $subscription->save();
822
823 $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
824 $subscription,
825 $subscription->order,
826 $subscription->customer,
827 );
828
829 $event->dispatch();
830
831 $stats['validity_expired']++;
832 }
833
834 }
835
836 $lastId = $subscriptions->last()->id;
837
838 unset($subscriptions);
839
840 } while (true);
841
842 if ($stats['checked'] > 0) {
843 fluent_cart_add_log(
844 'Subscription Validity Expiration Check',
845 sprintf(
846 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d',
847 $stats['checked'],
848 $stats['validity_expired'],
849 $stats['batches']
850 ),
851 'info',
852 $stats
853 );
854 }
855
856 return $stats;
857 }
858
859 }
860