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

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