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

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