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

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