PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.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 1.3.19 All 49 releases
← All changes | app/Models/Subscription.php +291 -43 1.6.0 → 1.6.5 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)
@@ -818,16 +1015,19 @@
818 1015 {
819 1016 return $this->canReactivate();
820 1017 }
821 1018
822 - public function getReactivationNonceAction()
823 - {
824 - return 'fluent_cart_reactivate_subscription_' . $this->uuid;
825 - }
826 -
1019 + /**
1020 + * These links are minted in email and webhook contexts, where there is no
1021 + * current user. A wp_create_nonce() token bound to that user-less request
1022 + * stops verifying the moment the recipient logs in to act on it, so the link
1023 + * broke for the one journey it exists to serve. Authorization for the
1024 + * endpoint is the subscription-ownership check on the handling side, which
1025 + * a nonce never provided; the uuid alone is inert to anyone else.
1026 + */
827 1027 public function getReactivateUrl()
828 1028 {
829 - if (!$this->canReactive()) {
1029 + if (!$this->canReactivate()) {
830 1030 return '';
831 1031 }
832 1032
833 1033 return add_query_arg([
@@ -832,9 +1032,8 @@
832 1032
833 1033 return add_query_arg([
834 1034 'fluent-cart' => 'reactivate-subscription',
835 1035 'subscription_hash' => $this->uuid,
836 - '_wpnonce' => wp_create_nonce($this->getReactivationNonceAction()),
837 1036 ], home_url('/'));
838 1037 }
839 1038
840 1039 public function getReactivateUrlAttribute()
@@ -863,11 +1062,11 @@
863 1062 if (in_array($this->status, $validAccessStatuses)) {
864 1063 return true;
865 1064 }
866 1065
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) {
1066 + // Past-due/expiring/failing keep access while the unpaid invoice is inside its
1067 + // dunning grace window; checkAndExpireSubscriptions() flips them to expired past that.
1068 + if (in_array($this->status, [Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_FAILING])) {
870 1069 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
871 1070 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
872 1071
873 1072 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
@@ -898,9 +1097,9 @@
898 1097 }
899 1098
900 1099 public function reSyncFromRemote()
901 1100 {
902 - if ($gateway = App::gateway($this->current_payment_method)) {
1101 + if ($gateway = $this->resolveGateway()) {
903 1102 if ($gateway->has('subscriptions')) {
904 1103 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
905 1104 }
906 1105 }
@@ -920,9 +1119,9 @@
920 1119 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
921 1120 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
922 1121 }
923 1122
924 - $gateway = App::gateway($this->current_payment_method);
1123 + $gateway = $this->resolveGateway();
925 1124
926 1125 // No vendor subscription (store-billed, or a vendor id that never landed) —
927 1126 // nothing to cancel at the gateway.
928 1127 if (!$this->vendor_subscription_id) {
@@ -965,14 +1164,8 @@
965 1164 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
966 1165 $updateData['canceled_at'] = NULL;
967 1166 }
968 1167
969 - $config = $this->config;
970 - if ($args['reason']) {
971 - $config['cancellation_reason'] = $args['reason'];
972 - }
973 - $updateData['config'] = $config;
974 -
975 1168 if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
976 1169 $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
977 1170 }
978 1171
@@ -985,8 +1178,12 @@
985 1178
986 1179 $this->fill($updateData);
987 1180 $this->save();
988 1181
1182 + if ($args['reason']) {
1183 + $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1184 + }
1185 +
989 1186 $note = $args['note'];
990 1187
991 1188 if (!$note) {
992 1189 $note = 'on customer request';
@@ -1185,11 +1382,59 @@
1185 1382
1186 1383 return $query;
1187 1384 }
1188 1385
1386 + /**
1387 + * Whether a lapsed/canceled subscription still has unexpired paid time to
1388 + * credit back on reactivation. Deliberately NOT hasAccessValidity() — that
1389 + * method answers "can the customer access content right now" and its status
1390 + * list is free to evolve for that purpose alone. This is its own copy so a
1391 + * future access-only change (e.g. a new status added for content gating)
1392 + * can't silently change how much reactivation trial credit gets granted.
1393 + *
1394 + * @return bool
1395 + */
1396 + public function hasReactivationTrialCredit(): bool
1397 + {
1398 + $validStatuses = [
1399 + Status::SUBSCRIPTION_ACTIVE,
1400 + Status::SUBSCRIPTION_TRIALING,
1401 + Status::SUBSCRIPTION_COMPLETED
1402 + ];
1403 +
1404 + if (in_array($this->status, $validStatuses)) {
1405 + return true;
1406 + }
1407 +
1408 + // No grace-period math here on purpose: past_due/expiring/failing fall through
1409 + // to the plain next_billing_date > now check below. If that date is still
1410 + // future, credit is granted same as any other status; if it's past, this
1411 + // returns false the same way the grace window would eventually clamp to via
1412 + // getReactivationTrialDays()'s <=1 floor — without a redundant grace-days
1413 + // lookup either way.
1414 +
1415 + $invalidStatuses = [
1416 + Status::SUBSCRIPTION_EXPIRED,
1417 + Status::SUBSCRIPTION_INTENDED,
1418 + Status::SUBSCRIPTION_PENDING
1419 + ];
1420 +
1421 + if (in_array($this->status, $invalidStatuses)) {
1422 + return false;
1423 + }
1424 +
1425 + $nextBillingDate = $this->next_billing_date;
1426 +
1427 + if (!$nextBillingDate) {
1428 + $nextBillingDate = $this->guessNextBillingDate();
1429 + }
1430 +
1431 + return strtotime($nextBillingDate) > time();
1432 + }
1433 +
1189 1434 public function getReactivationTrialDays()
1190 1435 {
1191 - if (!$this->hasAccessValidity()) {
1436 + if (!$this->hasReactivationTrialCredit()) {
1192 1437 return 0;
1193 1438 }
1194 1439
1195 1440 $lastPaidTransaction = OrderTransaction::query()
@@ -1248,16 +1493,17 @@
1248 1493 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1249 1494 ->first();
1250 1495
1251 1496 if ($theLastOrder) {
1252 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1497 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1498 +
1253 1499 if ($theLastOrder->type == 'renewal') {
1254 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1500 + $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
1255 1501 } else {
1256 1502 if ($this->trial_days) {
1257 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
1503 + $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($paidAnchor) + (int)($this->trial_days) * DAY_IN_SECONDS);
1258 1504 } else {
1259 - $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS);
1505 + $nextBillingDate = gmdate('Y-m-d H:i:s', SubscriptionHelper::addBillingInterval($paidAnchor, $this->billing_interval, SubscriptionHelper::getBillingSchedule($this)));
1260 1506 }
1261 1507 }
1262 1508 } else {
1263 1509 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -1274,9 +1520,9 @@
1274 1520 *
1275 1521 * Processes all candidates in batches to avoid memory issues.
1276 1522 * The query example works as follows:
1277 1523 * SELECT * FROM subscriptions WHERE
1278 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1524 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
1279 1525 AND next_billing_date IS NOT NULL
1280 1526 AND id > 0 -- last processed ID for batch cursor
1281 1527 AND next_billing_date < DATE_SUB(
1282 1528 '2026-02-17 10:00:00',
@@ -1333,8 +1579,9 @@
1333 1579 Status::SUBSCRIPTION_ACTIVE,
1334 1580 Status::SUBSCRIPTION_TRIALING,
1335 1581 Status::SUBSCRIPTION_CANCELED,
1336 1582 Status::SUBSCRIPTION_EXPIRING,
1583 + Status::SUBSCRIPTION_FAILING,
1337 1584 Status::SUBSCRIPTION_PAST_DUE
1338 1585 ])
1339 1586 ->whereNotIn('collection_method', ['manual', 'system'])
1340 1587 ->whereNotNull('next_billing_date')
@@ -1345,8 +1592,9 @@
1345 1592 $subQuery->whereIn('status', [
1346 1593 Status::SUBSCRIPTION_ACTIVE,
1347 1594 Status::SUBSCRIPTION_TRIALING,
1348 1595 Status::SUBSCRIPTION_EXPIRING,
1596 + Status::SUBSCRIPTION_FAILING,
1349 1597 Status::SUBSCRIPTION_PAST_DUE,
1350 1598 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1351 1599 $index = 0;
1352 1600
@@ -1496,5 +1744,5 @@
1496 1744
1497 1745 return $stats;
1498 1746 }
1499 1747
1500 -}
1748 +}