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

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

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