PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
← All changes | app/Models/Subscription.php +817 -53 1.5.2 → 1.7.0 View file →
@@ -4,15 +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;
9 8 use FluentCart\App\Helpers\AttributeHelper;
10 9 use FluentCart\App\Helpers\Helper;
11 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;
12 14 use FluentCart\App\Models\Concerns\CanUpdateBatch;
13 15 use FluentCart\App\Models\Concerns\HasActivity;
14 -use FluentCart\App\Services\Payments\PaymentHelper;
15 16 use FluentCart\App\Services\Payments\SubscriptionHelper;
16 17 use FluentCart\App\Services\TemplateService;
17 18 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
18 19 use FluentCart\Framework\Database\Orm\Relations\HasMany;
@@ -25,8 +26,10 @@
25 26 * Meta Model - DB Model for Meta table
26 27 *
27 28 * Database Model
28 29 *
30 + * @property string $uuid
31 + *
29 32 * @package FluentCart\App\Models
30 33 *
31 34 * @version 1.0.0
32 35 */
@@ -37,9 +40,9 @@
37 40 protected $table = 'fct_subscriptions';
38 41
39 42 protected $primaryKey = 'id';
40 43
41 - protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'display_item_name'];
44 + protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'permissions', 'display_item_name', 'system_charge_state', 'payment_method_title'];
42 45
43 46 protected $guarded = ['id'];
44 47
45 48 protected $fillable = [
@@ -161,9 +164,9 @@
161 164 public function getConfigAttribute($value)
162 165 {
163 166 if (is_string($value)) {
164 167 $decoded = json_decode($value, true);
165 - return $decoded ?: $value;
168 + return is_array($decoded) ? $decoded : $value;
166 169 }
167 170 return $value ?: [];
168 171 }
169 172
@@ -178,8 +181,69 @@
178 181 $this->attributes['config'] = $value;
179 182 }
180 183
181 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 + /**
182 246 * Customer-facing display name. When the config['item_attributes'] snapshot
183 247 * resolves it returns the product name with the labeled combination
184 248 * ("Cake - Flavor: Vanilla | Weight: 500 g"); otherwise the raw item_name
185 249 * (simple / pre-snapshot subscriptions).
@@ -213,8 +277,27 @@
213 277
214 278 return $postTitle !== '' ? $postTitle . ' - ' . $attributeDisplayTitleString : $attributeDisplayTitleString;
215 279 }
216 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 +
217 300 public function getUrlAttribute($value)
218 301 {
219 302 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
220 303 'vendor_subscription_id' => $this->vendor_subscription_id,
@@ -233,9 +316,8 @@
233 316 * use overriden status to show the correct status for customer
234 317 */
235 318 public function getOverriddenStatusAttribute($value)
236 319 {
237 - $variation = ProductVariation::find($this->variation_id);
238 320 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
239 321 return Status::SUBSCRIPTION_ACTIVE;
240 322 }
241 323
@@ -245,8 +327,44 @@
245 327
246 328 return $this->status;
247 329 }
248 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 +
249 367 public function getBillingInfoAttribute($value)
250 368 {
251 369 $billingInfo = '';
252 370 $metaKey = 'active_payment_method';
@@ -327,8 +445,131 @@
327 445 return $this->getSubscriptionInfo();
328 446 }
329 447
330 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 + /**
331 572 * Helper method to get subscription info
332 573 *
333 574 * @return string
334 575 */
@@ -344,8 +585,12 @@
344 585 ];
345 586
346 587 $recurringTotal = $this->recurring_total ?? 0;
347 588
589 + if ($schedule = SubscriptionHelper::getBillingSchedule($this)) {
590 + return Helper::generateScheduleSubscriptionInfo($schedule, $otherInfo, $recurringTotal, $this->currency) ?? '';
591 + }
592 +
348 593 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal, $this->currency) ?? '';
349 594 }
350 595
351 596 public function addLog($title, $description = '', $type = 'info', $by = '')
@@ -461,12 +706,72 @@
461 706 ->where('object_id', $this->variation_id)
462 707 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
463 708 }
464 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 +
465 770 public function canUpdatePaymentMethod()
466 771 {
467 - $gateway = App::gateway($this->current_payment_method);
468 - if (!$gateway || !in_array('card_update', $gateway->supportedFeatures)) {
772 + $gateway = $this->resolveGateway();
773 + if (!$gateway || !$gateway->has('card_update')) {
469 774 return false;
470 775 }
471 776
472 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
@@ -473,11 +778,18 @@
473 778 }
474 779
475 780 public function canSwitchPaymentMethod()
476 781 {
477 - $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 + }
478 790
479 - if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) {
791 + if (!$this->switchPaymentConfig()) {
480 792 return false;
481 793 }
482 794
483 795 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
@@ -484,36 +796,268 @@
484 796 }
485 797
486 798 public function switchablePaymentMethods()
487 799 {
488 - $gateway = App::gateway($this->current_payment_method);
489 - if (!$gateway || empty($gateway->supportedFeatures['switch_payment_method'])) {
800 + if (!$this->canSwitchPaymentMethod()) {
490 801 return [];
491 802 }
492 803
493 - return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []);
804 + return Arr::get($this->switchPaymentConfig(), 'supported_gateways', []);
494 805 }
495 806
496 - public function canReactive()
807 + public function canPause()
497 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 + {
498 987 if (!App::isProActive()) {
499 - return '';
988 + return false;
500 989 }
501 990
502 991 if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) {
503 - return '';
992 + return false;
504 993 }
505 994
506 - $canReactivate = in_array($this->status, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRED, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_PAST_DUE]);
995 + // Paused is intentionally excluded — a paused subscription resumes (see
996 + // canResume()); reactivation is for terminal/lapsed states only.
997 + $canReactivate = in_array($this->status, [
998 + Status::SUBSCRIPTION_CANCELED,
999 + Status::SUBSCRIPTION_FAILING,
1000 + Status::SUBSCRIPTION_EXPIRED,
1001 + Status::SUBSCRIPTION_EXPIRING,
1002 + Status::SUBSCRIPTION_PAST_DUE,
1003 + ]) || (
1004 + in_array($this->status, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED], true)
1005 + && $this->hasReactivationAttempt()
1006 + && ((int) $this->bill_times === 0 || (int) $this->bill_count < (int) $this->bill_times)
1007 + );
507 1008
508 - return apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
1009 + return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
509 1010 'subscription' => $this
510 1011 ]);
511 1012 }
512 1013
1014 + /**
1015 + * Pro records `reactivation_order_id` before a reactivation checkout is paid. A first
1016 + * purchase awaiting gateway activation is also pending/intended and can already be
1017 + * billed, so only this marker separates a retryable reactivation from it.
1018 + */
1019 + public function hasReactivationAttempt(): bool
1020 + {
1021 + return (int) Arr::get($this->config, 'reactivation_order_id', 0) > 0;
1022 + }
1023 +
1024 + public function isVisibleToCustomer(): bool
1025 + {
1026 + return !in_array($this->status, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED], true)
1027 + || (int) $this->bill_count > 0
1028 + || $this->hasReactivationAttempt();
1029 + }
1030 +
1031 + public function scopeVisibleToCustomer($query)
1032 + {
1033 + return $query->where(function ($query) {
1034 + $query->whereNotIn('status', [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED])
1035 + ->orWhere('bill_count', '>', 0)
1036 + ->orWhere('config', 'LIKE', '%"reactivation_order_id":%');
1037 + });
1038 + }
1039 +
1040 + /**
1041 + * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
1042 + * @return bool
1043 + */
1044 + public function canReactive()
1045 + {
1046 + return $this->canReactivate();
1047 + }
1048 +
1049 + /**
1050 + * These links are minted in email and webhook contexts, where there is no
1051 + * current user. A wp_create_nonce() token bound to that user-less request
1052 + * stops verifying the moment the recipient logs in to act on it, so the link
1053 + * broke for the one journey it exists to serve. Authorization for the
1054 + * endpoint is the subscription-ownership check on the handling side, which
1055 + * a nonce never provided; the uuid alone is inert to anyone else.
1056 + */
513 1057 public function getReactivateUrl()
514 1058 {
515 - if (!$this->canReactive()) {
1059 + if (!$this->canReactivate()) {
516 1060 return '';
517 1061 }
518 1062
519 1063 return add_query_arg([
@@ -548,11 +1092,19 @@
548 1092 if (in_array($this->status, $validAccessStatuses)) {
549 1093 return true;
550 1094 }
551 1095
1096 + // Past-due/expiring/failing keep access while the unpaid invoice is inside its
1097 + // dunning grace window; checkAndExpireSubscriptions() flips them to expired past that.
1098 + if (in_array($this->status, [Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_FAILING])) {
1099 + $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
1100 + $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
1101 +
1102 + return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
1103 + }
1104 +
552 1105 $invalidStatuses = [
553 1106 Status::SUBSCRIPTION_EXPIRED,
554 - Status::SUBSCRIPTION_PAST_DUE,
555 1107 Status::SUBSCRIPTION_INTENDED,
556 1108 Status::SUBSCRIPTION_PENDING
557 1109 ];
558 1110
@@ -575,9 +1127,9 @@
575 1127 }
576 1128
577 1129 public function reSyncFromRemote()
578 1130 {
579 - if ($gateway = App::gateway($this->current_payment_method)) {
1131 + if ($gateway = $this->resolveGateway()) {
580 1132 if ($gateway->has('subscriptions')) {
581 1133 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
582 1134 }
583 1135 }
@@ -597,11 +1149,18 @@
597 1149 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
598 1150 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
599 1151 }
600 1152
601 - $gateway = App::gateway($this->current_payment_method);
1153 + $gateway = $this->resolveGateway();
602 1154
603 - if ($gateway && $gateway->has('subscriptions')) {
1155 + // No vendor subscription (store-billed, or a vendor id that never landed) —
1156 + // nothing to cancel at the gateway.
1157 + if (!$this->vendor_subscription_id) {
1158 + $vendorCanceled = null;
1159 + $updateData = [
1160 + 'canceled_at' => gmdate('Y-m-d H:i:s', time())
1161 + ];
1162 + } elseif ($gateway && $gateway->has('subscriptions')) {
604 1163 $cancelArgs = [
605 1164 'subscription_id' => $this->id,
606 1165 'parent_order_id' => $this->parent_order_id,
607 1166 'mode' => $this->order->mode,
@@ -617,8 +1176,9 @@
617 1176 }
618 1177
619 1178 $updateData = array_filter($vendorCanceled);
620 1179 } else {
1180 + // Vendor subscription exists but this gateway cannot cancel it — it stays live.
621 1181 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
622 1182 $updateData = [
623 1183 'canceled_at' => gmdate('Y-m-d H:i:s', time())
624 1184 ];
@@ -634,21 +1194,26 @@
634 1194 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
635 1195 $updateData['canceled_at'] = NULL;
636 1196 }
637 1197
638 - $config = $this->config;
639 - if ($args['reason']) {
640 - $config['cancellation_reason'] = $args['reason'];
1198 + if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
1199 + $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
641 1200 }
642 - $updateData['config'] = $config;
643 1201
644 - if (Arr::get($args, 'effective_from') === 'immediately') {
645 - $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
1202 + // A completed (EOT) subscription has no upcoming billing — the immediate-cancel
1203 + // date above must not resurrect one (SubscriptionEOT cancels remote subscriptions
1204 + // with effective_from=immediately after syncSubscriptionStates nulled the date).
1205 + if (Arr::get($updateData, 'status') === Status::SUBSCRIPTION_COMPLETED) {
1206 + $updateData['next_billing_date'] = NULL;
646 1207 }
647 1208
648 1209 $this->fill($updateData);
649 1210 $this->save();
650 1211
1212 + if ($args['reason']) {
1213 + $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1214 + }
1215 +
651 1216 $note = $args['note'];
652 1217
653 1218 if (!$note) {
654 1219 $note = 'on customer request';
@@ -653,10 +1218,11 @@
653 1218 if (!$note) {
654 1219 $note = 'on customer request';
655 1220 }
656 1221
657 - if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) {
658 - (new SubscriptionCanceled($this, $this->order, $this->order->customer, $note))->dispatch();
1222 + // Single cancel chokepoint — void open renewals, clear reminders, email once.
1223 + if ($this->status === Status::SUBSCRIPTION_CANCELED) {
1224 + SubscriptionService::finalizeCancellation($this, $note, (bool) $args['fire_hooks']);
659 1225 }
660 1226
661 1227 if ($args['note']) {
662 1228 $this->order->note = $note;
@@ -679,8 +1245,36 @@
679 1245
680 1246 return $this->recurring_total;
681 1247 }
682 1248
1249 + /**
1250 + * Cycles the remote (vendor) plan must bill at INITIAL checkout.
1251 + * With a simulated trial the first installment is already collected outside
1252 + * the remote recurring cycles (one-time charge, paid/free trial cycle), so
1253 + * the remote plan only needs bill_times - 1.
1254 + *
1255 + * Only valid at initial checkout — do NOT use for renewals/reactivation
1256 + * (payment-method switching also sets is_trial_days_simulated; renewal flows
1257 + * must use getRequiredBillTimes() which is bill_count based).
1258 + *
1259 + * @return int 0 means unlimited
1260 + */
1261 + public function getInitialRemoteBillTimes()
1262 + {
1263 + $billTimes = (int)$this->bill_times;
1264 +
1265 + if (!$billTimes) {
1266 + return 0;
1267 + }
1268 +
1269 + if (Arr::get($this->config, 'is_trial_days_simulated', 'no') === 'yes') {
1270 + // never return 0 here — 0 means unlimited to the gateways
1271 + $billTimes = max(1, $billTimes - 1);
1272 + }
1273 +
1274 + return $billTimes;
1275 + }
1276 +
683 1277 public function getRequiredBillTimes()
684 1278 {
685 1279 $billTimes = (int)$this->bill_times;
686 1280
@@ -686,23 +1280,10 @@
686 1280
687 1281 if ($billTimes > 0) {
688 1282 $billTimes = $billTimes - $this->bill_count;
689 1283 if ($billTimes <= 0) {
690 - $transacactionsCount = OrderTransaction::query()
691 - ->where('subscription_id', $this->id)
692 - ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
693 - ->where('status', Status::TRANSACTION_SUCCEEDED)
694 - ->where('total', '>', 0)
695 - ->count();
1284 + $transacactionsCount = $this->calculateBillCount();
696 1285
697 - $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
698 - foreach ($earlyPaymentHistory as $earlyPayment) {
699 - $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
700 - if ($paidCount > 1) {
701 - $transacactionsCount += ($paidCount - 1);
702 - }
703 - }
704 -
705 1286 if ($transacactionsCount != $this->bill_count) {
706 1287 $this->bill_count = $transacactionsCount;
707 1288 $this->save();
708 1289 }
@@ -718,11 +1299,172 @@
718 1299
719 1300 return $billTimes;
720 1301 }
721 1302
1303 + /**
1304 + * Canonical bill_count formula. Every writer of bill_count must go through
1305 + * this — a separate ad hoc count (e.g. StripeGateway\SubscriptionsManager
1306 + * previously) silently drops the offset/deduction corrections below and
1307 + * reports a wrong count until the next recompute.
1308 + *
1309 + * total > 0 CHARGE transactions linked to this subscription, adjusted for
1310 + * the two one-time corrections decided at creation (see
1311 + * CheckoutProcessor::syncInitialCycleCounting):
1312 + * - billed_cycles_offset: free simulated-trial first cycle consumed a
1313 + * cycle without producing a total > 0 transaction.
1314 + * - billed_cycles_deduction: real-trial signup-fee-only charge is a
1315 + * total > 0 transaction but isn't a billed cycle.
1316 + */
1317 + public function calculateBillCount()
1318 + {
1319 + $transacactionsCount = OrderTransaction::query()
1320 + ->where('subscription_id', $this->id)
1321 + ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1322 + ->where('status', Status::TRANSACTION_SUCCEEDED)
1323 + ->where('total', '>', 0)
1324 + ->count();
1325 +
1326 + $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
1327 + foreach ((array)$earlyPaymentHistory as $earlyPayment) {
1328 + $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
1329 + if ($paidCount > 1) {
1330 + $transacactionsCount += ($paidCount - 1);
1331 + }
1332 + }
1333 +
1334 + $transacactionsCount += (int) $this->getMeta('billed_cycles_offset', 0);
1335 + $transacactionsCount -= (int) $this->getMeta('billed_cycles_deduction', 0);
1336 +
1337 + return $transacactionsCount;
1338 + }
1339 +
1340 + /**
1341 + * Installment / split-pay plan: a finite-term subscription (a lifetime
1342 + * license paid off in a fixed number of charges), as opposed to an
1343 + * open-ended recurring subscription. The canonical structural signal is
1344 + * bill_times > 0 (0 = infinite/open-ended). Reused across analytics,
1345 + * filters and lifecycle handling — do NOT reintroduce title-string
1346 + * ("Split") matching, which the data does not reliably carry.
1347 + *
1348 + * @return bool
1349 + */
1350 + public function isInstallment()
1351 + {
1352 + return (int) $this->bill_times > 0;
1353 + }
1354 +
1355 + /**
1356 + * Installments still owed: 0 for open-ended plans, or once the term is
1357 + * fully paid.
1358 + *
1359 + * @return int
1360 + */
1361 + public function installmentsRemaining()
1362 + {
1363 + if (!$this->isInstallment()) {
1364 + return 0;
1365 + }
1366 +
1367 + return max(0, (int) $this->bill_times - (int) $this->bill_count);
1368 + }
1369 +
1370 + /**
1371 + * Has a finite installment plan collected every scheduled charge (end of
1372 + * term)? Open-ended plans never reach term end.
1373 + *
1374 + * @return bool
1375 + */
1376 + public function hasReachedTermEnd()
1377 + {
1378 + return $this->isInstallment() && (int) $this->bill_count >= (int) $this->bill_times;
1379 + }
1380 +
1381 + /**
1382 + * Full committed price of an installment contract: recurring_total x
1383 + * bill_times, in cents. 0 for open-ended plans (no fixed total). This is
1384 + * the per-row form of the SUM(recurring_total * bill_times) used by the
1385 + * subscription analytics aggregate.
1386 + *
1387 + * @return int
1388 + */
1389 + public function totalContractValue()
1390 + {
1391 + if (!$this->isInstallment()) {
1392 + return 0;
1393 + }
1394 +
1395 + return (int) $this->recurring_total * (int) $this->bill_times;
1396 + }
1397 +
1398 + /**
1399 + * Filter by plan type: 'installment' (finite term, bill_times > 0),
1400 + * 'recurring' (open-ended, bill_times = 0) or anything else (no filter).
1401 + * The bill_times threshold is kept identical to isInstallment() so the SQL
1402 + * and PHP definitions never drift apart.
1403 + */
1404 + public function scopeOfPlanType($query, $planType)
1405 + {
1406 + if ($planType === 'installment') {
1407 + return $query->where('bill_times', '>', 0);
1408 + }
1409 + if ($planType === 'recurring') {
1410 + return $query->where('bill_times', '<=', 0);
1411 + }
1412 +
1413 + return $query;
1414 + }
1415 +
1416 + /**
1417 + * Whether a lapsed/canceled subscription still has unexpired paid time to
1418 + * credit back on reactivation. Deliberately NOT hasAccessValidity() — that
1419 + * method answers "can the customer access content right now" and its status
1420 + * list is free to evolve for that purpose alone. This is its own copy so a
1421 + * future access-only change (e.g. a new status added for content gating)
1422 + * can't silently change how much reactivation trial credit gets granted.
1423 + *
1424 + * @return bool
1425 + */
1426 + public function hasReactivationTrialCredit(): bool
1427 + {
1428 + $validStatuses = [
1429 + Status::SUBSCRIPTION_ACTIVE,
1430 + Status::SUBSCRIPTION_TRIALING,
1431 + Status::SUBSCRIPTION_COMPLETED
1432 + ];
1433 +
1434 + if (in_array($this->status, $validStatuses)) {
1435 + return true;
1436 + }
1437 +
1438 + // No grace-period math here on purpose: past_due/expiring/failing fall through
1439 + // to the plain next_billing_date > now check below. If that date is still
1440 + // future, credit is granted same as any other status; if it's past, this
1441 + // returns false the same way the grace window would eventually clamp to via
1442 + // getReactivationTrialDays()'s <=1 floor — without a redundant grace-days
1443 + // lookup either way.
1444 +
1445 + $invalidStatuses = [
1446 + Status::SUBSCRIPTION_EXPIRED,
1447 + ];
1448 +
1449 + if (in_array($this->status, $invalidStatuses)
1450 + || (in_array($this->status, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED], true)
1451 + && !$this->hasReactivationAttempt())) {
1452 + return false;
1453 + }
1454 +
1455 + $nextBillingDate = $this->next_billing_date;
1456 +
1457 + if (!$nextBillingDate) {
1458 + $nextBillingDate = $this->guessNextBillingDate();
1459 + }
1460 +
1461 + return strtotime($nextBillingDate) > time();
1462 + }
1463 +
722 1464 public function getReactivationTrialDays()
723 1465 {
724 - if (!$this->hasAccessValidity()) {
1466 + if (!$this->hasReactivationTrialCredit()) {
725 1467 return 0;
726 1468 }
727 1469
728 1470 $lastPaidTransaction = OrderTransaction::query()
@@ -781,16 +1523,17 @@
781 1523 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
782 1524 ->first();
783 1525
784 1526 if ($theLastOrder) {
785 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1527 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1528 +
786 1529 if ($theLastOrder->type == 'renewal') {
787 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1530 + $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
788 1531 } else {
789 1532 if ($this->trial_days) {
790 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
1533 + $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($paidAnchor) + (int)($this->trial_days) * DAY_IN_SECONDS);
791 1534 } else {
792 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1535 + $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
793 1536 }
794 1537 }
795 1538 } else {
796 1539 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -807,9 +1550,9 @@
807 1550 *
808 1551 * Processes all candidates in batches to avoid memory issues.
809 1552 * The query example works as follows:
810 1553 * SELECT * FROM subscriptions WHERE
811 - status IN ('active', 'trialing', 'canceled')
1554 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
812 1555 AND next_billing_date IS NOT NULL
813 1556 AND id > 0 -- last processed ID for batch cursor
814 1557 AND next_billing_date < DATE_SUB(
815 1558 '2026-02-17 10:00:00',
@@ -852,20 +1595,26 @@
852 1595 foreach ($gracePeriodDays as $interval => $days) {
853 1596 $cutoffDates[$interval] = gmdate('Y-m-d H:i:s', $currentTime - ((int)$days * DAY_IN_SECONDS));
854 1597 }
855 1598
1599 + // Fallback cutoff for unknown/null billing intervals.
856 1600 $defaultGraceDays = 7;
857 1601 $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
858 1602 $knownIntervals = array_keys($cutoffDates);
859 1603
1604 + // Include canceled subscriptions to check if validity is yet to expired
1605 + // Exclude store-billed (manual/system) subscriptions — their expiry is
1606 + // handled by the invoice-based overdue flow
860 1607 $subscriptions = Subscription::query()
861 1608 ->whereIn('status', [
862 1609 Status::SUBSCRIPTION_ACTIVE,
863 1610 Status::SUBSCRIPTION_TRIALING,
864 1611 Status::SUBSCRIPTION_CANCELED,
865 - Status::SUBSCRIPTION_EXPIRING
866 -
1612 + Status::SUBSCRIPTION_EXPIRING,
1613 + Status::SUBSCRIPTION_FAILING,
1614 + Status::SUBSCRIPTION_PAST_DUE
867 1615 ])
1616 + ->whereNotIn('collection_method', ['manual', 'system'])
868 1617 ->whereNotNull('next_billing_date')
869 1618 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
870 1619 ->where('id', '>', $lastId)
871 1620 ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
@@ -873,11 +1622,14 @@
873 1622 $subQuery->whereIn('status', [
874 1623 Status::SUBSCRIPTION_ACTIVE,
875 1624 Status::SUBSCRIPTION_TRIALING,
876 1625 Status::SUBSCRIPTION_EXPIRING,
1626 + Status::SUBSCRIPTION_FAILING,
1627 + Status::SUBSCRIPTION_PAST_DUE,
877 1628 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
878 1629 $index = 0;
879 1630
1631 + // OR together one (interval + its cutoff) clause per known interval.
880 1632 foreach ($cutoffDates as $interval => $cutoff) {
881 1633 $method = $index === 0 ? 'where' : 'orWhere';
882 1634
883 1635 $dateQuery->{$method}(function ($intervalQuery) use ($interval, $cutoff) {
@@ -887,8 +1639,9 @@
887 1639
888 1640 $index++;
889 1641 }
890 1642
1643 + // Unknown/null intervals fall back to the default cutoff.
891 1644 $dateQuery->orWhere(function ($intervalQuery) use ($knownIntervals, $defaultCutoff) {
892 1645 $intervalQuery->where(function ($unknownIntervalQuery) use ($knownIntervals) {
893 1646 $unknownIntervalQuery->whereNotIn('billing_interval', $knownIntervals)
894 1647 ->orWhereNull('billing_interval');
@@ -894,8 +1647,9 @@
894 1647 ->orWhereNull('billing_interval');
895 1648 })->where('next_billing_date', '<', $defaultCutoff);
896 1649 });
897 1650 });
1651 + // Branch B: canceled subs expire the moment their paid period ends (no grace).
898 1652 })->orWhere(function ($subQuery) use ($now) {
899 1653 $subQuery->where('status', Status::SUBSCRIPTION_CANCELED)
900 1654 ->where('next_billing_date', '<', $now);
901 1655 });
@@ -914,21 +1668,26 @@
914 1668
915 1669 foreach ($subscriptions as $subscription) {
916 1670 $nextBillingTimestamp = strtotime($subscription->next_billing_date);
917 1671
1672 + // Skip unparseable/invalid dates.
918 1673 if (!$nextBillingTimestamp || $nextBillingTimestamp <= 0) {
919 1674 continue;
920 1675 }
921 1676
1677 + // Re-validate in PHP (SQL was a coarse filter) and derive the exact cutoff used as a write guard below.
922 1678 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1679 + // Superseded by an upgrade -> the new sub owns validity, leave this one alone.
923 1680 if (isset($subscription->config['upgraded_to_sub_id'])) {
924 1681 continue;
925 1682 }
926 1683
1684 + // Already processed in a prior run.
927 1685 if ($subscription->getMeta('validity_expired_at')) {
928 1686 continue;
929 1687 }
930 1688
1689 + // Paid period not over yet.
931 1690 if ($nextBillingTimestamp >= $currentTime) {
932 1691 continue;
933 1692 }
934 1693
@@ -937,8 +1696,9 @@
937 1696 $graceDays = $gracePeriodDays[$subscription->billing_interval] ?? $defaultGraceDays;
938 1697 $graceDays = max(0, (int)$graceDays);
939 1698 $cutoffTimestamp = $currentTime - ($graceDays * DAY_IN_SECONDS);
940 1699
1700 + // Still inside the grace window.
941 1701 if ($nextBillingTimestamp >= $cutoffTimestamp) {
942 1702 continue;
943 1703 }
944 1704
@@ -944,21 +1704,24 @@
944 1704
945 1705 $cutoff = gmdate('Y-m-d H:i:s', $cutoffTimestamp);
946 1706 }
947 1707
1708 + // Null out next_billing_date so the row can't be re-selected/re-processed.
948 1709 $updateData = [
949 1710 'next_billing_date' => NULL,
950 1711 'updated_at' => gmdate('Y-m-d H:i:s', $currentTime),
951 1712 ];
952 1713
1714 + // Canceled subs keep their status; only billing statuses flip to EXPIRED.
953 1715 if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
954 1716 $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
955 1717 }
956 1718
1719 + // Optimistic-lock write: only apply if status + past-cutoff still hold, so a concurrent
1720 + // renewal/cancel between SELECT and UPDATE can't be overwritten with a stale decision.
957 1721 $updated = Subscription::query()
958 1722 ->where('id', $subscription->id)
959 1723 ->where('status', $subscription->status)
960 - ->where('next_billing_date', $subscription->next_billing_date)
961 1724 ->where('next_billing_date', '<', $cutoff)
962 1725 ->update($updateData);
963 1726
964 1727 if (!$updated) {
@@ -972,8 +1735,9 @@
972 1735 if (!$subscription) {
973 1736 continue;
974 1737 }
975 1738
1739 + // Idempotency marker + audit timestamp for this expiry.
976 1740 $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s', $currentTime));
977 1741
978 1742 $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
979 1743 $subscription,