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 +322 -44 1.6.0 → 1.7.0 View file →
@@ -7,12 +7,13 @@
7 7 use FluentCart\App\App;
8 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;
11 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;
@@ -39,9 +40,9 @@
39 40 protected $table = 'fct_subscriptions';
40 41
41 42 protected $primaryKey = 'id';
42 43
43 - protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'permissions', 'display_item_name', 'system_charge_state'];
44 + protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'permissions', 'display_item_name', 'system_charge_state', 'payment_method_title'];
44 45
45 46 protected $guarded = ['id'];
46 47
47 48 protected $fillable = [
@@ -180,8 +181,69 @@
180 181 $this->attributes['config'] = $value;
181 182 }
182 183
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 + /**
184 246 * Customer-facing display name. When the config['item_attributes'] snapshot
185 247 * resolves it returns the product name with the labeled combination
186 248 * ("Cake - Flavor: Vanilla | Weight: 500 g"); otherwise the raw item_name
187 249 * (simple / pre-snapshot subscriptions).
@@ -215,8 +277,27 @@
215 277
216 278 return $postTitle !== '' ? $postTitle . ' - ' . $attributeDisplayTitleString : $attributeDisplayTitleString;
217 279 }
218 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 +
219 300 public function getUrlAttribute($value)
220 301 {
221 302 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
222 303 'vendor_subscription_id' => $this->vendor_subscription_id,
@@ -235,9 +316,8 @@
235 316 * use overriden status to show the correct status for customer
236 317 */
237 318 public function getOverriddenStatusAttribute($value)
238 319 {
239 - $variation = ProductVariation::find($this->variation_id);
240 320 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
241 321 return Status::SUBSCRIPTION_ACTIVE;
242 322 }
243 323
@@ -415,8 +495,10 @@
415 495 && Arr::get($chargeState, 'status') !== 'processing';
416 496
417 497 return [
418 498 'canEdit' => $canEdit,
499 + 'canEditVendorIds' => $this->canEditVendorIds(),
500 + 'canVerifyVendorIds' => $this->canVerifyVendorIds(),
419 501 'canPause' => $this->canPause(),
420 502 'canResume' => $this->canResume(),
421 503 'canFetch' => !$this->usesRenewalEngine() && $hasVendorId,
422 504 'canCancel' => $canCancel,
@@ -453,8 +535,18 @@
453 535 return $this->collection_method === 'system';
454 536 }
455 537
456 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 + /**
457 549 * Manual and system subscriptions are both billed by FluentCart's invoice
458 550 * engine (renewal invoices, overdue escalation, admin invoice actions).
459 551 * System additionally auto-charges a stored token per invoice.
460 552 *
@@ -493,8 +585,12 @@
493 585 ];
494 586
495 587 $recurringTotal = $this->recurring_total ?? 0;
496 588
589 + if ($schedule = SubscriptionHelper::getBillingSchedule($this)) {
590 + return Helper::generateScheduleSubscriptionInfo($schedule, $otherInfo, $recurringTotal, $this->currency) ?? '';
591 + }
592 +
497 593 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal, $this->currency) ?? '';
498 594 }
499 595
500 596 public function addLog($title, $description = '', $type = 'info', $by = '')
@@ -610,12 +706,72 @@
610 706 ->where('object_id', $this->variation_id)
611 707 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
612 708 }
613 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 +
614 770 public function canUpdatePaymentMethod()
615 771 {
616 - $gateway = App::gateway($this->current_payment_method);
617 - if (!$gateway || !in_array('card_update', $gateway->supportedFeatures)) {
772 + $gateway = $this->resolveGateway();
773 + if (!$gateway || !$gateway->has('card_update')) {
618 774 return false;
619 775 }
620 776
621 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
@@ -631,11 +787,9 @@
631 787 if ($this->usesRenewalEngine()) {
632 788 return false;
633 789 }
634 790
635 - $gateway = App::gateway($this->current_payment_method);
636 -
637 - if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) {
791 + if (!$this->switchPaymentConfig()) {
638 792 return false;
639 793 }
640 794
641 795 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
@@ -646,14 +800,9 @@
646 800 if (!$this->canSwitchPaymentMethod()) {
647 801 return [];
648 802 }
649 803
650 - $gateway = App::gateway($this->current_payment_method);
651 - if (!$gateway || empty($gateway->supportedFeatures['switch_payment_method'])) {
652 - return [];
653 - }
654 -
655 - return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []);
804 + return Arr::get($this->switchPaymentConfig(), 'supported_gateways', []);
656 805 }
657 806
658 807 public function canPause()
659 808 {
@@ -668,9 +817,9 @@
668 817 ]);
669 818 }
670 819
671 820 // Automatic subscriptions require gateway support
672 - $gateway = App::gateway($this->current_payment_method);
821 + $gateway = $this->resolveGateway();
673 822
674 823 if (!$gateway) {
675 824 return false;
676 825 }
@@ -675,9 +824,9 @@
675 824 return false;
676 825 }
677 826
678 827 // Check if gateway supports pause
679 - if (!in_array('pause_subscription', $gateway->supportedFeatures)) {
828 + if (!$gateway->has('pause_subscription')) {
680 829 return false;
681 830 }
682 831
683 832 // Default behavior for automatic subscriptions
@@ -722,15 +871,15 @@
722 871 return $this->status === Status::SUBSCRIPTION_PAUSED;
723 872 }
724 873
725 874
726 - $gateway = App::gateway($this->current_payment_method);
875 + $gateway = $this->resolveGateway();
727 876
728 877 if (!$gateway) {
729 878 return false;
730 879 }
731 880
732 - if (!in_array('resume_subscription', $gateway->supportedFeatures)) {
881 + if (!$gateway->has('resume_subscription')) {
733 882 return false;
734 883 }
735 884
736 885 // Default behavior
@@ -754,8 +903,56 @@
754 903 return $this->usesRenewalEngine();
755 904 }
756 905
757 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 + /**
758 955 * Update subscription details (for manual subscriptions)
759 956 *
760 957 * Allowed fields for manual subscriptions:
761 958 * - recurring_total: Update the next invoice/payment amount (in cents)
@@ -802,9 +999,13 @@
802 999 Status::SUBSCRIPTION_FAILING,
803 1000 Status::SUBSCRIPTION_EXPIRED,
804 1001 Status::SUBSCRIPTION_EXPIRING,
805 1002 Status::SUBSCRIPTION_PAST_DUE,
806 - ]);
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 + );
807 1008
808 1009 return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
809 1010 'subscription' => $this
810 1011 ]);
@@ -810,8 +1011,34 @@
810 1011 ]);
811 1012 }
812 1013
813 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 + /**
814 1041 * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
815 1042 * @return bool
816 1043 */
817 1044 public function canReactive()
@@ -818,16 +1045,19 @@
818 1045 {
819 1046 return $this->canReactivate();
820 1047 }
821 1048
822 - public function getReactivationNonceAction()
823 - {
824 - return 'fluent_cart_reactivate_subscription_' . $this->uuid;
825 - }
826 -
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 + */
827 1057 public function getReactivateUrl()
828 1058 {
829 - if (!$this->canReactive()) {
1059 + if (!$this->canReactivate()) {
830 1060 return '';
831 1061 }
832 1062
833 1063 return add_query_arg([
@@ -832,9 +1062,8 @@
832 1062
833 1063 return add_query_arg([
834 1064 'fluent-cart' => 'reactivate-subscription',
835 1065 'subscription_hash' => $this->uuid,
836 - '_wpnonce' => wp_create_nonce($this->getReactivationNonceAction()),
837 1066 ], home_url('/'));
838 1067 }
839 1068
840 1069 public function getReactivateUrlAttribute()
@@ -863,11 +1092,11 @@
863 1092 if (in_array($this->status, $validAccessStatuses)) {
864 1093 return true;
865 1094 }
866 1095
867 - // Past-due keeps access while the unpaid invoice is inside its dunning
868 - // grace window; the expiry crons flip it to expired past that.
869 - if ($this->status === Status::SUBSCRIPTION_PAST_DUE) {
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])) {
870 1099 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
871 1100 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
872 1101
873 1102 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
@@ -898,9 +1127,9 @@
898 1127 }
899 1128
900 1129 public function reSyncFromRemote()
901 1130 {
902 - if ($gateway = App::gateway($this->current_payment_method)) {
1131 + if ($gateway = $this->resolveGateway()) {
903 1132 if ($gateway->has('subscriptions')) {
904 1133 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
905 1134 }
906 1135 }
@@ -920,9 +1149,9 @@
920 1149 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
921 1150 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
922 1151 }
923 1152
924 - $gateway = App::gateway($this->current_payment_method);
1153 + $gateway = $this->resolveGateway();
925 1154
926 1155 // No vendor subscription (store-billed, or a vendor id that never landed) —
927 1156 // nothing to cancel at the gateway.
928 1157 if (!$this->vendor_subscription_id) {
@@ -965,14 +1194,8 @@
965 1194 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
966 1195 $updateData['canceled_at'] = NULL;
967 1196 }
968 1197
969 - $config = $this->config;
970 - if ($args['reason']) {
971 - $config['cancellation_reason'] = $args['reason'];
972 - }
973 - $updateData['config'] = $config;
974 -
975 1198 if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
976 1199 $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
977 1200 }
978 1201
@@ -985,8 +1208,12 @@
985 1208
986 1209 $this->fill($updateData);
987 1210 $this->save();
988 1211
1212 + if ($args['reason']) {
1213 + $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1214 + }
1215 +
989 1216 $note = $args['note'];
990 1217
991 1218 if (!$note) {
992 1219 $note = 'on customer request';
@@ -1185,11 +1412,59 @@
1185 1412
1186 1413 return $query;
1187 1414 }
1188 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 +
1189 1464 public function getReactivationTrialDays()
1190 1465 {
1191 - if (!$this->hasAccessValidity()) {
1466 + if (!$this->hasReactivationTrialCredit()) {
1192 1467 return 0;
1193 1468 }
1194 1469
1195 1470 $lastPaidTransaction = OrderTransaction::query()
@@ -1248,16 +1523,17 @@
1248 1523 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1249 1524 ->first();
1250 1525
1251 1526 if ($theLastOrder) {
1252 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1527 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1528 +
1253 1529 if ($theLastOrder->type == 'renewal') {
1254 - $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)));
1255 1531 } else {
1256 1532 if ($this->trial_days) {
1257 - $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);
1258 1534 } else {
1259 - $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)));
1260 1536 }
1261 1537 }
1262 1538 } else {
1263 1539 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -1274,9 +1550,9 @@
1274 1550 *
1275 1551 * Processes all candidates in batches to avoid memory issues.
1276 1552 * The query example works as follows:
1277 1553 * SELECT * FROM subscriptions WHERE
1278 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1554 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
1279 1555 AND next_billing_date IS NOT NULL
1280 1556 AND id > 0 -- last processed ID for batch cursor
1281 1557 AND next_billing_date < DATE_SUB(
1282 1558 '2026-02-17 10:00:00',
@@ -1333,8 +1609,9 @@
1333 1609 Status::SUBSCRIPTION_ACTIVE,
1334 1610 Status::SUBSCRIPTION_TRIALING,
1335 1611 Status::SUBSCRIPTION_CANCELED,
1336 1612 Status::SUBSCRIPTION_EXPIRING,
1613 + Status::SUBSCRIPTION_FAILING,
1337 1614 Status::SUBSCRIPTION_PAST_DUE
1338 1615 ])
1339 1616 ->whereNotIn('collection_method', ['manual', 'system'])
1340 1617 ->whereNotNull('next_billing_date')
@@ -1345,8 +1622,9 @@
1345 1622 $subQuery->whereIn('status', [
1346 1623 Status::SUBSCRIPTION_ACTIVE,
1347 1624 Status::SUBSCRIPTION_TRIALING,
1348 1625 Status::SUBSCRIPTION_EXPIRING,
1626 + Status::SUBSCRIPTION_FAILING,
1349 1627 Status::SUBSCRIPTION_PAST_DUE,
1350 1628 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1351 1629 $index = 0;
1352 1630
@@ -1496,5 +1774,5 @@
1496 1774
1497 1775 return $stats;
1498 1776 }
1499 1777
1500 -}
1778 +}