PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Models / Subscription.php

Subscription.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.1, at app/Models/Subscription.php

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