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

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