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

1,749 lines 60.5 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\Helpers\AttributeHelper;
9 use FluentCart\App\Helpers\Helper;
10 use FluentCart\App\Helpers\Status;
11 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
12 use FluentCart\App\Modules\PaymentMethods\Core\PaymentGatewayInterface;
13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 use FluentCart\App\Models\Concerns\CanUpdateBatch;
15 use FluentCart\App\Models\Concerns\HasActivity;
16 use FluentCart\App\Services\Payments\SubscriptionHelper;
17 use FluentCart\App\Services\TemplateService;
18 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
19 use FluentCart\Framework\Database\Orm\Relations\HasMany;
20 use FluentCart\Framework\Database\Orm\Relations\HasOne;
21 use FluentCart\Framework\Database\Orm\Relations\MorphMany;
22 use FluentCart\Framework\Support\Arr;
23 use FluentCartPro\App\Modules\Licensing\Models\License;
24
25 /**
26 * Meta Model - DB Model for Meta table
27 *
28 * Database Model
29 *
30 * @property string $uuid
31 *
32 * @package FluentCart\App\Models
33 *
34 * @version 1.0.0
35 */
36 class Subscription extends Model
37 {
38 use HasActivity, CanUpdateBatch;
39
40 protected $table = 'fct_subscriptions';
41
42 protected $primaryKey = 'id';
43
44 protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'permissions', 'display_item_name', 'system_charge_state', 'payment_method_title'];
45
46 protected $guarded = ['id'];
47
48 protected $fillable = [
49 'customer_id',
50 'parent_order_id',
51 'product_id',
52 'item_name',
53 'variation_id',
54 'billing_interval',
55 'signup_fee',
56 'quantity',
57 'recurring_amount',
58 'recurring_tax_total',
59 'recurring_total',
60 'bill_times',
61 'bill_count',
62 'expire_at',
63 'trial_ends_at',
64 'canceled_at',
65 'restored_at',
66 'collection_method',
67 'trial_days',
68 'vendor_customer_id',
69 'vendor_plan_id',
70 'vendor_subscription_id',
71 'next_billing_date',
72 'status',
73 'original_plan',
74 'vendor_response',
75 'current_payment_method',
76 'config'
77 ];
78
79 public static function boot()
80 {
81 parent::boot();
82 static::creating(function ($model) {
83 if (empty($model->uuid)) {
84 $model->uuid = md5(time() . wp_generate_uuid4());
85 }
86 });
87 }
88
89 public function getNextBillingDateAttribute($value)
90 {
91 if (empty($value) || $value === '0000-00-00 00:00:00' || $value === '0000-00-00') {
92 return null;
93 }
94 return $value;
95 }
96
97 public function getCanceledAtAttribute($value)
98 {
99 if (empty($value) || $value === '0000-00-00 00:00:00' || $value === '0000-00-00') {
100 return null;
101 }
102 return $value;
103 }
104
105 public function getExpireAtAttribute($value)
106 {
107 if (empty($value) || $value === '0000-00-00 00:00:00' || $value === '0000-00-00') {
108 return null;
109 }
110 return $value;
111 }
112
113 public function meta()
114 {
115 return $this->hasMany(SubscriptionMeta::class, 'subscription_id', 'id');
116 }
117
118 public function customer(): BelongsTo
119 {
120 return $this->belongsTo(Customer::class, 'customer_id', 'id');
121 }
122
123 public function product(): BelongsTo
124 {
125 return $this->belongsTo(Product::class, 'product_id', 'ID');
126 }
127
128 public function variation(): BelongsTo
129 {
130 return $this->belongsTo(ProductVariation::class, 'variation_id');
131 }
132
133 public function labels(): MorphMany
134 {
135 return $this->morphMany(LabelRelationship::class, 'labelable');
136 }
137
138 public function license(): ?HasOne
139 {
140 if (!class_exists(License::class)) {
141 return null;
142 }
143 return $this->hasOne(License::class, 'subscription_id', 'id');
144 }
145
146 public function licenses(): ?HasMany
147 {
148 if (!class_exists(License::class)) {
149 return null;
150 }
151 return $this->hasMany(License::class, 'subscription_id', 'id');
152 }
153
154 public function transactions(): HasMany
155 {
156 return $this->hasMany(OrderTransaction::class, 'subscription_id', 'id');
157 }
158
159 public function billing_addresses(): HasMany
160 {
161 return $this->hasMany(CustomerAddresses::class, 'customer_id', 'customer_id')->where('type', 'billing');
162 }
163
164 public function getConfigAttribute($value)
165 {
166 if (is_string($value)) {
167 $decoded = json_decode($value, true);
168 return is_array($decoded) ? $decoded : $value;
169 }
170 return $value ?: [];
171 }
172
173 public function setConfigAttribute($value)
174 {
175 if (is_array($value)) {
176 $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
177 } else {
178 $value = '[]';
179 }
180
181 $this->attributes['config'] = $value;
182 }
183
184 /**
185 * Merge keys into the config blob under a row lock.
186 *
187 * Every writer of this column must go through here. `config` is a single JSON
188 * document written by the cancel path, both Stripe paths and both PayPal paths;
189 * a plain read-merge-write loses whichever concurrent write commits first, and a
190 * renewal landing during a payment-method switch is not a rare pairing.
191 *
192 * @param array $values keys to set; existing keys not named here survive
193 * @return array the merged config as committed
194 */
195 public function mergeConfig(array $values): array
196 {
197 $current = $this->config;
198 $current = is_array($current) ? $current : [];
199
200 if (!$values) {
201 return $current;
202 }
203
204 $db = static::query()->getConnection();
205 $db->beginTransaction();
206
207 try {
208 $locked = static::query()
209 ->where('id', $this->getKey())
210 ->lockForUpdate()
211 ->first();
212
213 if (!$locked) {
214 $db->rollBack();
215 return $current;
216 }
217
218 $stored = $locked->config;
219 $stored = is_array($stored) ? $stored : [];
220 $merged = array_merge($stored, $values);
221
222 // Query-builder update bypasses setConfigAttribute, so encode with the
223 // same flags the mutator uses.
224 static::query()
225 ->where('id', $this->getKey())
226 ->update([
227 'config' => json_encode($merged, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
228 ]);
229
230 $db->commit();
231 } catch (\Exception $e) {
232 $db->rollBack();
233 throw $e;
234 }
235
236 // Only `config` was written, so only `config` is clean now — a bare
237 // syncOriginal() would also mark the caller's unsaved edits as persisted
238 // and their next save() would drop them.
239 $this->setAttribute('config', $merged);
240 $this->syncOriginalAttribute('config');
241
242 return $merged;
243 }
244
245 /**
246 * Customer-facing display name. When the config['item_attributes'] snapshot
247 * resolves it returns the product name with the labeled combination
248 * ("Cake - Flavor: Vanilla | Weight: 500 g"); otherwise the raw item_name
249 * (simple / pre-snapshot subscriptions).
250 *
251 * Presentation-only — it does NOT override the item_name column, so internal
252 * and payment-gateway reads of $subscription->item_name keep the raw stored
253 * value. Use this only at customer-facing display sites.
254 *
255 * The model is passed to the resolver so attribute-display filters (e.g. for
256 * simple-variation / third-party attributes) get the item context they need.
257 *
258 * @return string
259 */
260 public function getDisplayItemNameAttribute()
261 {
262 $itemAttributes = Arr::get($this->config, 'item_attributes', []);
263
264 if (!$itemAttributes) {
265 return $this->item_name;
266 }
267
268 $attributeDisplayTitleString = AttributeHelper::getDisplayAttributesString($itemAttributes, $this, 'subscription');
269
270 if ($attributeDisplayTitleString === '') {
271 return $this->item_name;
272 }
273
274 // Standalone label has no separate product line, so prefix the product
275 // name: "<product> - <attributes>".
276 $postTitle = $this->product ? $this->product->post_title : '';
277
278 return $postTitle !== '' ? $postTitle . ' - ' . $attributeDisplayTitleString : $attributeDisplayTitleString;
279 }
280
281 /**
282 * Display label of the backing gateway ("Authorize.Net", "Cash"), the same
283 * source StatusHelper stamps into order.payment_method_title. Empty when the
284 * slug resolves to no registered gateway.
285 */
286 public function getPaymentMethodTitleAttribute(): string
287 {
288 $gateway = $this->resolveGateway();
289 if (!$gateway) {
290 return '';
291 }
292
293 $title = method_exists($gateway, 'getMeta')
294 ? $gateway->getMeta('title')
295 : Arr::get($gateway->meta(), 'title');
296
297 return (string) $title;
298 }
299
300 public function getUrlAttribute($value)
301 {
302 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
303 'vendor_subscription_id' => $this->vendor_subscription_id,
304 'payment_mode' => (new StoreSettings())->get('order_mode'),
305 'subscription' => $this
306 ]);
307
308 }
309
310
311 // use this to override the status of the subscription for any custom use case
312
313 /**
314 * current use case: If the orignal plan(product variation) has no trial days but the subscription status is 'trialing'
315 * it can happens upon discount applied / proration on plan change,
316 * use overriden status to show the correct status for customer
317 */
318 public function getOverriddenStatusAttribute($value)
319 {
320 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
321 return Status::SUBSCRIPTION_ACTIVE;
322 }
323
324 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()) {
325 return Status::SUBSCRIPTION_TRIALING;
326 }
327
328 return $this->status;
329 }
330
331 /**
332 * Auto-charge bookkeeping for system subscriptions (attempt count, next retry,
333 * last error, processing marker). Null for every other collection method —
334 * guarded before the meta lookup so manual/automatic subscriptions pay nothing.
335 */
336 public function getSystemChargeStateAttribute()
337 {
338 if ($this->collection_method !== 'system') {
339 return null;
340 }
341
342 $meta = $this->meta->where('meta_key', 'system_charge_state')->first();
343
344 if (!$meta) {
345 return null;
346 }
347
348 return is_string($meta->meta_value) ? json_decode($meta->meta_value, true) : $meta->meta_value;
349 }
350
351 public function getHasPendingSkipAttribute(): bool
352 {
353 return $this->hasPendingSkip();
354 }
355
356 public function getLastSkippedPeriodAttribute()
357 {
358 $skipped = $this->getMeta('skipped_periods', []);
359
360 if (!is_array($skipped) || empty($skipped)) {
361 return null;
362 }
363
364 return end($skipped) ?: null;
365 }
366
367 public function getBillingInfoAttribute($value)
368 {
369 $billingInfo = '';
370 $metaKey = 'active_payment_method';
371 $meta = $this->meta->where('meta_key', $metaKey)->first();
372 $billingInfo = $meta ? (is_string($meta->meta_value) ? json_decode($meta->meta_value, true) : $meta->meta_value) : [];
373 return $billingInfo;
374 }
375
376
377 public function getPaymentMethodText()
378 {
379 $info = Arr::get($this->billingInfo, 'details');
380 if (Arr::get($info, 'brand') && Arr::get($info, 'last_4')) {
381 return sprintf('%1$s ***%2$s', esc_html($info['brand']), esc_html($info['last_4']));
382 }
383
384 return Arr::get($info, 'method', '');
385 }
386
387 public function product_detail(): BelongsTo
388 {
389 return $this->belongsTo(ProductDetail::class, 'variation_id', 'id');
390 }
391
392 public function order(): BelongsTo
393 {
394 return $this->belongsTo(Order::class, 'parent_order_id', 'id');
395 }
396
397 public function getBusinessInfoAttribute(): array
398 {
399 if ($this->relationLoaded('order') && $this->order) {
400 return $this->order->getBusinessInfo();
401 }
402 return [];
403 }
404
405 public function getIsReverseChargeTaxOrderAttribute(): bool
406 {
407 if ($this->relationLoaded('order') && $this->order) {
408 return $this->order->isReverseChargeTaxOrder();
409 }
410 return false;
411 }
412
413 /**
414 * Get the currency for the subscription
415 *
416 * @return string
417 */
418 public function getCurrencyAttribute(): string
419 {
420 $currency = '';
421
422 if (empty($this->config)) {
423 // get from store settings
424 $currency = CurrencySettings::get('currency');
425 return strtoupper($currency);
426 }
427
428 $definedCurrency = Arr::get($this->config, 'currency', '');
429
430 if(empty($definedCurrency)) {
431 $currency = CurrencySettings::get('currency');
432 return strtoupper($currency);
433 }
434
435 return strtoupper($definedCurrency);
436 }
437
438 /**
439 * Get subscription payment info if available
440 *
441 * @return string
442 */
443 public function getPaymentInfoAttribute(): string
444 {
445 return $this->getSubscriptionInfo();
446 }
447
448 /**
449 * Get subscription permissions for the current user
450 * Returns what actions can be performed on this subscription
451 *
452 * @return array
453 */
454 public function getPermissionsAttribute(): array
455 {
456 $status = strtolower($this->status);
457 $hasVendorId = !empty($this->vendor_subscription_id);
458 $terminalStatuses = [
459 Status::SUBSCRIPTION_CANCELED,
460 Status::SUBSCRIPTION_EXPIRED,
461 Status::SUBSCRIPTION_COMPLETED,
462 ];
463
464 $canEdit = $this->usesRenewalEngine() && !in_array($status, $terminalStatuses);
465 $canCancel = !in_array($status, $terminalStatuses);
466
467 // One open-invoice lookup shared by the invoice actions below. Only runs
468 // for store-billed subscriptions in states where any of them can apply.
469 $hasOpenInvoice = false;
470 $chargeableStatuses = [
471 Status::SUBSCRIPTION_ACTIVE,
472 Status::SUBSCRIPTION_TRIALING,
473 Status::SUBSCRIPTION_PAST_DUE,
474 Status::SUBSCRIPTION_EXPIRED,
475 ];
476 if ($this->usesRenewalEngine() && in_array($status, $chargeableStatuses) && $this->parent_order_id) {
477 $hasOpenInvoice = Order::query()
478 ->where('parent_id', $this->parent_order_id)
479 ->where('type', Status::ORDER_TYPE_RENEWAL)
480 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
481 ->exists();
482 }
483
484 $canManageRenewal = $this->usesRenewalEngine()
485 && in_array($status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
486 && $this->next_billing_date
487 && !$hasOpenInvoice;
488
489 // Admin "Charge Now": system subscription with an open invoice whose charge
490 // is not currently settling at the gateway (processing marker).
491 $chargeState = $this->isSystem() ? ($this->system_charge_state ?: []) : [];
492 $canChargeNow = $this->isSystem()
493 && $hasOpenInvoice
494 && in_array($status, $chargeableStatuses)
495 && Arr::get($chargeState, 'status') !== 'processing';
496
497 return [
498 'canEdit' => $canEdit,
499 'canEditVendorIds' => $this->canEditVendorIds(),
500 'canVerifyVendorIds' => $this->canVerifyVendorIds(),
501 'canPause' => $this->canPause(),
502 'canResume' => $this->canResume(),
503 'canFetch' => !$this->usesRenewalEngine() && $hasVendorId,
504 'canCancel' => $canCancel,
505 // Admin one-click reactivate is for store-billed subscriptions only (the REST
506 // endpoint rejects automatic); automatic reactivation runs through the gateway
507 // URL flow, gated by canReactivate().
508 'canAdminReactivate' => $this->usesRenewalEngine() && $this->canReactivate(),
509 'canCreateRenewal' => $canManageRenewal,
510 'canSkipRenewal' => $canManageRenewal && !$this->hasPendingSkip(),
511 'canChargeNow' => $canChargeNow,
512 // Surfaced in the Edit modal: an already-issued renewal invoice is
513 // re-synced to the edited amount when it exists.
514 'hasPendingRenewal' => $hasOpenInvoice,
515 ];
516 }
517
518 /**
519 * Check if this is a manual subscription
520 *
521 * @return bool
522 */
523 public function isManual(): bool
524 {
525 return $this->collection_method === 'manual';
526 }
527
528 /**
529 * Check if this is a system (auto-charged, store-billed) subscription
530 *
531 * @return bool
532 */
533 public function isSystem(): bool
534 {
535 return $this->collection_method === 'system';
536 }
537
538 /**
539 * Check if this is a gateway-billed (automatic) subscription
540 *
541 * @return bool
542 */
543 public function isAutomatic(): bool
544 {
545 return $this->collection_method === Status::SUBSCRIPTION_METHOD_AUTOMATIC;
546 }
547
548 /**
549 * Manual and system subscriptions are both billed by FluentCart's invoice
550 * engine (renewal invoices, overdue escalation, admin invoice actions).
551 * System additionally auto-charges a stored token per invoice.
552 *
553 * @return bool
554 */
555 public function usesRenewalEngine(): bool
556 {
557 return in_array($this->collection_method, ['manual', 'system'], true);
558 }
559
560 /**
561 * Store-billed (manual/system) with a future due date has nothing to charge yet —
562 * reactivation should flip the subscription active locally instead of checkout.
563 *
564 * @return bool
565 */
566 public function shouldSubscriptionActiveLocally(): bool
567 {
568 return $this->usesRenewalEngine() && $this->next_billing_date && strtotime($this->next_billing_date) > time();
569 }
570
571 /**
572 * Helper method to get subscription info
573 *
574 * @return string
575 */
576 private function getSubscriptionInfo(): string
577 {
578 $subscriptionInfo = '';
579
580 $otherInfo = [
581 'repeat_interval' => $this->billing_interval ?? '',
582 'times' => $this->bill_times ?? 0,
583 'recurring_total' => $this->recurring_total ?? 0,
584 'trial_days' => $this->trial_days ?? 0,
585 ];
586
587 $recurringTotal = $this->recurring_total ?? 0;
588
589 if ($schedule = SubscriptionHelper::getBillingSchedule($this)) {
590 return Helper::generateScheduleSubscriptionInfo($schedule, $otherInfo, $recurringTotal, $this->currency) ?? '';
591 }
592
593 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal, $this->currency) ?? '';
594 }
595
596 public function addLog($title, $description = '', $type = 'info', $by = '')
597 {
598 $logData = [
599 'module_type' => 'FluentCart\App\Models\Subscription',
600 'module_id' => $this->id,
601 'module_name' => 'subscription',
602 ];
603
604 if ($by) {
605 $logData['created_by'] = $by;
606 }
607
608 fluent_cart_add_log($title, $description, $type, $logData);
609 }
610
611 public function getDownloads()
612 {
613 if (!$this->variation_id || $this->status !== Status::SUBSCRIPTION_ACTIVE) {
614 return [];
615 }
616
617 $variationTitles = ProductVariation::pluck('variation_title', 'id');
618 $productTitles = Product::pluck('post_title', 'ID');
619
620 $downloads = ProductDownload::query()->where('post_id', $this->product_id)->get();
621
622 $downloads->filter(function ($download) {
623 if (empty($download->product_variation_id)) {
624 return true;
625 }
626 $ids = $download->product_variation_id;
627
628 if (!is_array($ids)) {
629 return true;
630 }
631 return empty($ids) || in_array($this->variation_id, $ids);
632 });
633
634 return $downloads
635 ->map(function ($download) use ($variationTitles, $productTitles) {
636 $variationIds = $download->product_variation_id;
637
638 $download->product_title = $productTitles[$download->post_id] ?? '';
639 $download->variation_ids = $variationIds;
640 $download->variation_titles = array_map(
641 fn($id) => $variationTitles[$id] ?? null,
642 $variationIds
643 );
644 unset($download->product_variation_id);
645 return $download;
646 });
647 }
648
649 public function getMeta($metaKey, $default = null)
650 {
651 $exist = SubscriptionMeta::query()
652 ->where('subscription_id', $this->id)
653 ->where('meta_key', $metaKey)
654 ->first();
655
656 if ($exist) {
657 return $exist->meta_value;
658 }
659
660 return $default;
661 }
662
663 public function updateMeta($metaKey, $metaValue)
664 {
665 $exist = SubscriptionMeta::query()
666 ->where('subscription_id', $this->id)
667 ->where('meta_key', $metaKey)
668 ->first();
669
670 if ($exist) {
671 $exist->meta_value = $metaValue;
672 $exist->save();
673 } else {
674 SubscriptionMeta::query()->create([
675 'subscription_id' => $this->id,
676 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
677 'meta_key' => $metaKey,
678 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
679 'meta_value' => $metaValue
680 ]);
681 }
682
683 return true;
684 }
685
686 public function deleteMeta($metaKey)
687 {
688 return SubscriptionMeta::query()
689 ->where('subscription_id', $this->id)
690 ->where('meta_key', $metaKey)
691 ->delete();
692 }
693
694 public function getLatestTransaction()
695 {
696 return OrderTransaction::query()
697 ->where('subscription_id', $this->id)
698 ->orderBy('id', 'DESC')
699 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
700 ->first();
701 }
702
703 public function canUpgrade()
704 {
705 return Meta::query()->where('meta_key', 'variant_upgrade_path')
706 ->where('object_id', $this->variation_id)
707 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
708 }
709
710 /**
711 * The gateway backing this subscription, or null when there is not one.
712 *
713 * `App::gateway()` returns the GatewayManager when its argument is null —
714 * that is how `App::gateway()` with no argument is meant to work, but
715 * `current_payment_method` is nullable, so a subscription with no payment
716 * method resolves to the manager too. The manager is a truthy object, so
717 * every `if (!$gateway)` guard in this class waved it through, and the next
718 * line read `$gateway->supportedFeatures` as null.
719 *
720 * `in_array($needle, null)` is a TypeError on PHP 8, thrown from
721 * `getPermissionsAttribute()` — an `$appends` entry — so it fires while
722 * SERIALIZING. One subscription row with a blank payment method therefore
723 * took down the entire subscriptions list response, not just its own row.
724 *
725 * Resolve through here rather than calling `App::gateway()` directly.
726 *
727 * The instanceof is against PaymentGatewayInterface — the manager's
728 * registration contract — NOT AbstractPaymentGateway, so a third-party
729 * gateway implementing the interface directly still resolves. The only
730 * object it rejects is the GatewayManager itself, which does not implement
731 * the interface.
732 *
733 * @return PaymentGatewayInterface|null
734 */
735 private function resolveGateway(): ?PaymentGatewayInterface
736 {
737 if (empty($this->current_payment_method)) {
738 return null;
739 }
740
741 // The one direct App::gateway() call in this class.
742 $gateway = App::gateway($this->current_payment_method);
743
744 return $gateway instanceof PaymentGatewayInterface ? $gateway : null;
745 }
746
747 /**
748 * The `switch_payment_method` entry of `supportedFeatures`, or [] when the
749 * gateway does not declare one.
750 *
751 * Unlike the flat feature flags this is a KEYED entry carrying config
752 * (`supported_gateways`), so `has()` cannot answer it — it needs the raw
753 * `supportedFeatures` property, which only AbstractPaymentGateway carries.
754 * An interface-only gateway therefore reports no switch support rather
755 * than triggering an undefined-property read.
756 *
757 * @return array
758 */
759 private function switchPaymentConfig(): array
760 {
761 $gateway = $this->resolveGateway();
762
763 if (!$gateway instanceof AbstractPaymentGateway) {
764 return [];
765 }
766
767 return (array) Arr::get($gateway->supportedFeatures, 'switch_payment_method', []);
768 }
769
770 public function canUpdatePaymentMethod()
771 {
772 $gateway = $this->resolveGateway();
773 if (!$gateway || !$gateway->has('card_update')) {
774 return false;
775 }
776
777 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
778 }
779
780 public function canSwitchPaymentMethod()
781 {
782 // Switching moves the subscription onto ANOTHER gateway's vendor subscription
783 // (see PayPal SubscriptionManager::switchPaymentMethod — it creates a live
784 // PayPal subscription). A store-billed subscription is already owned by the
785 // invoice engine, so a vendor subscription would bill it a second time. The
786 // customer changes the card on file instead (canUpdatePaymentMethod).
787 if ($this->usesRenewalEngine()) {
788 return false;
789 }
790
791 if (!$this->switchPaymentConfig()) {
792 return false;
793 }
794
795 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
796 }
797
798 public function switchablePaymentMethods()
799 {
800 if (!$this->canSwitchPaymentMethod()) {
801 return [];
802 }
803
804 return Arr::get($this->switchPaymentConfig(), 'supported_gateways', []);
805 }
806
807 public function canPause()
808 {
809 // Store-billed (manual/system) subscriptions can always be paused
810 // (unless already paused/canceled/expired)
811 if ($this->usesRenewalEngine()) {
812 return in_array($this->status, [
813 Status::SUBSCRIPTION_ACTIVE,
814 Status::SUBSCRIPTION_TRIALING,
815 Status::SUBSCRIPTION_PAST_DUE,
816 Status::SUBSCRIPTION_EXPIRING
817 ]);
818 }
819
820 // Automatic subscriptions require gateway support
821 $gateway = $this->resolveGateway();
822
823 if (!$gateway) {
824 return false;
825 }
826
827 // Check if gateway supports pause
828 if (!$gateway->has('pause_subscription')) {
829 return false;
830 }
831
832 // Default behavior for automatic subscriptions
833 return in_array($this->status, [
834 Status::SUBSCRIPTION_ACTIVE,
835 Status::SUBSCRIPTION_TRIALING
836 ]) && !in_array($this->status, [
837 Status::SUBSCRIPTION_PAUSED,
838 Status::SUBSCRIPTION_CANCELED,
839 Status::SUBSCRIPTION_EXPIRED,
840 Status::SUBSCRIPTION_COMPLETED
841 ]);
842 }
843
844 /**
845 * A skip is pending when the current upcoming period was reached by an admin
846 * skip that has not yet elapsed — next_billing_date still equals the value the
847 * last skip set. Blocks stacking another skip onto the same pending window.
848 *
849 * @return bool
850 */
851 public function hasPendingSkip(): bool
852 {
853 if (!$this->next_billing_date) {
854 return false;
855 }
856
857 $skippedTo = $this->getMeta('pending_skip_until');
858
859 if (!$skippedTo) {
860 return false;
861 }
862
863 return $skippedTo === $this->next_billing_date
864 && strtotime($this->next_billing_date) > time();
865 }
866
867 public function canResume()
868 {
869 // Store-billed (manual/system) subscriptions can be resumed from paused state
870 if ($this->usesRenewalEngine()) {
871 return $this->status === Status::SUBSCRIPTION_PAUSED;
872 }
873
874
875 $gateway = $this->resolveGateway();
876
877 if (!$gateway) {
878 return false;
879 }
880
881 if (!$gateway->has('resume_subscription')) {
882 return false;
883 }
884
885 // Default behavior
886 return $this->status === Status::SUBSCRIPTION_PAUSED;
887 }
888
889 public function pauseSubscription($reason = '')
890 {
891 return SubscriptionService::pauseSubscription($this, $reason);
892 }
893
894 public function resumeSubscription($reason = '')
895 {
896 return SubscriptionService::resumeSubscription($this, $reason);
897 }
898
899 public function canUpdateDetails()
900 {
901 // Only store-billed (manual/system) subscriptions can be fully edited by
902 // admin — edits to a system subscription take effect on its next invoice.
903 return $this->usesRenewalEngine();
904 }
905
906 /**
907 * Vendor identifiers are the inverse case of canUpdateDetails(): only a
908 * gateway-billed subscription has them, and correcting them is the one
909 * admin write an automatic subscription accepts. Billing fields stay
910 * gateway-owned.
911 *
912 * Off by default — this is a migration/support repair tool, and the column it
913 * writes is what gateway webhooks resolve on. Enable with:
914 *
915 * add_filter('fluent_cart/subscription/vendor_id_editing_enabled', '__return_true');
916 *
917 * @return bool
918 */
919 public function canEditVendorIds(): bool
920 {
921 if (!apply_filters('fluent_cart/subscription/vendor_id_editing_enabled', false)) {
922 return false;
923 }
924
925 if (!$this->isAutomatic() || !$this->current_payment_method) {
926 return false;
927 }
928
929 // `expired` and `canceled` stay editable: a subscription usually lands there
930 // *because* the id was wrong (webhooks resolved to nothing), so those are the
931 // states the repair is needed in most. Sync from gateway has no status gate
932 // either. `completed` is a real end of term, not a lookup failure.
933 return strtolower($this->status) !== Status::SUBSCRIPTION_COMPLETED;
934 }
935
936 /**
937 * Whether the gateway backing this subscription can look a candidate id up
938 * before it is saved. Editing does not depend on this — a gateway with no
939 * lookup still accepts a correction, it just cannot preview it.
940 *
941 * @return bool
942 */
943 public function canVerifyVendorIds(): bool
944 {
945 if (!$this->canEditVendorIds()) {
946 return false;
947 }
948
949 $gateway = App::gateway($this->current_payment_method);
950
951 return $gateway && $gateway->has('subscriptions') && $gateway->has('verify_vendor_ids');
952 }
953
954 /**
955 * Update subscription details (for manual subscriptions)
956 *
957 * Allowed fields for manual subscriptions:
958 * - recurring_total: Update the next invoice/payment amount (in cents)
959 * - bill_times: Update the number of billing cycles (0 = unlimited)
960 * - billing_interval: Change billing frequency (daily, weekly, monthly, etc.)
961 * - expire_at: Update expiration date
962 * - trial_days: Update trial period
963 * - next_billing_date: Update next billing date
964 *
965 * @param array $data
966 * @return true|\WP_Error
967 */
968 public function updateSubscription(array $data)
969 {
970 return SubscriptionService::updateSubscription($this, $data);
971 }
972
973 /**
974 * Whether this subscription can be reactivated.
975 *
976 * Status-based for BOTH manual and automatic subscriptions — no gateway
977 * supportedFeatures branch on purpose. Manual reactivation is a local status
978 * flip; automatic reactivation runs through the Pro re-checkout flow
979 * (SubscriptionRenewalHandler builds an instant cart and the customer pays
980 * again), which works with any gateway. Gating on a gateway feature here
981 * would hide the customer-facing reactivate URL for Stripe/PayPal/etc.
982 *
983 * @return bool
984 */
985 public function canReactivate()
986 {
987 if (!App::isProActive()) {
988 return false;
989 }
990
991 if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) {
992 return false;
993 }
994
995 // Paused is intentionally excluded — a paused subscription resumes (see
996 // canResume()); reactivation is for terminal/lapsed states only.
997 $canReactivate = in_array($this->status, [
998 Status::SUBSCRIPTION_CANCELED,
999 Status::SUBSCRIPTION_FAILING,
1000 Status::SUBSCRIPTION_EXPIRED,
1001 Status::SUBSCRIPTION_EXPIRING,
1002 Status::SUBSCRIPTION_PAST_DUE,
1003 ]);
1004
1005 return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
1006 'subscription' => $this
1007 ]);
1008 }
1009
1010 /**
1011 * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
1012 * @return bool
1013 */
1014 public function canReactive()
1015 {
1016 return $this->canReactivate();
1017 }
1018
1019 /**
1020 * These links are minted in email and webhook contexts, where there is no
1021 * current user. A wp_create_nonce() token bound to that user-less request
1022 * stops verifying the moment the recipient logs in to act on it, so the link
1023 * broke for the one journey it exists to serve. Authorization for the
1024 * endpoint is the subscription-ownership check on the handling side, which
1025 * a nonce never provided; the uuid alone is inert to anyone else.
1026 */
1027 public function getReactivateUrl()
1028 {
1029 if (!$this->canReactivate()) {
1030 return '';
1031 }
1032
1033 return add_query_arg([
1034 'fluent-cart' => 'reactivate-subscription',
1035 'subscription_hash' => $this->uuid,
1036 ], home_url('/'));
1037 }
1038
1039 public function getReactivateUrlAttribute()
1040 {
1041 return $this->getReactivateUrl();
1042 }
1043
1044 public function getViewUrl($type = 'customer')
1045 {
1046 if ($type == 'customer') {
1047 return TemplateService::getCustomerProfileUrl('subscription/' . $this->uuid);
1048 }
1049
1050 return TemplateService::getAdminUrl('subscriptions/' . $this->id . '/view');
1051
1052 }
1053
1054 public function hasAccessValidity()
1055 {
1056 $validAccessStatuses = [
1057 Status::SUBSCRIPTION_ACTIVE,
1058 Status::SUBSCRIPTION_TRIALING,
1059 Status::SUBSCRIPTION_COMPLETED
1060 ];
1061
1062 if (in_array($this->status, $validAccessStatuses)) {
1063 return true;
1064 }
1065
1066 // Past-due/expiring/failing keep access while the unpaid invoice is inside its
1067 // dunning grace window; checkAndExpireSubscriptions() flips them to expired past that.
1068 if (in_array($this->status, [Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_FAILING])) {
1069 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
1070 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
1071
1072 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
1073 }
1074
1075 $invalidStatuses = [
1076 Status::SUBSCRIPTION_EXPIRED,
1077 Status::SUBSCRIPTION_INTENDED,
1078 Status::SUBSCRIPTION_PENDING
1079 ];
1080
1081 if (in_array($this->status, $invalidStatuses)) {
1082 return false;
1083 }
1084
1085 $nextBillingDate = $this->next_billing_date;
1086
1087 if (!$nextBillingDate) {
1088 $nextBillingDate = $this->guessNextBillingDate();
1089 }
1090
1091 // now check the dates
1092 if (strtotime($nextBillingDate) > time()) {
1093 return true;
1094 }
1095
1096 return false;
1097 }
1098
1099 public function reSyncFromRemote()
1100 {
1101 if ($gateway = $this->resolveGateway()) {
1102 if ($gateway->has('subscriptions')) {
1103 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
1104 }
1105 }
1106
1107 return new \WP_Error('invalid_payment_method', __('This payment method does not support remote resync', 'fluent-cart'));
1108 }
1109
1110 public function cancelRemoteSubscription($args = [])
1111 {
1112 $args = wp_parse_args($args, [
1113 'reason' => '',
1114 'fire_hooks' => true,
1115 'note' => '',
1116 'effective_from' => ''
1117 ]);
1118
1119 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
1120 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
1121 }
1122
1123 $gateway = $this->resolveGateway();
1124
1125 // No vendor subscription (store-billed, or a vendor id that never landed) —
1126 // nothing to cancel at the gateway.
1127 if (!$this->vendor_subscription_id) {
1128 $vendorCanceled = null;
1129 $updateData = [
1130 'canceled_at' => gmdate('Y-m-d H:i:s', time())
1131 ];
1132 } elseif ($gateway && $gateway->has('subscriptions')) {
1133 $cancelArgs = [
1134 'subscription_id' => $this->id,
1135 'parent_order_id' => $this->parent_order_id,
1136 'mode' => $this->order->mode,
1137 ];
1138 $effectiveFrom = Arr::get($args, 'effective_from', '');
1139 if ($effectiveFrom) {
1140 $cancelArgs['effective_from'] = $effectiveFrom;
1141 }
1142 $vendorCanceled = $gateway->subscriptions->cancel($this->vendor_subscription_id, $cancelArgs);
1143
1144 if (is_wp_error($vendorCanceled)) {
1145 return $vendorCanceled;
1146 }
1147
1148 $updateData = array_filter($vendorCanceled);
1149 } else {
1150 // Vendor subscription exists but this gateway cannot cancel it — it stays live.
1151 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
1152 $updateData = [
1153 'canceled_at' => gmdate('Y-m-d H:i:s', time())
1154 ];
1155 }
1156
1157 $updateData['status'] = Status::SUBSCRIPTION_CANCELED;
1158
1159 if (empty($updateData['canceled_at']) && !$this->canceled_at) {
1160 $updateData['canceled_at'] = gmdate('Y-m-d H:i:s', time());
1161 }
1162
1163 if ($this->status === Status::SUBSCRIPTION_COMPLETED) {
1164 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
1165 $updateData['canceled_at'] = NULL;
1166 }
1167
1168 if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
1169 $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
1170 }
1171
1172 // A completed (EOT) subscription has no upcoming billing — the immediate-cancel
1173 // date above must not resurrect one (SubscriptionEOT cancels remote subscriptions
1174 // with effective_from=immediately after syncSubscriptionStates nulled the date).
1175 if (Arr::get($updateData, 'status') === Status::SUBSCRIPTION_COMPLETED) {
1176 $updateData['next_billing_date'] = NULL;
1177 }
1178
1179 $this->fill($updateData);
1180 $this->save();
1181
1182 if ($args['reason']) {
1183 $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1184 }
1185
1186 $note = $args['note'];
1187
1188 if (!$note) {
1189 $note = 'on customer request';
1190 }
1191
1192 // Single cancel chokepoint — void open renewals, clear reminders, email once.
1193 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
1194 SubscriptionService::finalizeCancellation($this, $note, (bool) $args['fire_hooks']);
1195 }
1196
1197 if ($args['note']) {
1198 $this->order->note = $note;
1199 $this->order->save();
1200 }
1201
1202 return [
1203 'subscription' => $this,
1204 'vendor_result' => $vendorCanceled
1205 ];
1206 }
1207
1208
1209 public function getCurrentRenewalAmount()
1210 {
1211 $currentRecurringAmount = (int)Arr::get($this->config, 'current_renewal_amount');
1212 if ($currentRecurringAmount) {
1213 return $currentRecurringAmount;
1214 }
1215
1216 return $this->recurring_total;
1217 }
1218
1219 /**
1220 * Cycles the remote (vendor) plan must bill at INITIAL checkout.
1221 * With a simulated trial the first installment is already collected outside
1222 * the remote recurring cycles (one-time charge, paid/free trial cycle), so
1223 * the remote plan only needs bill_times - 1.
1224 *
1225 * Only valid at initial checkout — do NOT use for renewals/reactivation
1226 * (payment-method switching also sets is_trial_days_simulated; renewal flows
1227 * must use getRequiredBillTimes() which is bill_count based).
1228 *
1229 * @return int 0 means unlimited
1230 */
1231 public function getInitialRemoteBillTimes()
1232 {
1233 $billTimes = (int)$this->bill_times;
1234
1235 if (!$billTimes) {
1236 return 0;
1237 }
1238
1239 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') === 'yes') {
1240 // never return 0 here — 0 means unlimited to the gateways
1241 $billTimes = max(1, $billTimes - 1);
1242 }
1243
1244 return $billTimes;
1245 }
1246
1247 public function getRequiredBillTimes()
1248 {
1249 $billTimes = (int)$this->bill_times;
1250
1251 if ($billTimes > 0) {
1252 $billTimes = $billTimes - $this->bill_count;
1253 if ($billTimes <= 0) {
1254 $transacactionsCount = $this->calculateBillCount();
1255
1256 if ($transacactionsCount != $this->bill_count) {
1257 $this->bill_count = $transacactionsCount;
1258 $this->save();
1259 }
1260
1261 $revisedBillTimes = $this->bill_times - $this->bill_count;
1262 if ($revisedBillTimes <= 0) {
1263 return -1;
1264 }
1265
1266 return $revisedBillTimes;
1267 }
1268 }
1269
1270 return $billTimes;
1271 }
1272
1273 /**
1274 * Canonical bill_count formula. Every writer of bill_count must go through
1275 * this — a separate ad hoc count (e.g. StripeGateway\SubscriptionsManager
1276 * previously) silently drops the offset/deduction corrections below and
1277 * reports a wrong count until the next recompute.
1278 *
1279 * total > 0 CHARGE transactions linked to this subscription, adjusted for
1280 * the two one-time corrections decided at creation (see
1281 * CheckoutProcessor::syncInitialCycleCounting):
1282 * - billed_cycles_offset: free simulated-trial first cycle consumed a
1283 * cycle without producing a total > 0 transaction.
1284 * - billed_cycles_deduction: real-trial signup-fee-only charge is a
1285 * total > 0 transaction but isn't a billed cycle.
1286 */
1287 public function calculateBillCount()
1288 {
1289 $transacactionsCount = OrderTransaction::query()
1290 ->where('subscription_id', $this->id)
1291 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1292 ->where('status', Status::TRANSACTION_SUCCEEDED)
1293 ->where('total', '>', 0)
1294 ->count();
1295
1296 $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
1297 foreach ((array)$earlyPaymentHistory as $earlyPayment) {
1298 $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
1299 if ($paidCount > 1) {
1300 $transacactionsCount += ($paidCount - 1);
1301 }
1302 }
1303
1304 $transacactionsCount += (int) $this->getMeta('billed_cycles_offset', 0);
1305 $transacactionsCount -= (int) $this->getMeta('billed_cycles_deduction', 0);
1306
1307 return $transacactionsCount;
1308 }
1309
1310 /**
1311 * Installment / split-pay plan: a finite-term subscription (a lifetime
1312 * license paid off in a fixed number of charges), as opposed to an
1313 * open-ended recurring subscription. The canonical structural signal is
1314 * bill_times > 0 (0 = infinite/open-ended). Reused across analytics,
1315 * filters and lifecycle handling — do NOT reintroduce title-string
1316 * ("Split") matching, which the data does not reliably carry.
1317 *
1318 * @return bool
1319 */
1320 public function isInstallment()
1321 {
1322 return (int) $this->bill_times > 0;
1323 }
1324
1325 /**
1326 * Installments still owed: 0 for open-ended plans, or once the term is
1327 * fully paid.
1328 *
1329 * @return int
1330 */
1331 public function installmentsRemaining()
1332 {
1333 if (!$this->isInstallment()) {
1334 return 0;
1335 }
1336
1337 return max(0, (int) $this->bill_times - (int) $this->bill_count);
1338 }
1339
1340 /**
1341 * Has a finite installment plan collected every scheduled charge (end of
1342 * term)? Open-ended plans never reach term end.
1343 *
1344 * @return bool
1345 */
1346 public function hasReachedTermEnd()
1347 {
1348 return $this->isInstallment() && (int) $this->bill_count >= (int) $this->bill_times;
1349 }
1350
1351 /**
1352 * Full committed price of an installment contract: recurring_total x
1353 * bill_times, in cents. 0 for open-ended plans (no fixed total). This is
1354 * the per-row form of the SUM(recurring_total * bill_times) used by the
1355 * subscription analytics aggregate.
1356 *
1357 * @return int
1358 */
1359 public function totalContractValue()
1360 {
1361 if (!$this->isInstallment()) {
1362 return 0;
1363 }
1364
1365 return (int) $this->recurring_total * (int) $this->bill_times;
1366 }
1367
1368 /**
1369 * Filter by plan type: 'installment' (finite term, bill_times > 0),
1370 * 'recurring' (open-ended, bill_times = 0) or anything else (no filter).
1371 * The bill_times threshold is kept identical to isInstallment() so the SQL
1372 * and PHP definitions never drift apart.
1373 */
1374 public function scopeOfPlanType($query, $planType)
1375 {
1376 if ($planType === 'installment') {
1377 return $query->where('bill_times', '>', 0);
1378 }
1379 if ($planType === 'recurring') {
1380 return $query->where('bill_times', '<=', 0);
1381 }
1382
1383 return $query;
1384 }
1385
1386 /**
1387 * Whether a lapsed/canceled subscription still has unexpired paid time to
1388 * credit back on reactivation. Deliberately NOT hasAccessValidity() — that
1389 * method answers "can the customer access content right now" and its status
1390 * list is free to evolve for that purpose alone. This is its own copy so a
1391 * future access-only change (e.g. a new status added for content gating)
1392 * can't silently change how much reactivation trial credit gets granted.
1393 *
1394 * @return bool
1395 */
1396 public function hasReactivationTrialCredit(): bool
1397 {
1398 $validStatuses = [
1399 Status::SUBSCRIPTION_ACTIVE,
1400 Status::SUBSCRIPTION_TRIALING,
1401 Status::SUBSCRIPTION_COMPLETED
1402 ];
1403
1404 if (in_array($this->status, $validStatuses)) {
1405 return true;
1406 }
1407
1408 // No grace-period math here on purpose: past_due/expiring/failing fall through
1409 // to the plain next_billing_date > now check below. If that date is still
1410 // future, credit is granted same as any other status; if it's past, this
1411 // returns false the same way the grace window would eventually clamp to via
1412 // getReactivationTrialDays()'s <=1 floor — without a redundant grace-days
1413 // lookup either way.
1414
1415 $invalidStatuses = [
1416 Status::SUBSCRIPTION_EXPIRED,
1417 Status::SUBSCRIPTION_INTENDED,
1418 Status::SUBSCRIPTION_PENDING
1419 ];
1420
1421 if (in_array($this->status, $invalidStatuses)) {
1422 return false;
1423 }
1424
1425 $nextBillingDate = $this->next_billing_date;
1426
1427 if (!$nextBillingDate) {
1428 $nextBillingDate = $this->guessNextBillingDate();
1429 }
1430
1431 return strtotime($nextBillingDate) > time();
1432 }
1433
1434 public function getReactivationTrialDays()
1435 {
1436 if (!$this->hasReactivationTrialCredit()) {
1437 return 0;
1438 }
1439
1440 $lastPaidTransaction = OrderTransaction::query()
1441 ->where('subscription_id', $this->id)
1442 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1443 ->where('status', Status::TRANSACTION_SUCCEEDED)
1444 ->where('total', '>', 0)
1445 ->orderBy('id', 'DESC')
1446 ->first();
1447
1448 if ($lastPaidTransaction && $lastPaidTransaction->getMaxRefundableAmount() === 0) {
1449 return 0;
1450 }
1451
1452 $nextBillingDate = $this->guessNextBillingDate(true);
1453
1454 // @todo: Temporary fix for next billing date mismatch issue from migration
1455
1456 // $nextBillingDate = $this->next_billing_date;
1457 //
1458 // if (!$nextBillingDate) {
1459 // $nextBillingDate = $this->guessNextBillingDate(true);
1460 // }
1461
1462 $nextBillingDate = strtotime($nextBillingDate);
1463
1464 $currentDate = time();
1465 $trialDays = floor(($nextBillingDate - $currentDate) / DAY_IN_SECONDS); // Convert seconds to days
1466
1467 if ($trialDays <= 1) {
1468 $trialDays = 0; // Ensure trial days are not negative
1469 }
1470
1471 return $trialDays;
1472 }
1473
1474
1475 public function guessNextBillingDate($forced = false)
1476 {
1477 if ($this->next_billing_date && !$forced) {
1478 return $this->next_billing_date;
1479 }
1480
1481 // preserve it during reactivation to maintain the billing cycle
1482 if ($this->next_billing_date && $this->status === Status::SUBSCRIPTION_CANCELED) {
1483 return $this->next_billing_date;
1484 }
1485
1486 // we have to create a next billing date somehow!!
1487 $theLastOrder = Order::query()
1488 ->where(function ($q) {
1489 $q->where('parent_id', $this->parent_order_id)
1490 ->orWhere('id', $this->parent_order_id);
1491 })
1492 ->orderBy('id', 'DESC')
1493 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1494 ->first();
1495
1496 if ($theLastOrder) {
1497 $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1498
1499 if ($theLastOrder->type == 'renewal') {
1500 $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
1501 } else {
1502 if ($this->trial_days) {
1503 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($paidAnchor) + (int)($this->trial_days) * DAY_IN_SECONDS);
1504 } else {
1505 $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
1506 }
1507 }
1508 } else {
1509 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
1510 }
1511
1512 return $nextBillingDate;
1513 }
1514
1515 /**
1516 * Check and expire subscriptions past their grace period
1517 *
1518 * This method is called by the hourly scheduler to automatically expire
1519 * subscriptions that have missed payments and are past their grace period.
1520 *
1521 * Processes all candidates in batches to avoid memory issues.
1522 * The query example works as follows:
1523 * SELECT * FROM subscriptions WHERE
1524 status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
1525 AND next_billing_date IS NOT NULL
1526 AND id > 0 -- last processed ID for batch cursor
1527 AND next_billing_date < DATE_SUB(
1528 '2026-02-17 10:00:00',
1529 INTERVAL (
1530 CASE billing_interval
1531 WHEN 'daily' THEN 1
1532 WHEN 'weekly' THEN 3
1533 WHEN 'monthly' THEN 7
1534 WHEN 'quarterly' THEN 15
1535 WHEN 'half_yearly' THEN 15
1536 WHEN 'yearly' THEN 15
1537 ELSE 7
1538 END
1539 ) DAY
1540 )
1541 ORDER BY id ASC
1542 LIMIT 100;
1543 *
1544 * @param int $batchSize Number of subscriptions to process per batch
1545 * @return array Statistics about processed subscriptions
1546 */
1547 public static function checkAndExpireSubscriptions($batchSize = 100)
1548 {
1549 $stats = [
1550 'checked' => 0,
1551 'validity_expired' => 0,
1552 'batches' => 0,
1553 'expired_ids' => [],
1554 ];
1555
1556 $lastId = 0;
1557
1558 do {
1559 $currentTime = time();
1560 $now = gmdate('Y-m-d H:i:s', $currentTime);
1561
1562 $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays();
1563
1564 $cutoffDates = [];
1565 foreach ($gracePeriodDays as $interval => $days) {
1566 $cutoffDates[$interval] = gmdate('Y-m-d H:i:s', $currentTime - ((int)$days * DAY_IN_SECONDS));
1567 }
1568
1569 // Fallback cutoff for unknown/null billing intervals.
1570 $defaultGraceDays = 7;
1571 $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
1572 $knownIntervals = array_keys($cutoffDates);
1573
1574 // Include canceled subscriptions to check if validity is yet to expired
1575 // Exclude store-billed (manual/system) subscriptions — their expiry is
1576 // handled by the invoice-based overdue flow
1577 $subscriptions = Subscription::query()
1578 ->whereIn('status', [
1579 Status::SUBSCRIPTION_ACTIVE,
1580 Status::SUBSCRIPTION_TRIALING,
1581 Status::SUBSCRIPTION_CANCELED,
1582 Status::SUBSCRIPTION_EXPIRING,
1583 Status::SUBSCRIPTION_FAILING,
1584 Status::SUBSCRIPTION_PAST_DUE
1585 ])
1586 ->whereNotIn('collection_method', ['manual', 'system'])
1587 ->whereNotNull('next_billing_date')
1588 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
1589 ->where('id', '>', $lastId)
1590 ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
1591 $query->where(function ($subQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1592 $subQuery->whereIn('status', [
1593 Status::SUBSCRIPTION_ACTIVE,
1594 Status::SUBSCRIPTION_TRIALING,
1595 Status::SUBSCRIPTION_EXPIRING,
1596 Status::SUBSCRIPTION_FAILING,
1597 Status::SUBSCRIPTION_PAST_DUE,
1598 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1599 $index = 0;
1600
1601 // OR together one (interval + its cutoff) clause per known interval.
1602 foreach ($cutoffDates as $interval => $cutoff) {
1603 $method = $index === 0 ? 'where' : 'orWhere';
1604
1605 $dateQuery->{$method}(function ($intervalQuery) use ($interval, $cutoff) {
1606 $intervalQuery->where('billing_interval', $interval)
1607 ->where('next_billing_date', '<', $cutoff);
1608 });
1609
1610 $index++;
1611 }
1612
1613 // Unknown/null intervals fall back to the default cutoff.
1614 $dateQuery->orWhere(function ($intervalQuery) use ($knownIntervals, $defaultCutoff) {
1615 $intervalQuery->where(function ($unknownIntervalQuery) use ($knownIntervals) {
1616 $unknownIntervalQuery->whereNotIn('billing_interval', $knownIntervals)
1617 ->orWhereNull('billing_interval');
1618 })->where('next_billing_date', '<', $defaultCutoff);
1619 });
1620 });
1621 // Branch B: canceled subs expire the moment their paid period ends (no grace).
1622 })->orWhere(function ($subQuery) use ($now) {
1623 $subQuery->where('status', Status::SUBSCRIPTION_CANCELED)
1624 ->where('next_billing_date', '<', $now);
1625 });
1626 })
1627 ->orderBy('id', 'ASC')
1628 ->limit($batchSize)
1629 ->with(['order', 'customer'])
1630 ->get();
1631
1632 if ($subscriptions->isEmpty()) {
1633 break;
1634 }
1635
1636 $stats['batches']++;
1637 $stats['checked'] += $subscriptions->count();
1638
1639 foreach ($subscriptions as $subscription) {
1640 $nextBillingTimestamp = strtotime($subscription->next_billing_date);
1641
1642 // Skip unparseable/invalid dates.
1643 if (!$nextBillingTimestamp || $nextBillingTimestamp <= 0) {
1644 continue;
1645 }
1646
1647 // Re-validate in PHP (SQL was a coarse filter) and derive the exact cutoff used as a write guard below.
1648 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1649 // Superseded by an upgrade -> the new sub owns validity, leave this one alone.
1650 if (isset($subscription->config['upgraded_to_sub_id'])) {
1651 continue;
1652 }
1653
1654 // Already processed in a prior run.
1655 if ($subscription->getMeta('validity_expired_at')) {
1656 continue;
1657 }
1658
1659 // Paid period not over yet.
1660 if ($nextBillingTimestamp >= $currentTime) {
1661 continue;
1662 }
1663
1664 $cutoff = $now;
1665 } else {
1666 $graceDays = $gracePeriodDays[$subscription->billing_interval] ?? $defaultGraceDays;
1667 $graceDays = max(0, (int)$graceDays);
1668 $cutoffTimestamp = $currentTime - ($graceDays * DAY_IN_SECONDS);
1669
1670 // Still inside the grace window.
1671 if ($nextBillingTimestamp >= $cutoffTimestamp) {
1672 continue;
1673 }
1674
1675 $cutoff = gmdate('Y-m-d H:i:s', $cutoffTimestamp);
1676 }
1677
1678 // Null out next_billing_date so the row can't be re-selected/re-processed.
1679 $updateData = [
1680 'next_billing_date' => NULL,
1681 'updated_at' => gmdate('Y-m-d H:i:s', $currentTime),
1682 ];
1683
1684 // Canceled subs keep their status; only billing statuses flip to EXPIRED.
1685 if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
1686 $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
1687 }
1688
1689 // Optimistic-lock write: only apply if status + past-cutoff still hold, so a concurrent
1690 // renewal/cancel between SELECT and UPDATE can't be overwritten with a stale decision.
1691 $updated = Subscription::query()
1692 ->where('id', $subscription->id)
1693 ->where('status', $subscription->status)
1694 ->where('next_billing_date', '<', $cutoff)
1695 ->update($updateData);
1696
1697 if (!$updated) {
1698 continue;
1699 }
1700
1701 $subscription = Subscription::query()
1702 ->with(['order', 'customer'])
1703 ->find($subscription->id);
1704
1705 if (!$subscription) {
1706 continue;
1707 }
1708
1709 // Idempotency marker + audit timestamp for this expiry.
1710 $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s', $currentTime));
1711
1712 $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
1713 $subscription,
1714 $subscription->order,
1715 $subscription->customer
1716 );
1717
1718 $event->dispatch();
1719
1720 $stats['validity_expired']++;
1721 $stats['expired_ids'][] = $subscription->id;
1722 }
1723
1724 $lastId = $subscriptions->last()->id;
1725
1726 unset($subscriptions);
1727 } while (true);
1728
1729 if ($stats['checked'] > 0) {
1730 $expiredList = !empty($stats['expired_ids']) ? ' (IDs: ' . implode(', ', $stats['expired_ids']) . ')' : '';
1731 fluent_cart_add_log(
1732 'Subscription Validity Expiration Check',
1733 sprintf(
1734 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d%s',
1735 $stats['checked'],
1736 $stats['validity_expired'],
1737 $stats['batches'],
1738 $expiredList
1739 ),
1740 'info',
1741 $stats
1742 );
1743 }
1744
1745 return $stats;
1746 }
1747
1748 }
1749