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 +855 -51 1.4.1 → 1.7.0 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 = [
@@ -160,9 +164,9 @@
160 164 public function getConfigAttribute($value)
161 165 {
162 166 if (is_string($value)) {
163 167 $decoded = json_decode($value, true);
164 - return $decoded ?: $value;
168 + return is_array($decoded) ? $decoded : $value;
165 169 }
166 170 return $value ?: [];
167 171 }
168 172
@@ -176,8 +180,124 @@
176 180
177 181 $this->attributes['config'] = $value;
178 182 }
179 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 +
180 300 public function getUrlAttribute($value)
181 301 {
182 302 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
183 303 'vendor_subscription_id' => $this->vendor_subscription_id,
@@ -196,9 +316,8 @@
196 316 * use overriden status to show the correct status for customer
197 317 */
198 318 public function getOverriddenStatusAttribute($value)
199 319 {
200 - $variation = ProductVariation::find($this->variation_id);
201 320 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
202 321 return Status::SUBSCRIPTION_ACTIVE;
203 322 }
204 323
@@ -208,8 +327,44 @@
208 327
209 328 return $this->status;
210 329 }
211 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 +
212 367 public function getBillingInfoAttribute($value)
213 368 {
214 369 $billingInfo = '';
215 370 $metaKey = 'active_payment_method';
@@ -290,8 +445,131 @@
290 445 return $this->getSubscriptionInfo();
291 446 }
292 447
293 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 + /**
294 572 * Helper method to get subscription info
295 573 *
296 574 * @return string
297 575 */
@@ -307,8 +585,12 @@
307 585 ];
308 586
309 587 $recurringTotal = $this->recurring_total ?? 0;
310 588
589 + if ($schedule = SubscriptionHelper::getBillingSchedule($this)) {
590 + return Helper::generateScheduleSubscriptionInfo($schedule, $otherInfo, $recurringTotal, $this->currency) ?? '';
591 + }
592 +
311 593 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal, $this->currency) ?? '';
312 594 }
313 595
314 596 public function addLog($title, $description = '', $type = 'info', $by = '')
@@ -424,12 +706,72 @@
424 706 ->where('object_id', $this->variation_id)
425 707 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
426 708 }
427 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 +
428 770 public function canUpdatePaymentMethod()
429 771 {
430 - $gateway = App::gateway($this->current_payment_method);
431 - if ($gateway && !in_array('card_update', $gateway->supportedFeatures)) {
772 + $gateway = $this->resolveGateway();
773 + if (!$gateway || !$gateway->has('card_update')) {
432 774 return false;
433 775 }
434 776
435 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
@@ -436,11 +778,18 @@
436 778 }
437 779
438 780 public function canSwitchPaymentMethod()
439 781 {
440 - $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 + }
441 790
442 - if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) {
791 + if (!$this->switchPaymentConfig()) {
443 792 return false;
444 793 }
445 794
446 795 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
@@ -447,36 +796,268 @@
447 796 }
448 797
449 798 public function switchablePaymentMethods()
450 799 {
451 - $gateway = App::gateway($this->current_payment_method);
452 - if ($gateway && empty($gateway->supportedFeatures['switch_payment_method'])) {
800 + if (!$this->canSwitchPaymentMethod()) {
453 801 return [];
454 802 }
455 803
456 - return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []);
804 + return Arr::get($this->switchPaymentConfig(), 'supported_gateways', []);
457 805 }
458 806
459 - public function canReactive()
807 + public function canPause()
460 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 + {
461 987 if (!App::isProActive()) {
462 - return '';
988 + return false;
463 989 }
464 990
465 991 if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) {
466 - return '';
992 + return false;
467 993 }
468 994
469 - $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 + );
470 1008
471 - return apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
1009 + return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
472 1010 'subscription' => $this
473 1011 ]);
474 1012 }
475 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 + */
476 1057 public function getReactivateUrl()
477 1058 {
478 - if (!$this->canReactive()) {
1059 + if (!$this->canReactivate()) {
479 1060 return '';
480 1061 }
481 1062
482 1063 return add_query_arg([
@@ -511,11 +1092,19 @@
511 1092 if (in_array($this->status, $validAccessStatuses)) {
512 1093 return true;
513 1094 }
514 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 +
515 1105 $invalidStatuses = [
516 1106 Status::SUBSCRIPTION_EXPIRED,
517 - Status::SUBSCRIPTION_PAST_DUE,
518 1107 Status::SUBSCRIPTION_INTENDED,
519 1108 Status::SUBSCRIPTION_PENDING
520 1109 ];
521 1110
@@ -538,9 +1127,9 @@
538 1127 }
539 1128
540 1129 public function reSyncFromRemote()
541 1130 {
542 - if ($gateway = App::gateway($this->current_payment_method)) {
1131 + if ($gateway = $this->resolveGateway()) {
543 1132 if ($gateway->has('subscriptions')) {
544 1133 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
545 1134 }
546 1135 }
@@ -560,11 +1149,18 @@
560 1149 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
561 1150 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
562 1151 }
563 1152
564 - $gateway = App::gateway($this->current_payment_method);
1153 + $gateway = $this->resolveGateway();
565 1154
566 - 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')) {
567 1163 $cancelArgs = [
568 1164 'subscription_id' => $this->id,
569 1165 'parent_order_id' => $this->parent_order_id,
570 1166 'mode' => $this->order->mode,
@@ -580,8 +1176,9 @@
580 1176 }
581 1177
582 1178 $updateData = array_filter($vendorCanceled);
583 1179 } else {
1180 + // Vendor subscription exists but this gateway cannot cancel it — it stays live.
584 1181 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
585 1182 $updateData = [
586 1183 'canceled_at' => gmdate('Y-m-d H:i:s', time())
587 1184 ];
@@ -597,21 +1194,26 @@
597 1194 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
598 1195 $updateData['canceled_at'] = NULL;
599 1196 }
600 1197
601 - $config = $this->config;
602 - if ($args['reason']) {
603 - $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());
604 1200 }
605 - $updateData['config'] = $config;
606 1201
607 - if (Arr::get($args, 'effective_from') === 'immediately') {
608 - $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;
609 1207 }
610 1208
611 1209 $this->fill($updateData);
612 1210 $this->save();
613 1211
1212 + if ($args['reason']) {
1213 + $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1214 + }
1215 +
614 1216 $note = $args['note'];
615 1217
616 1218 if (!$note) {
617 1219 $note = 'on customer request';
@@ -616,10 +1218,11 @@
616 1218 if (!$note) {
617 1219 $note = 'on customer request';
618 1220 }
619 1221
620 - if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) {
621 - (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']);
622 1225 }
623 1226
624 1227 if ($args['note']) {
625 1228 $this->order->note = $note;
@@ -642,8 +1245,36 @@
642 1245
643 1246 return $this->recurring_total;
644 1247 }
645 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 +
646 1277 public function getRequiredBillTimes()
647 1278 {
648 1279 $billTimes = (int)$this->bill_times;
649 1280
@@ -649,23 +1280,10 @@
649 1280
650 1281 if ($billTimes > 0) {
651 1282 $billTimes = $billTimes - $this->bill_count;
652 1283 if ($billTimes <= 0) {
653 - $transacactionsCount = OrderTransaction::query()
654 - ->where('subscription_id', $this->id)
655 - ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
656 - ->where('status', Status::TRANSACTION_SUCCEEDED)
657 - ->where('total', '>', 0)
658 - ->count();
1284 + $transacactionsCount = $this->calculateBillCount();
659 1285
660 - $earlyPaymentHistory = $this->getMeta('early_payment_history', []);
661 - foreach ($earlyPaymentHistory as $earlyPayment) {
662 - $paidCount = (int) Arr::get($earlyPayment, 'count', 1);
663 - if ($paidCount > 1) {
664 - $transacactionsCount += ($paidCount - 1);
665 - }
666 - }
667 -
668 1286 if ($transacactionsCount != $this->bill_count) {
669 1287 $this->bill_count = $transacactionsCount;
670 1288 $this->save();
671 1289 }
@@ -681,11 +1299,172 @@
681 1299
682 1300 return $billTimes;
683 1301 }
684 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 +
685 1464 public function getReactivationTrialDays()
686 1465 {
687 - if (!$this->hasAccessValidity()) {
1466 + if (!$this->hasReactivationTrialCredit()) {
688 1467 return 0;
689 1468 }
690 1469
691 1470 $lastPaidTransaction = OrderTransaction::query()
@@ -744,16 +1523,17 @@
744 1523 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
745 1524 ->first();
746 1525
747 1526 if ($theLastOrder) {
748 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1527 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1528 +
749 1529 if ($theLastOrder->type == 'renewal') {
750 - $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)));
751 1531 } else {
752 1532 if ($this->trial_days) {
753 - $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);
754 1534 } else {
755 - $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)));
756 1536 }
757 1537 }
758 1538 } else {
759 1539 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -770,9 +1550,9 @@
770 1550 *
771 1551 * Processes all candidates in batches to avoid memory issues.
772 1552 * The query example works as follows:
773 1553 * SELECT * FROM subscriptions WHERE
774 - status IN ('active', 'trialing', 'canceled')
1554 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
775 1555 AND next_billing_date IS NOT NULL
776 1556 AND id > 0 -- last processed ID for batch cursor
777 1557 AND next_billing_date < DATE_SUB(
778 1558 '2026-02-17 10:00:00',
@@ -815,18 +1595,26 @@
815 1595 foreach ($gracePeriodDays as $interval => $days) {
816 1596 $cutoffDates[$interval] = gmdate('Y-m-d H:i:s', $currentTime - ((int)$days * DAY_IN_SECONDS));
817 1597 }
818 1598
1599 + // Fallback cutoff for unknown/null billing intervals.
819 1600 $defaultGraceDays = 7;
820 1601 $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
821 1602 $knownIntervals = array_keys($cutoffDates);
822 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
823 1607 $subscriptions = Subscription::query()
824 1608 ->whereIn('status', [
825 1609 Status::SUBSCRIPTION_ACTIVE,
826 1610 Status::SUBSCRIPTION_TRIALING,
827 1611 Status::SUBSCRIPTION_CANCELED,
1612 + Status::SUBSCRIPTION_EXPIRING,
1613 + Status::SUBSCRIPTION_FAILING,
1614 + Status::SUBSCRIPTION_PAST_DUE
828 1615 ])
1616 + ->whereNotIn('collection_method', ['manual', 'system'])
829 1617 ->whereNotNull('next_billing_date')
830 1618 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
831 1619 ->where('id', '>', $lastId)
832 1620 ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
@@ -833,11 +1621,15 @@
833 1621 $query->where(function ($subQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
834 1622 $subQuery->whereIn('status', [
835 1623 Status::SUBSCRIPTION_ACTIVE,
836 1624 Status::SUBSCRIPTION_TRIALING,
1625 + Status::SUBSCRIPTION_EXPIRING,
1626 + Status::SUBSCRIPTION_FAILING,
1627 + Status::SUBSCRIPTION_PAST_DUE,
837 1628 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
838 1629 $index = 0;
839 1630
1631 + // OR together one (interval + its cutoff) clause per known interval.
840 1632 foreach ($cutoffDates as $interval => $cutoff) {
841 1633 $method = $index === 0 ? 'where' : 'orWhere';
842 1634
843 1635 $dateQuery->{$method}(function ($intervalQuery) use ($interval, $cutoff) {
@@ -847,8 +1639,9 @@
847 1639
848 1640 $index++;
849 1641 }
850 1642
1643 + // Unknown/null intervals fall back to the default cutoff.
851 1644 $dateQuery->orWhere(function ($intervalQuery) use ($knownIntervals, $defaultCutoff) {
852 1645 $intervalQuery->where(function ($unknownIntervalQuery) use ($knownIntervals) {
853 1646 $unknownIntervalQuery->whereNotIn('billing_interval', $knownIntervals)
854 1647 ->orWhereNull('billing_interval');
@@ -854,8 +1647,9 @@
854 1647 ->orWhereNull('billing_interval');
855 1648 })->where('next_billing_date', '<', $defaultCutoff);
856 1649 });
857 1650 });
1651 + // Branch B: canceled subs expire the moment their paid period ends (no grace).
858 1652 })->orWhere(function ($subQuery) use ($now) {
859 1653 $subQuery->where('status', Status::SUBSCRIPTION_CANCELED)
860 1654 ->where('next_billing_date', '<', $now);
861 1655 });
@@ -874,21 +1668,26 @@
874 1668
875 1669 foreach ($subscriptions as $subscription) {
876 1670 $nextBillingTimestamp = strtotime($subscription->next_billing_date);
877 1671
1672 + // Skip unparseable/invalid dates.
878 1673 if (!$nextBillingTimestamp || $nextBillingTimestamp <= 0) {
879 1674 continue;
880 1675 }
881 1676
1677 + // Re-validate in PHP (SQL was a coarse filter) and derive the exact cutoff used as a write guard below.
882 1678 if ($subscription->status === Status::SUBSCRIPTION_CANCELED) {
1679 + // Superseded by an upgrade -> the new sub owns validity, leave this one alone.
883 1680 if (isset($subscription->config['upgraded_to_sub_id'])) {
884 1681 continue;
885 1682 }
886 1683
1684 + // Already processed in a prior run.
887 1685 if ($subscription->getMeta('validity_expired_at')) {
888 1686 continue;
889 1687 }
890 1688
1689 + // Paid period not over yet.
891 1690 if ($nextBillingTimestamp >= $currentTime) {
892 1691 continue;
893 1692 }
894 1693
@@ -897,8 +1696,9 @@
897 1696 $graceDays = $gracePeriodDays[$subscription->billing_interval] ?? $defaultGraceDays;
898 1697 $graceDays = max(0, (int)$graceDays);
899 1698 $cutoffTimestamp = $currentTime - ($graceDays * DAY_IN_SECONDS);
900 1699
1700 + // Still inside the grace window.
901 1701 if ($nextBillingTimestamp >= $cutoffTimestamp) {
902 1702 continue;
903 1703 }
904 1704
@@ -904,21 +1704,24 @@
904 1704
905 1705 $cutoff = gmdate('Y-m-d H:i:s', $cutoffTimestamp);
906 1706 }
907 1707
1708 + // Null out next_billing_date so the row can't be re-selected/re-processed.
908 1709 $updateData = [
909 1710 'next_billing_date' => NULL,
910 1711 'updated_at' => gmdate('Y-m-d H:i:s', $currentTime),
911 1712 ];
912 1713
1714 + // Canceled subs keep their status; only billing statuses flip to EXPIRED.
913 1715 if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) {
914 1716 $updateData['status'] = Status::SUBSCRIPTION_EXPIRED;
915 1717 }
916 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.
917 1721 $updated = Subscription::query()
918 1722 ->where('id', $subscription->id)
919 1723 ->where('status', $subscription->status)
920 - ->where('next_billing_date', $subscription->next_billing_date)
921 1724 ->where('next_billing_date', '<', $cutoff)
922 1725 ->update($updateData);
923 1726
924 1727 if (!$updated) {
@@ -932,8 +1735,9 @@
932 1735 if (!$subscription) {
933 1736 continue;
934 1737 }
935 1738
1739 + // Idempotency marker + audit timestamp for this expiry.
936 1740 $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s', $currentTime));
937 1741
938 1742 $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired(
939 1743 $subscription,