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

1,157 lines 38.8 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 /**
684 * Cycles the remote (vendor) plan must bill at INITIAL checkout.
685 * With a simulated trial the first installment is already collected outside
686 * the remote recurring cycles (one-time charge, paid/free trial cycle), so
687 * the remote plan only needs bill_times - 1.
688 *
689 * Only valid at initial checkout — do NOT use for renewals/reactivation
690 * (payment-method switching also sets is_trial_days_simulated; renewal flows
691 * must use getRequiredBillTimes() which is bill_count based).
692 *
693 * @return int 0 means unlimited
694 */
695 public function getInitialRemoteBillTimes()
696 {
697 $billTimes = (int)$this->bill_times;
698
699 if (!$billTimes) {
700 return 0;
701 }
702
703 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') === 'yes') {
704 // never return 0 here — 0 means unlimited to the gateways
705 $billTimes = max(1, $billTimes - 1);
706 }
707
708 return $billTimes;
709 }
710
711 public function getRequiredBillTimes()
712 {
713 $billTimes = (int)$this->bill_times;
714
715 if ($billTimes > 0) {
716 $billTimes = $billTimes - $this->bill_count;
717 if ($billTimes <= 0) {
718 $transacactionsCount = $this->calculateBillCount();
719
720 if ($transacactionsCount != $this->bill_count) {
721 $this->bill_count = $transacactionsCount;
722 $this->save();
723 }
724
725 $revisedBillTimes = $this->bill_times - $this->bill_count;
726 if ($revisedBillTimes <= 0) {
727 return -1;
728 }
729
730 return $revisedBillTimes;
731 }
732 }
733
734 return $billTimes;
735 }
736
737 /**
738 * Canonical bill_count formula. Every writer of bill_count must go through
739 * this — a separate ad hoc count (e.g. StripeGateway\SubscriptionsManager
740 * previously) silently drops the offset/deduction corrections below and
741 * reports a wrong count until the next recompute.
742 *
743 * total > 0 CHARGE transactions linked to this subscription, adjusted for
744 * the two one-time corrections decided at creation (see
745 * CheckoutProcessor::syncInitialCycleCounting):
746 * - billed_cycles_offset: free simulated-trial first cycle consumed a
747 * cycle without producing a total > 0 transaction.
748 * - billed_cycles_deduction: real-trial signup-fee-only charge is a
749 * total > 0 transaction but isn't a billed cycle.
750 */
751 public function calculateBillCount()
752 {
753 $transacactionsCount = OrderTransaction::query()
754 ->where('subscription_id', $this->id)
755 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
756 ->where('status', Status::TRANSACTION_SUCCEEDED)
757 ->where('total', '>', 0)
758 ->count();
759
760 $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
761 foreach ((array)$earlyPaymentHistory as $earlyPayment) {
762 $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
763 if ($paidCount > 1) {
764 $transacactionsCount += ($paidCount - 1);
765 }
766 }
767
768 $transacactionsCount += (int) $this->getMeta('billed_cycles_offset', 0);
769 $transacactionsCount -= (int) $this->getMeta('billed_cycles_deduction', 0);
770
771 return $transacactionsCount;
772 }
773
774 /**
775 * Installment / split-pay plan: a finite-term subscription (a lifetime
776 * license paid off in a fixed number of charges), as opposed to an
777 * open-ended recurring subscription. The canonical structural signal is
778 * bill_times > 0 (0 = infinite/open-ended). Reused across analytics,
779 * filters and lifecycle handling — do NOT reintroduce title-string
780 * ("Split") matching, which the data does not reliably carry.
781 *
782 * @return bool
783 */
784 public function isInstallment()
785 {
786 return (int) $this->bill_times > 0;
787 }
788
789 /**
790 * Installments still owed: 0 for open-ended plans, or once the term is
791 * fully paid.
792 *
793 * @return int
794 */
795 public function installmentsRemaining()
796 {
797 if (!$this->isInstallment()) {
798 return 0;
799 }
800
801 return max(0, (int) $this->bill_times - (int) $this->bill_count);
802 }
803
804 /**
805 * Has a finite installment plan collected every scheduled charge (end of
806 * term)? Open-ended plans never reach term end.
807 *
808 * @return bool
809 */
810 public function hasReachedTermEnd()
811 {
812 return $this->isInstallment() && (int) $this->bill_count >= (int) $this->bill_times;
813 }
814
815 /**
816 * Full committed price of an installment contract: recurring_total x
817 * bill_times, in cents. 0 for open-ended plans (no fixed total). This is
818 * the per-row form of the SUM(recurring_total * bill_times) used by the
819 * subscription analytics aggregate.
820 *
821 * @return int
822 */
823 public function totalContractValue()
824 {
825 if (!$this->isInstallment()) {
826 return 0;
827 }
828
829 return (int) $this->recurring_total * (int) $this->bill_times;
830 }
831
832 /**
833 * Filter by plan type: 'installment' (finite term, bill_times > 0),
834 * 'recurring' (open-ended, bill_times = 0) or anything else (no filter).
835 * The bill_times threshold is kept identical to isInstallment() so the SQL
836 * and PHP definitions never drift apart.
837 */
838 public function scopeOfPlanType($query, $planType)
839 {
840 if ($planType === 'installment') {
841 return $query->where('bill_times', '>', 0);
842 }
843 if ($planType === 'recurring') {
844 return $query->where('bill_times', '<=', 0);
845 }
846
847 return $query;
848 }
849
850 public function getReactivationTrialDays()
851 {
852 if (!$this->hasAccessValidity()) {
853 return 0;
854 }
855
856 $lastPaidTransaction = OrderTransaction::query()
857 ->where('subscription_id', $this->id)
858 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
859 ->where('status', Status::TRANSACTION_SUCCEEDED)
860 ->where('total', '>', 0)
861 ->orderBy('id', 'DESC')
862 ->first();
863
864 if ($lastPaidTransaction && $lastPaidTransaction->getMaxRefundableAmount() === 0) {
865 return 0;
866 }
867
868 $nextBillingDate = $this->guessNextBillingDate(true);
869
870 // @todo: Temporary fix for next billing date mismatch issue from migration
871
872 // $nextBillingDate = $this->next_billing_date;
873 //
874 // if (!$nextBillingDate) {
875 // $nextBillingDate = $this->guessNextBillingDate(true);
876 // }
877
878 $nextBillingDate = strtotime($nextBillingDate);
879
880 $currentDate = time();
881 $trialDays = floor(($nextBillingDate - $currentDate) / DAY_IN_SECONDS); // Convert seconds to days
882
883 if ($trialDays <= 1) {
884 $trialDays = 0; // Ensure trial days are not negative
885 }
886
887 return $trialDays;
888 }
889
890
891 public function guessNextBillingDate($forced = false)
892 {
893 if ($this->next_billing_date && !$forced) {
894 return $this->next_billing_date;
895 }
896
897 // preserve it during reactivation to maintain the billing cycle
898 if ($this->next_billing_date && $this->status === Status::SUBSCRIPTION_CANCELED) {
899 return $this->next_billing_date;
900 }
901
902 // we have to create a next billing date somehow!!
903 $theLastOrder = Order::query()
904 ->where(function ($q) {
905 $q->where('parent_id', $this->parent_order_id)
906 ->orWhere('id', $this->parent_order_id);
907 })
908 ->orderBy('id', 'DESC')
909 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
910 ->first();
911
912 if ($theLastOrder) {
913 $days = PaymentHelper::getIntervalDays($this->billing_interval);
914 if ($theLastOrder->type == 'renewal') {
915 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
916 } else {
917 if ($this->trial_days) {
918 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
919 } else {
920 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
921 }
922 }
923 } else {
924 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
925 }
926
927 return $nextBillingDate;
928 }
929
930 /**
931 * Check and expire subscriptions past their grace period
932 *
933 * This method is called by the hourly scheduler to automatically expire
934 * subscriptions that have missed payments and are past their grace period.
935 *
936 * Processes all candidates in batches to avoid memory issues.
937 * The query example works as follows:
938 * SELECT * FROM subscriptions WHERE
939 status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
940 AND next_billing_date IS NOT NULL
941 AND id > 0 -- last processed ID for batch cursor
942 AND next_billing_date < DATE_SUB(
943 '2026-02-17 10:00:00',
944 INTERVAL (
945 CASE billing_interval
946 WHEN 'daily' THEN 1
947 WHEN 'weekly' THEN 3
948 WHEN 'monthly' THEN 7
949 WHEN 'quarterly' THEN 15
950 WHEN 'half_yearly' THEN 15
951 WHEN 'yearly' THEN 15
952 ELSE 7
953 END
954 ) DAY
955 )
956 ORDER BY id ASC
957 LIMIT 100;
958 *
959 * @param int $batchSize Number of subscriptions to process per batch
960 * @return array Statistics about processed subscriptions
961 */
962 public static function checkAndExpireSubscriptions($batchSize = 100)
963 {
964 $stats = [
965 'checked' => 0,
966 'validity_expired' => 0,
967 'batches' => 0,
968 'expired_ids' => [],
969 ];
970
971 $lastId = 0;
972
973 do {
974 $currentTime = time();
975 $now = gmdate('Y-m-d H:i:s', $currentTime);
976
977 $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays();
978
979 $cutoffDates = [];
980 foreach ($gracePeriodDays as $interval => $days) {
981 $cutoffDates[$interval] = gmdate('Y-m-d H:i:s', $currentTime - ((int)$days * DAY_IN_SECONDS));
982 }
983
984 // Fallback cutoff for unknown/null billing intervals.
985 $defaultGraceDays = 7;
986 $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
987 $knownIntervals = array_keys($cutoffDates);
988
989 $subscriptions = Subscription::query()
990 ->whereIn('status', [
991 Status::SUBSCRIPTION_ACTIVE,
992 Status::SUBSCRIPTION_TRIALING,
993 Status::SUBSCRIPTION_CANCELED,
994 Status::SUBSCRIPTION_EXPIRING,
995 Status::SUBSCRIPTION_PAST_DUE
996 ])
997 ->whereNotNull('next_billing_date')
998 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
999 ->where('id', '>', $lastId)
1000 ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
1001 $query->where(function ($subQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1002 $subQuery->whereIn('status', [
1003 Status::SUBSCRIPTION_ACTIVE,
1004 Status::SUBSCRIPTION_TRIALING,
1005 Status::SUBSCRIPTION_EXPIRING,
1006 Status::SUBSCRIPTION_PAST_DUE,
1007 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1008 $index = 0;
1009
1010 // OR together one (interval + its cutoff) clause per known interval.
1011 foreach ($cutoffDates as $interval => $cutoff) {
1012 $method = $index === 0 ? 'where' : 'orWhere';
1013
1014 $dateQuery->{$method}(function ($intervalQuery) use ($interval, $cutoff) {
1015 $intervalQuery->where('billing_interval', $interval)
1016 ->where('next_billing_date', '<', $cutoff);
1017 });
1018
1019 $index++;
1020 }
1021
1022 // Unknown/null intervals fall back to the default cutoff.
1023 $dateQuery->orWhere(function ($intervalQuery) use ($knownIntervals, $defaultCutoff) {
1024 $intervalQuery->where(function ($unknownIntervalQuery) use ($knownIntervals) {
1025 $unknownIntervalQuery->whereNotIn('billing_interval', $knownIntervals)
1026 ->orWhereNull('billing_interval');
1027 })->where('next_billing_date', '<', $defaultCutoff);
1028 });
1029 });
1030 // Branch B: canceled subs expire the moment their paid period ends (no grace).
1031 })->orWhere(function ($subQuery) use ($now) {
1032 $subQuery->where('status', Status::SUBSCRIPTION_CANCELED)
1033 ->where('next_billing_date', '<', $now);
1034 });
1035 })
1036 ->orderBy('id', 'ASC')
1037 ->limit($batchSize)
1038 ->with(['order', 'customer'])
1039 ->get();
1040
1041 if ($subscriptions->isEmpty()) {
1042 break;
1043 }
1044
1045 $stats['batches']++;
1046 $stats['checked'] += $subscriptions->count();
1047
1048 foreach ($subscriptions as $subscription) {
1049 $nextBillingTimestamp = strtotime($subscription->next_billing_date);
1050
1051 // Skip unparseable/invalid dates.
1052 if (!$nextBillingTimestamp || $nextBillingTimestamp <= 0) {
1053 continue;
1054 }
1055
1056 // Re-validate in PHP (SQL was a coarse filter) and derive the exact cutoff used as a write guard below.
1057 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1058 // Superseded by an upgrade -> the new sub owns validity, leave this one alone.
1059 if (isset($subscription->config['upgraded_to_sub_id'])) {
1060 continue;
1061 }
1062
1063 // Already processed in a prior run.
1064 if ($subscription->getMeta('validity_expired_at')) {
1065 continue;
1066 }
1067
1068 // Paid period not over yet.
1069 if ($nextBillingTimestamp >= $currentTime) {
1070 continue;
1071 }
1072
1073 $cutoff = $now;
1074 } else {
1075 $graceDays = $gracePeriodDays[$subscription->billing_interval] ?? $defaultGraceDays;
1076 $graceDays = max(0, (int)$graceDays);
1077 $cutoffTimestamp = $currentTime - ($graceDays * DAY_IN_SECONDS);
1078
1079 // Still inside the grace window.
1080 if ($nextBillingTimestamp >= $cutoffTimestamp) {
1081 continue;
1082 }
1083
1084 $cutoff = gmdate('Y-m-d H:i:s', $cutoffTimestamp);
1085 }
1086
1087 // Null out next_billing_date so the row can't be re-selected/re-processed.
1088 $updateData = [
1089 'next_billing_date' => NULL,
1090 'updated_at' => gmdate('Y-m-d H:i:s', $currentTime),
1091 ];
1092
1093 // Canceled subs keep their status; only billing statuses flip to EXPIRED.
1094 if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
1095 $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
1096 }
1097
1098 // Optimistic-lock write: only apply if status + past-cutoff still hold, so a concurrent
1099 // renewal/cancel between SELECT and UPDATE can't be overwritten with a stale decision.
1100 $updated = Subscription::query()
1101 ->where('id', $subscription->id)
1102 ->where('status', $subscription->status)
1103 ->where('next_billing_date', '<', $cutoff)
1104 ->update($updateData);
1105
1106 if (!$updated) {
1107 continue;
1108 }
1109
1110 $subscription = Subscription::query()
1111 ->with(['order', 'customer'])
1112 ->find($subscription->id);
1113
1114 if (!$subscription) {
1115 continue;
1116 }
1117
1118 // Idempotency marker + audit timestamp for this expiry.
1119 $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s', $currentTime));
1120
1121 $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
1122 $subscription,
1123 $subscription->order,
1124 $subscription->customer
1125 );
1126
1127 $event->dispatch();
1128
1129 $stats['validity_expired']++;
1130 $stats['expired_ids'][] = $subscription->id;
1131 }
1132
1133 $lastId = $subscriptions->last()->id;
1134
1135 unset($subscriptions);
1136 } while (true);
1137
1138 if ($stats['checked'] > 0) {
1139 $expiredList = !empty($stats['expired_ids']) ? ' (IDs: ' . implode(', ', $stats['expired_ids']) . ')' : '';
1140 fluent_cart_add_log(
1141 'Subscription Validity Expiration Check',
1142 sprintf(
1143 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d%s',
1144 $stats['checked'],
1145 $stats['validity_expired'],
1146 $stats['batches'],
1147 $expiredList
1148 ),
1149 'info',
1150 $stats
1151 );
1152 }
1153
1154 return $stats;
1155 }
1156
1157 }