PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Models/Subscription.php +999 -105 1.3.21 → 1.6.5 View file →
@@ -4,14 +4,16 @@
4 4
5 5 use FluentCart\Api\CurrencySettings;
6 6 use FluentCart\Api\StoreSettings;
7 7 use FluentCart\App\App;
8 -use FluentCart\App\Events\Subscription\SubscriptionCanceled;
8 +use FluentCart\App\Helpers\AttributeHelper;
9 9 use FluentCart\App\Helpers\Helper;
10 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;
11 14 use FluentCart\App\Models\Concerns\CanUpdateBatch;
12 15 use FluentCart\App\Models\Concerns\HasActivity;
13 -use FluentCart\App\Services\Payments\PaymentHelper;
14 16 use FluentCart\App\Services\Payments\SubscriptionHelper;
15 17 use FluentCart\App\Services\TemplateService;
16 18 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
17 19 use FluentCart\Framework\Database\Orm\Relations\HasMany;
@@ -24,8 +26,10 @@
24 26 * Meta Model - DB Model for Meta table
25 27 *
26 28 * Database Model
27 29 *
30 + * @property string $uuid
31 + *
28 32 * @package FluentCart\App\Models
29 33 *
30 34 * @version 1.0.0
31 35 */
@@ -36,9 +40,9 @@
36 40 protected $table = 'fct_subscriptions';
37 41
38 42 protected $primaryKey = 'id';
39 43
40 - protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url'];
44 + protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'permissions', 'display_item_name', 'system_charge_state', 'payment_method_title'];
41 45
42 46 protected $guarded = ['id'];
43 47
44 48 protected $fillable = [
@@ -81,8 +85,32 @@
81 85 }
82 86 });
83 87 }
84 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 +
85 113 public function meta()
86 114 {
87 115 return $this->hasMany(SubscriptionMeta::class, 'subscription_id', 'id');
88 116 }
@@ -136,9 +164,9 @@
136 164 public function getConfigAttribute($value)
137 165 {
138 166 if (is_string($value)) {
139 167 $decoded = json_decode($value, true);
140 - return $decoded ?: $value;
168 + return is_array($decoded) ? $decoded : $value;
141 169 }
142 170 return $value ?: [];
143 171 }
144 172
@@ -152,8 +180,124 @@
152 180
153 181 $this->attributes['config'] = $value;
154 182 }
155 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 +
156 300 public function getUrlAttribute($value)
157 301 {
158 302 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
159 303 'vendor_subscription_id' => $this->vendor_subscription_id,
@@ -172,9 +316,8 @@
172 316 * use overriden status to show the correct status for customer
173 317 */
174 318 public function getOverriddenStatusAttribute($value)
175 319 {
176 - $variation = ProductVariation::find($this->variation_id);
177 320 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
178 321 return Status::SUBSCRIPTION_ACTIVE;
179 322 }
180 323
@@ -184,8 +327,44 @@
184 327
185 328 return $this->status;
186 329 }
187 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 +
188 367 public function getBillingInfoAttribute($value)
189 368 {
190 369 $billingInfo = '';
191 370 $metaKey = 'active_payment_method';
@@ -214,8 +393,24 @@
214 393 {
215 394 return $this->belongsTo(Order::class, 'parent_order_id', 'id');
216 395 }
217 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 +
218 413 /**
219 414 * Get the currency for the subscription
220 415 *
221 416 * @return string
@@ -250,8 +445,131 @@
250 445 return $this->getSubscriptionInfo();
251 446 }
252 447
253 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 + /**
254 572 * Helper method to get subscription info
255 573 *
256 574 * @return string
257 575 */
@@ -267,9 +585,13 @@
267 585 ];
268 586
269 587 $recurringTotal = $this->recurring_total ?? 0;
270 588
271 - return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal) ?? '';
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) ?? '';
272 594 }
273 595
274 596 public function addLog($title, $description = '', $type = 'info', $by = '')
275 597 {
@@ -384,12 +706,72 @@
384 706 ->where('object_id', $this->variation_id)
385 707 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
386 708 }
387 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 +
388 770 public function canUpdatePaymentMethod()
389 771 {
390 - $gateway = App::gateway($this->current_payment_method);
391 - if ($gateway && !in_array('card_update', $gateway->supportedFeatures)) {
772 + $gateway = $this->resolveGateway();
773 + if (!$gateway || !$gateway->has('card_update')) {
392 774 return false;
393 775 }
394 776
395 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
@@ -396,11 +778,18 @@
396 778 }
397 779
398 780 public function canSwitchPaymentMethod()
399 781 {
400 - $gateway = App::gateway($this->current_payment_method);
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 + }
401 790
402 - if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) {
791 + if (!$this->switchPaymentConfig()) {
403 792 return false;
404 793 }
405 794
406 795 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
@@ -407,40 +796,238 @@
407 796 }
408 797
409 798 public function switchablePaymentMethods()
410 799 {
411 - $gateway = App::gateway($this->current_payment_method);
412 - if ($gateway && empty($gateway->supportedFeatures['switch_payment_method'])) {
800 + if (!$this->canSwitchPaymentMethod()) {
413 801 return [];
414 802 }
415 803
416 - return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []);
804 + return Arr::get($this->switchPaymentConfig(), 'supported_gateways', []);
417 805 }
418 806
419 - public function canReactive()
807 + public function canPause()
420 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 + {
421 987 if (!App::isProActive()) {
422 - return '';
988 + return false;
423 989 }
424 990
425 991 if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) {
426 - return '';
992 + return false;
427 993 }
428 994
429 - if (isset($this->config['cancellation_reason']) && $this->config['cancellation_reason'] === 'refunded') {
430 - return '';
431 - }
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 + ]);
432 1004
433 - $canReactivate = in_array($this->status, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRED, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_PAST_DUE]);
434 -
435 - return apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
1005 + return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
436 1006 'subscription' => $this
437 1007 ]);
438 1008 }
439 1009
1010 + /**
1011 + * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
1012 + * @return bool
1013 + */
1014 + public function canReactive()
1015 + {
1016 + return $this->canReactivate();
1017 + }
1018 +
1019 + /**
1020 + * These links are minted in email and webhook contexts, where there is no
1021 + * current user. A wp_create_nonce() token bound to that user-less request
1022 + * stops verifying the moment the recipient logs in to act on it, so the link
1023 + * broke for the one journey it exists to serve. Authorization for the
1024 + * endpoint is the subscription-ownership check on the handling side, which
1025 + * a nonce never provided; the uuid alone is inert to anyone else.
1026 + */
440 1027 public function getReactivateUrl()
441 1028 {
442 - if (!$this->canReactive()) {
1029 + if (!$this->canReactivate()) {
443 1030 return '';
444 1031 }
445 1032
446 1033 return add_query_arg([
@@ -475,11 +1062,19 @@
475 1062 if (in_array($this->status, $validAccessStatuses)) {
476 1063 return true;
477 1064 }
478 1065
1066 + // Past-due/expiring/failing keep access while the unpaid invoice is inside its
1067 + // dunning grace window; checkAndExpireSubscriptions() flips them to expired past that.
1068 + if (in_array($this->status, [Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_FAILING])) {
1069 + $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
1070 + $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
1071 +
1072 + return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
1073 + }
1074 +
479 1075 $invalidStatuses = [
480 1076 Status::SUBSCRIPTION_EXPIRED,
481 - Status::SUBSCRIPTION_PAST_DUE,
482 1077 Status::SUBSCRIPTION_INTENDED,
483 1078 Status::SUBSCRIPTION_PENDING
484 1079 ];
485 1080
@@ -502,9 +1097,9 @@
502 1097 }
503 1098
504 1099 public function reSyncFromRemote()
505 1100 {
506 - if ($gateway = App::gateway($this->current_payment_method)) {
1101 + if ($gateway = $this->resolveGateway()) {
507 1102 if ($gateway->has('subscriptions')) {
508 1103 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
509 1104 }
510 1105 }
@@ -516,9 +1111,10 @@
516 1111 {
517 1112 $args = wp_parse_args($args, [
518 1113 'reason' => '',
519 1114 'fire_hooks' => true,
520 - 'note' => ''
1115 + 'note' => '',
1116 + 'effective_from' => ''
521 1117 ]);
522 1118
523 1119 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
524 1120 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
@@ -523,16 +1119,28 @@
523 1119 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
524 1120 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
525 1121 }
526 1122
527 - $gateway = App::gateway($this->current_payment_method);
1123 + $gateway = $this->resolveGateway();
528 1124
529 - if ($gateway && $gateway->has('subscriptions')) {
530 - $vendorCanceled = $gateway->subscriptions->cancel($this->vendor_subscription_id, [
1125 + // No vendor subscription (store-billed, or a vendor id that never landed) —
1126 + // nothing to cancel at the gateway.
1127 + if (!$this->vendor_subscription_id) {
1128 + $vendorCanceled = null;
1129 + $updateData = [
1130 + 'canceled_at' => gmdate('Y-m-d H:i:s', time())
1131 + ];
1132 + } elseif ($gateway && $gateway->has('subscriptions')) {
1133 + $cancelArgs = [
531 1134 'subscription_id' => $this->id,
532 1135 'parent_order_id' => $this->parent_order_id,
533 - 'mode' => $this->order->mode
534 - ]);
1136 + 'mode' => $this->order->mode,
1137 + ];
1138 + $effectiveFrom = Arr::get($args, 'effective_from', '');
1139 + if ($effectiveFrom) {
1140 + $cancelArgs['effective_from'] = $effectiveFrom;
1141 + }
1142 + $vendorCanceled = $gateway->subscriptions->cancel($this->vendor_subscription_id, $cancelArgs);
535 1143
536 1144 if (is_wp_error($vendorCanceled)) {
537 1145 return $vendorCanceled;
538 1146 }
@@ -538,8 +1146,9 @@
538 1146 }
539 1147
540 1148 $updateData = array_filter($vendorCanceled);
541 1149 } else {
1150 + // Vendor subscription exists but this gateway cannot cancel it — it stays live.
542 1151 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
543 1152 $updateData = [
544 1153 'canceled_at' => gmdate('Y-m-d H:i:s', time())
545 1154 ];
@@ -555,17 +1164,26 @@
555 1164 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
556 1165 $updateData['canceled_at'] = NULL;
557 1166 }
558 1167
559 - $config = $this->config;
560 - if ($args['reason']) {
561 - $config['cancellation_reason'] = $args['reason'];
1168 + if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
1169 + $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
562 1170 }
563 - $updateData['config'] = $config;
564 1171
1172 + // A completed (EOT) subscription has no upcoming billing — the immediate-cancel
1173 + // date above must not resurrect one (SubscriptionEOT cancels remote subscriptions
1174 + // with effective_from=immediately after syncSubscriptionStates nulled the date).
1175 + if (Arr::get($updateData, 'status') === Status::SUBSCRIPTION_COMPLETED) {
1176 + $updateData['next_billing_date'] = NULL;
1177 + }
1178 +
565 1179 $this->fill($updateData);
566 1180 $this->save();
567 1181
1182 + if ($args['reason']) {
1183 + $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1184 + }
1185 +
568 1186 $note = $args['note'];
569 1187
570 1188 if (!$note) {
571 1189 $note = 'on customer request';
@@ -570,10 +1188,11 @@
570 1188 if (!$note) {
571 1189 $note = 'on customer request';
572 1190 }
573 1191
574 - if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) {
575 - (new SubscriptionCanceled($this, $this->order, $this->order->customer, $note))->dispatch();
1192 + // Single cancel chokepoint — void open renewals, clear reminders, email once.
1193 + if ($this->status === Status::SUBSCRIPTION_CANCELED) {
1194 + SubscriptionService::finalizeCancellation($this, $note, (bool) $args['fire_hooks']);
576 1195 }
577 1196
578 1197 if ($args['note']) {
579 1198 $this->order->note = $note;
@@ -596,8 +1215,36 @@
596 1215
597 1216 return $this->recurring_total;
598 1217 }
599 1218
1219 + /**
1220 + * Cycles the remote (vendor) plan must bill at INITIAL checkout.
1221 + * With a simulated trial the first installment is already collected outside
1222 + * the remote recurring cycles (one-time charge, paid/free trial cycle), so
1223 + * the remote plan only needs bill_times - 1.
1224 + *
1225 + * Only valid at initial checkout — do NOT use for renewals/reactivation
1226 + * (payment-method switching also sets is_trial_days_simulated; renewal flows
1227 + * must use getRequiredBillTimes() which is bill_count based).
1228 + *
1229 + * @return int 0 means unlimited
1230 + */
1231 + public function getInitialRemoteBillTimes()
1232 + {
1233 + $billTimes = (int)$this->bill_times;
1234 +
1235 + if (!$billTimes) {
1236 + return 0;
1237 + }
1238 +
1239 + if (Arr::get($this->config, 'is_trial_days_simulated', 'no') === 'yes') {
1240 + // never return 0 here — 0 means unlimited to the gateways
1241 + $billTimes = max(1, $billTimes - 1);
1242 + }
1243 +
1244 + return $billTimes;
1245 + }
1246 +
600 1247 public function getRequiredBillTimes()
601 1248 {
602 1249 $billTimes = (int)$this->bill_times;
603 1250
@@ -603,23 +1250,10 @@
603 1250
604 1251 if ($billTimes > 0) {
605 1252 $billTimes = $billTimes - $this->bill_count;
606 1253 if ($billTimes <= 0) {
607 - $transacactionsCount = OrderTransaction::query()
608 - ->where('subscription_id', $this->id)
609 - ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
610 - ->where('status', Status::TRANSACTION_SUCCEEDED)
611 - ->where('total', '>', 0)
612 - ->count();
1254 + $transacactionsCount = $this->calculateBillCount();
613 1255
614 - $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
615 - foreach ($earlyPaymentHistory as $earlyPayment) {
616 - $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
617 - if ($paidCount > 1) {
618 - $transacactionsCount += ($paidCount - 1);
619 - }
620 - }
621 -
622 1256 if ($transacactionsCount != $this->bill_count) {
623 1257 $this->bill_count = $transacactionsCount;
624 1258 $this->save();
625 1259 }
@@ -635,14 +1269,187 @@
635 1269
636 1270 return $billTimes;
637 1271 }
638 1272
1273 + /**
1274 + * Canonical bill_count formula. Every writer of bill_count must go through
1275 + * this — a separate ad hoc count (e.g. StripeGateway\SubscriptionsManager
1276 + * previously) silently drops the offset/deduction corrections below and
1277 + * reports a wrong count until the next recompute.
1278 + *
1279 + * total > 0 CHARGE transactions linked to this subscription, adjusted for
1280 + * the two one-time corrections decided at creation (see
1281 + * CheckoutProcessor::syncInitialCycleCounting):
1282 + * - billed_cycles_offset: free simulated-trial first cycle consumed a
1283 + * cycle without producing a total > 0 transaction.
1284 + * - billed_cycles_deduction: real-trial signup-fee-only charge is a
1285 + * total > 0 transaction but isn't a billed cycle.
1286 + */
1287 + public function calculateBillCount()
1288 + {
1289 + $transacactionsCount = OrderTransaction::query()
1290 + ->where('subscription_id', $this->id)
1291 + ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1292 + ->where('status', Status::TRANSACTION_SUCCEEDED)
1293 + ->where('total', '>', 0)
1294 + ->count();
1295 +
1296 + $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
1297 + foreach ((array)$earlyPaymentHistory as $earlyPayment) {
1298 + $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
1299 + if ($paidCount > 1) {
1300 + $transacactionsCount += ($paidCount - 1);
1301 + }
1302 + }
1303 +
1304 + $transacactionsCount += (int) $this->getMeta('billed_cycles_offset', 0);
1305 + $transacactionsCount -= (int) $this->getMeta('billed_cycles_deduction', 0);
1306 +
1307 + return $transacactionsCount;
1308 + }
1309 +
1310 + /**
1311 + * Installment / split-pay plan: a finite-term subscription (a lifetime
1312 + * license paid off in a fixed number of charges), as opposed to an
1313 + * open-ended recurring subscription. The canonical structural signal is
1314 + * bill_times > 0 (0 = infinite/open-ended). Reused across analytics,
1315 + * filters and lifecycle handling — do NOT reintroduce title-string
1316 + * ("Split") matching, which the data does not reliably carry.
1317 + *
1318 + * @return bool
1319 + */
1320 + public function isInstallment()
1321 + {
1322 + return (int) $this->bill_times > 0;
1323 + }
1324 +
1325 + /**
1326 + * Installments still owed: 0 for open-ended plans, or once the term is
1327 + * fully paid.
1328 + *
1329 + * @return int
1330 + */
1331 + public function installmentsRemaining()
1332 + {
1333 + if (!$this->isInstallment()) {
1334 + return 0;
1335 + }
1336 +
1337 + return max(0, (int) $this->bill_times - (int) $this->bill_count);
1338 + }
1339 +
1340 + /**
1341 + * Has a finite installment plan collected every scheduled charge (end of
1342 + * term)? Open-ended plans never reach term end.
1343 + *
1344 + * @return bool
1345 + */
1346 + public function hasReachedTermEnd()
1347 + {
1348 + return $this->isInstallment() && (int) $this->bill_count >= (int) $this->bill_times;
1349 + }
1350 +
1351 + /**
1352 + * Full committed price of an installment contract: recurring_total x
1353 + * bill_times, in cents. 0 for open-ended plans (no fixed total). This is
1354 + * the per-row form of the SUM(recurring_total * bill_times) used by the
1355 + * subscription analytics aggregate.
1356 + *
1357 + * @return int
1358 + */
1359 + public function totalContractValue()
1360 + {
1361 + if (!$this->isInstallment()) {
1362 + return 0;
1363 + }
1364 +
1365 + return (int) $this->recurring_total * (int) $this->bill_times;
1366 + }
1367 +
1368 + /**
1369 + * Filter by plan type: 'installment' (finite term, bill_times > 0),
1370 + * 'recurring' (open-ended, bill_times = 0) or anything else (no filter).
1371 + * The bill_times threshold is kept identical to isInstallment() so the SQL
1372 + * and PHP definitions never drift apart.
1373 + */
1374 + public function scopeOfPlanType($query, $planType)
1375 + {
1376 + if ($planType === 'installment') {
1377 + return $query->where('bill_times', '>', 0);
1378 + }
1379 + if ($planType === 'recurring') {
1380 + return $query->where('bill_times', '<=', 0);
1381 + }
1382 +
1383 + return $query;
1384 + }
1385 +
1386 + /**
1387 + * Whether a lapsed/canceled subscription still has unexpired paid time to
1388 + * credit back on reactivation. Deliberately NOT hasAccessValidity() — that
1389 + * method answers "can the customer access content right now" and its status
1390 + * list is free to evolve for that purpose alone. This is its own copy so a
1391 + * future access-only change (e.g. a new status added for content gating)
1392 + * can't silently change how much reactivation trial credit gets granted.
1393 + *
1394 + * @return bool
1395 + */
1396 + public function hasReactivationTrialCredit(): bool
1397 + {
1398 + $validStatuses = [
1399 + Status::SUBSCRIPTION_ACTIVE,
1400 + Status::SUBSCRIPTION_TRIALING,
1401 + Status::SUBSCRIPTION_COMPLETED
1402 + ];
1403 +
1404 + if (in_array($this->status, $validStatuses)) {
1405 + return true;
1406 + }
1407 +
1408 + // No grace-period math here on purpose: past_due/expiring/failing fall through
1409 + // to the plain next_billing_date > now check below. If that date is still
1410 + // future, credit is granted same as any other status; if it's past, this
1411 + // returns false the same way the grace window would eventually clamp to via
1412 + // getReactivationTrialDays()'s <=1 floor — without a redundant grace-days
1413 + // lookup either way.
1414 +
1415 + $invalidStatuses = [
1416 + Status::SUBSCRIPTION_EXPIRED,
1417 + Status::SUBSCRIPTION_INTENDED,
1418 + Status::SUBSCRIPTION_PENDING
1419 + ];
1420 +
1421 + if (in_array($this->status, $invalidStatuses)) {
1422 + return false;
1423 + }
1424 +
1425 + $nextBillingDate = $this->next_billing_date;
1426 +
1427 + if (!$nextBillingDate) {
1428 + $nextBillingDate = $this->guessNextBillingDate();
1429 + }
1430 +
1431 + return strtotime($nextBillingDate) > time();
1432 + }
1433 +
639 1434 public function getReactivationTrialDays()
640 1435 {
641 - if (!$this->hasAccessValidity()) {
1436 + if (!$this->hasReactivationTrialCredit()) {
642 1437 return 0;
643 1438 }
644 1439
1440 + $lastPaidTransaction = OrderTransaction::query()
1441 + ->where('subscription_id', $this->id)
1442 + ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1443 + ->where('status', Status::TRANSACTION_SUCCEEDED)
1444 + ->where('total', '>', 0)
1445 + ->orderBy('id', 'DESC')
1446 + ->first();
1447 +
1448 + if ($lastPaidTransaction && $lastPaidTransaction->getMaxRefundableAmount() === 0) {
1449 + return 0;
1450 + }
1451 +
645 1452 $nextBillingDate = $this->guessNextBillingDate(true);
646 1453
647 1454 // @todo: Temporary fix for next billing date mismatch issue from migration
648 1455
@@ -686,16 +1493,17 @@
686 1493 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
687 1494 ->first();
688 1495
689 1496 if ($theLastOrder) {
690 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1497 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1498 +
691 1499 if ($theLastOrder->type == 'renewal') {
692 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1500 + $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
693 1501 } else {
694 1502 if ($this->trial_days) {
695 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
1503 + $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($paidAnchor) + (int)($this->trial_days) * DAY_IN_SECONDS);
696 1504 } else {
697 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1505 + $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
698 1506 }
699 1507 }
700 1508 } else {
701 1509 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -712,9 +1520,9 @@
712 1520 *
713 1521 * Processes all candidates in batches to avoid memory issues.
714 1522 * The query example works as follows:
715 1523 * SELECT * FROM subscriptions WHERE
716 - status IN ('active', 'trialing', 'canceled')
1524 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
717 1525 AND next_billing_date IS NOT NULL
718 1526 AND id > 0 -- last processed ID for batch cursor
719 1527 AND next_billing_date < DATE_SUB(
720 1528 '2026-02-17 10:00:00',
@@ -735,51 +1543,88 @@
735 1543 *
736 1544 * @param int $batchSize Number of subscriptions to process per batch
737 1545 * @return array Statistics about processed subscriptions
738 1546 */
739 - public static function checkAndExpireSubscriptions($batchSize = 100)
1547 + public static function checkAndExpireSubscriptions($batchSize = 100)
740 1548 {
741 1549 $stats = [
742 - 'checked' => 0,
743 - 'validity_expired' => 0,
744 - 'batches' => 0,
1550 + 'checked' => 0,
1551 + 'validity_expired' => 0,
1552 + 'batches' => 0,
1553 + 'expired_ids' => [],
745 1554 ];
746 1555
747 1556 $lastId = 0;
748 1557
749 - $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays();
1558 + do {
1559 + $currentTime = time();
1560 + $now = gmdate('Y-m-d H:i:s', $currentTime);
750 1561
751 - $caseSql = 'CASE billing_interval ';
752 - $bindings = [];
1562 + $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays();
753 1563
754 - foreach ($gracePeriodDays as $interval => $days) {
755 - $caseSql .= 'WHEN ? THEN ? ';
756 - $bindings[] = $interval;
757 - $bindings[] = $days;
758 - }
1564 + $cutoffDates = [];
1565 + foreach ($gracePeriodDays as $interval => $days) {
1566 + $cutoffDates[$interval] = gmdate('Y-m-d H:i:s', $currentTime - ((int)$days * DAY_IN_SECONDS));
1567 + }
759 1568
760 - $caseSql .= 'ELSE ? END';
761 - $bindings[] = 7;
1569 + // Fallback cutoff for unknown/null billing intervals.
1570 + $defaultGraceDays = 7;
1571 + $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
1572 + $knownIntervals = array_keys($cutoffDates);
762 1573
763 - $cutoffSql = "DATE_SUB(?, INTERVAL ($caseSql) DAY)";
764 -
765 - do {
766 1574 // Include canceled subscriptions to check if validity is yet to expired
1575 + // Exclude store-billed (manual/system) subscriptions — their expiry is
1576 + // handled by the invoice-based overdue flow
767 1577 $subscriptions = Subscription::query()
768 1578 ->whereIn('status', [
769 1579 Status::SUBSCRIPTION_ACTIVE,
770 1580 Status::SUBSCRIPTION_TRIALING,
771 1581 Status::SUBSCRIPTION_CANCELED,
1582 + Status::SUBSCRIPTION_EXPIRING,
1583 + Status::SUBSCRIPTION_FAILING,
1584 + Status::SUBSCRIPTION_PAST_DUE
772 1585 ])
1586 + ->whereNotIn('collection_method', ['manual', 'system'])
773 1587 ->whereNotNull('next_billing_date')
1588 + ->where('next_billing_date', '>', '0000-00-00 00:00:00')
774 1589 ->where('id', '>', $lastId)
775 - ->whereRaw(
776 - "next_billing_date < $cutoffSql",
777 - array_merge(
778 - [gmdate('Y-m-d H:i:s', time())],
779 - $bindings
780 - )
781 - )
1590 + ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
1591 + $query->where(function ($subQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1592 + $subQuery->whereIn('status', [
1593 + Status::SUBSCRIPTION_ACTIVE,
1594 + Status::SUBSCRIPTION_TRIALING,
1595 + Status::SUBSCRIPTION_EXPIRING,
1596 + Status::SUBSCRIPTION_FAILING,
1597 + Status::SUBSCRIPTION_PAST_DUE,
1598 + ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1599 + $index = 0;
1600 +
1601 + // OR together one (interval + its cutoff) clause per known interval.
1602 + foreach ($cutoffDates as $interval => $cutoff) {
1603 + $method = $index === 0 ? 'where' : 'orWhere';
1604 +
1605 + $dateQuery->{$method}(function ($intervalQuery) use ($interval, $cutoff) {
1606 + $intervalQuery->where('billing_interval', $interval)
1607 + ->where('next_billing_date', '<', $cutoff);
1608 + });
1609 +
1610 + $index++;
1611 + }
1612 +
1613 + // Unknown/null intervals fall back to the default cutoff.
1614 + $dateQuery->orWhere(function ($intervalQuery) use ($knownIntervals, $defaultCutoff) {
1615 + $intervalQuery->where(function ($unknownIntervalQuery) use ($knownIntervals) {
1616 + $unknownIntervalQuery->whereNotIn('billing_interval', $knownIntervals)
1617 + ->orWhereNull('billing_interval');
1618 + })->where('next_billing_date', '<', $defaultCutoff);
1619 + });
1620 + });
1621 + // Branch B: canceled subs expire the moment their paid period ends (no grace).
1622 + })->orWhere(function ($subQuery) use ($now) {
1623 + $subQuery->where('status', Status::SUBSCRIPTION_CANCELED)
1624 + ->where('next_billing_date', '<', $now);
1625 + });
1626 + })
782 1627 ->orderBy('id', 'ASC')
783 1628 ->limit($batchSize)
784 1629 ->with(['order', 'customer'])
785 1630 ->get();
@@ -784,9 +1629,9 @@
784 1629 ->with(['order', 'customer'])
785 1630 ->get();
786 1631
787 1632 if ($subscriptions->isEmpty()) {
788 - break; // No more subscriptions to process
1633 + break;
789 1634 }
790 1635
791 1636 $stats['batches']++;
792 1637 $stats['checked'] += $subscriptions->count();
@@ -791,58 +1636,107 @@
791 1636 $stats['batches']++;
792 1637 $stats['checked'] += $subscriptions->count();
793 1638
794 1639 foreach ($subscriptions as $subscription) {
1640 + $nextBillingTimestamp = strtotime($subscription->next_billing_date);
1641 +
1642 + // Skip unparseable/invalid dates.
1643 + if (!$nextBillingTimestamp || $nextBillingTimestamp <= 0) {
1644 + continue;
1645 + }
1646 +
1647 + // Re-validate in PHP (SQL was a coarse filter) and derive the exact cutoff used as a write guard below.
795 1648 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1649 + // Superseded by an upgrade -> the new sub owns validity, leave this one alone.
796 1650 if (isset($subscription->config['upgraded_to_sub_id'])) {
797 1651 continue;
798 1652 }
799 - }
800 - $gracePeriod = $gracePeriodDays[$subscription->billing_interval] ?? 7;
801 - $cutoff = gmdate('Y-m-d H:i:s', time() - ($gracePeriod * DAY_IN_SECONDS));
802 1653
803 - if ($subscription->next_billing_date < $cutoff) {
804 - $updateData = [
805 - 'next_billing_date' => NULL,
806 - ];
1654 + // Already processed in a prior run.
1655 + if ($subscription->getMeta('validity_expired_at')) {
1656 + continue;
1657 + }
807 1658
808 - // Only change status to EXPIRED for active/trialing subscriptions
809 - if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
810 - $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
1659 + // Paid period not over yet.
1660 + if ($nextBillingTimestamp >= $currentTime) {
1661 + continue;
811 1662 }
812 1663
813 - $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s'));
1664 + $cutoff = $now;
1665 + } else {
1666 + $graceDays = $gracePeriodDays[$subscription->billing_interval] ?? $defaultGraceDays;
1667 + $graceDays = max(0, (int)$graceDays);
1668 + $cutoffTimestamp = $currentTime - ($graceDays * DAY_IN_SECONDS);
814 1669
815 - $subscription->fill($updateData);
816 - $subscription->save();
1670 + // Still inside the grace window.
1671 + if ($nextBillingTimestamp >= $cutoffTimestamp) {
1672 + continue;
1673 + }
817 1674
818 - $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
819 - $subscription,
820 - $subscription->order,
821 - $subscription->customer,
822 - );
1675 + $cutoff = gmdate('Y-m-d H:i:s', $cutoffTimestamp);
1676 + }
823 1677
824 - $event->dispatch();
1678 + // Null out next_billing_date so the row can't be re-selected/re-processed.
1679 + $updateData = [
1680 + 'next_billing_date' => NULL,
1681 + 'updated_at' => gmdate('Y-m-d H:i:s', $currentTime),
1682 + ];
825 1683
826 - $stats['validity_expired']++;
1684 + // Canceled subs keep their status; only billing statuses flip to EXPIRED.
1685 + if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
1686 + $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
827 1687 }
828 1688
1689 + // Optimistic-lock write: only apply if status + past-cutoff still hold, so a concurrent
1690 + // renewal/cancel between SELECT and UPDATE can't be overwritten with a stale decision.
1691 + $updated = Subscription::query()
1692 + ->where('id', $subscription->id)
1693 + ->where('status', $subscription->status)
1694 + ->where('next_billing_date', '<', $cutoff)
1695 + ->update($updateData);
1696 +
1697 + if (!$updated) {
1698 + continue;
1699 + }
1700 +
1701 + $subscription = Subscription::query()
1702 + ->with(['order', 'customer'])
1703 + ->find($subscription->id);
1704 +
1705 + if (!$subscription) {
1706 + continue;
1707 + }
1708 +
1709 + // Idempotency marker + audit timestamp for this expiry.
1710 + $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s', $currentTime));
1711 +
1712 + $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
1713 + $subscription,
1714 + $subscription->order,
1715 + $subscription->customer
1716 + );
1717 +
1718 + $event->dispatch();
1719 +
1720 + $stats['validity_expired']++;
1721 + $stats['expired_ids'][] = $subscription->id;
829 1722 }
830 1723
831 1724 $lastId = $subscriptions->last()->id;
832 1725
833 1726 unset($subscriptions);
834 -
835 1727 } while (true);
836 1728
837 1729 if ($stats['checked'] > 0) {
1730 + $expiredList = !empty($stats['expired_ids']) ? ' (IDs: ' . implode(', ', $stats['expired_ids']) . ')' : '';
838 1731 fluent_cart_add_log(
839 1732 'Subscription Validity Expiration Check',
840 1733 sprintf(
841 - 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d',
1734 + 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d%s',
842 1735 $stats['checked'],
843 1736 $stats['validity_expired'],
844 - $stats['batches']
1737 + $stats['batches'],
1738 + $expiredList
845 1739 ),
846 1740 'info',
847 1741 $stats
848 1742 );