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

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