PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 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 All 50 releases
fluent-cart / app / Models / Subscription.php

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

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