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 +658 -37 1.5.4 → 1.7.0 View file →
@@ -4,15 +4,16 @@
4 4
5 5 use FluentCart\Api\CurrencySettings;
6 6 use FluentCart\Api\StoreSettings;
7 7 use FluentCart\App\App;
8 -use FluentCart\App\Events\Subscription\SubscriptionCanceled;
9 8 use FluentCart\App\Helpers\AttributeHelper;
10 9 use FluentCart\App\Helpers\Helper;
11 10 use FluentCart\App\Helpers\Status;
11 +use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
12 +use FluentCart\App\Modules\PaymentMethods\Core\PaymentGatewayInterface;
13 +use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
12 14 use FluentCart\App\Models\Concerns\CanUpdateBatch;
13 15 use FluentCart\App\Models\Concerns\HasActivity;
14 -use FluentCart\App\Services\Payments\PaymentHelper;
15 16 use FluentCart\App\Services\Payments\SubscriptionHelper;
16 17 use FluentCart\App\Services\TemplateService;
17 18 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
18 19 use FluentCart\Framework\Database\Orm\Relations\HasMany;
@@ -25,8 +26,10 @@
25 26 * Meta Model - DB Model for Meta table
26 27 *
27 28 * Database Model
28 29 *
30 + * @property string $uuid
31 + *
29 32 * @package FluentCart\App\Models
30 33 *
31 34 * @version 1.0.0
32 35 */
@@ -37,9 +40,9 @@
37 40 protected $table = 'fct_subscriptions';
38 41
39 42 protected $primaryKey = 'id';
40 43
41 - protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'display_item_name'];
44 + protected $appends = ['url', 'payment_info', 'billingInfo', 'overridden_status', 'currency', 'reactivate_url', 'permissions', 'display_item_name', 'system_charge_state', 'payment_method_title'];
42 45
43 46 protected $guarded = ['id'];
44 47
45 48 protected $fillable = [
@@ -161,9 +164,9 @@
161 164 public function getConfigAttribute($value)
162 165 {
163 166 if (is_string($value)) {
164 167 $decoded = json_decode($value, true);
165 - return $decoded ?: $value;
168 + return is_array($decoded) ? $decoded : $value;
166 169 }
167 170 return $value ?: [];
168 171 }
169 172
@@ -178,8 +181,69 @@
178 181 $this->attributes['config'] = $value;
179 182 }
180 183
181 184 /**
185 + * Merge keys into the config blob under a row lock.
186 + *
187 + * Every writer of this column must go through here. `config` is a single JSON
188 + * document written by the cancel path, both Stripe paths and both PayPal paths;
189 + * a plain read-merge-write loses whichever concurrent write commits first, and a
190 + * renewal landing during a payment-method switch is not a rare pairing.
191 + *
192 + * @param array $values keys to set; existing keys not named here survive
193 + * @return array the merged config as committed
194 + */
195 + public function mergeConfig(array $values): array
196 + {
197 + $current = $this->config;
198 + $current = is_array($current) ? $current : [];
199 +
200 + if (!$values) {
201 + return $current;
202 + }
203 +
204 + $db = static::query()->getConnection();
205 + $db->beginTransaction();
206 +
207 + try {
208 + $locked = static::query()
209 + ->where('id', $this->getKey())
210 + ->lockForUpdate()
211 + ->first();
212 +
213 + if (!$locked) {
214 + $db->rollBack();
215 + return $current;
216 + }
217 +
218 + $stored = $locked->config;
219 + $stored = is_array($stored) ? $stored : [];
220 + $merged = array_merge($stored, $values);
221 +
222 + // Query-builder update bypasses setConfigAttribute, so encode with the
223 + // same flags the mutator uses.
224 + static::query()
225 + ->where('id', $this->getKey())
226 + ->update([
227 + 'config' => json_encode($merged, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
228 + ]);
229 +
230 + $db->commit();
231 + } catch (\Exception $e) {
232 + $db->rollBack();
233 + throw $e;
234 + }
235 +
236 + // Only `config` was written, so only `config` is clean now — a bare
237 + // syncOriginal() would also mark the caller's unsaved edits as persisted
238 + // and their next save() would drop them.
239 + $this->setAttribute('config', $merged);
240 + $this->syncOriginalAttribute('config');
241 +
242 + return $merged;
243 + }
244 +
245 + /**
182 246 * Customer-facing display name. When the config['item_attributes'] snapshot
183 247 * resolves it returns the product name with the labeled combination
184 248 * ("Cake - Flavor: Vanilla | Weight: 500 g"); otherwise the raw item_name
185 249 * (simple / pre-snapshot subscriptions).
@@ -213,8 +277,27 @@
213 277
214 278 return $postTitle !== '' ? $postTitle . ' - ' . $attributeDisplayTitleString : $attributeDisplayTitleString;
215 279 }
216 280
281 + /**
282 + * Display label of the backing gateway ("Authorize.Net", "Cash"), the same
283 + * source StatusHelper stamps into order.payment_method_title. Empty when the
284 + * slug resolves to no registered gateway.
285 + */
286 + public function getPaymentMethodTitleAttribute(): string
287 + {
288 + $gateway = $this->resolveGateway();
289 + if (!$gateway) {
290 + return '';
291 + }
292 +
293 + $title = method_exists($gateway, 'getMeta')
294 + ? $gateway->getMeta('title')
295 + : Arr::get($gateway->meta(), 'title');
296 +
297 + return (string) $title;
298 + }
299 +
217 300 public function getUrlAttribute($value)
218 301 {
219 302 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
220 303 'vendor_subscription_id' => $this->vendor_subscription_id,
@@ -233,9 +316,8 @@
233 316 * use overriden status to show the correct status for customer
234 317 */
235 318 public function getOverriddenStatusAttribute($value)
236 319 {
237 - $variation = ProductVariation::find($this->variation_id);
238 320 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
239 321 return Status::SUBSCRIPTION_ACTIVE;
240 322 }
241 323
@@ -245,8 +327,44 @@
245 327
246 328 return $this->status;
247 329 }
248 330
331 + /**
332 + * Auto-charge bookkeeping for system subscriptions (attempt count, next retry,
333 + * last error, processing marker). Null for every other collection method —
334 + * guarded before the meta lookup so manual/automatic subscriptions pay nothing.
335 + */
336 + public function getSystemChargeStateAttribute()
337 + {
338 + if ($this->collection_method !== 'system') {
339 + return null;
340 + }
341 +
342 + $meta = $this->meta->where('meta_key', 'system_charge_state')->first();
343 +
344 + if (!$meta) {
345 + return null;
346 + }
347 +
348 + return is_string($meta->meta_value) ? json_decode($meta->meta_value, true) : $meta->meta_value;
349 + }
350 +
351 + public function getHasPendingSkipAttribute(): bool
352 + {
353 + return $this->hasPendingSkip();
354 + }
355 +
356 + public function getLastSkippedPeriodAttribute()
357 + {
358 + $skipped = $this->getMeta('skipped_periods', []);
359 +
360 + if (!is_array($skipped) || empty($skipped)) {
361 + return null;
362 + }
363 +
364 + return end($skipped) ?: null;
365 + }
366 +
249 367 public function getBillingInfoAttribute($value)
250 368 {
251 369 $billingInfo = '';
252 370 $metaKey = 'active_payment_method';
@@ -327,8 +445,131 @@
327 445 return $this->getSubscriptionInfo();
328 446 }
329 447
330 448 /**
449 + * Get subscription permissions for the current user
450 + * Returns what actions can be performed on this subscription
451 + *
452 + * @return array
453 + */
454 + public function getPermissionsAttribute(): array
455 + {
456 + $status = strtolower($this->status);
457 + $hasVendorId = !empty($this->vendor_subscription_id);
458 + $terminalStatuses = [
459 + Status::SUBSCRIPTION_CANCELED,
460 + Status::SUBSCRIPTION_EXPIRED,
461 + Status::SUBSCRIPTION_COMPLETED,
462 + ];
463 +
464 + $canEdit = $this->usesRenewalEngine() && !in_array($status, $terminalStatuses);
465 + $canCancel = !in_array($status, $terminalStatuses);
466 +
467 + // One open-invoice lookup shared by the invoice actions below. Only runs
468 + // for store-billed subscriptions in states where any of them can apply.
469 + $hasOpenInvoice = false;
470 + $chargeableStatuses = [
471 + Status::SUBSCRIPTION_ACTIVE,
472 + Status::SUBSCRIPTION_TRIALING,
473 + Status::SUBSCRIPTION_PAST_DUE,
474 + Status::SUBSCRIPTION_EXPIRED,
475 + ];
476 + if ($this->usesRenewalEngine() && in_array($status, $chargeableStatuses) && $this->parent_order_id) {
477 + $hasOpenInvoice = Order::query()
478 + ->where('parent_id', $this->parent_order_id)
479 + ->where('type', Status::ORDER_TYPE_RENEWAL)
480 + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
481 + ->exists();
482 + }
483 +
484 + $canManageRenewal = $this->usesRenewalEngine()
485 + && in_array($status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])
486 + && $this->next_billing_date
487 + && !$hasOpenInvoice;
488 +
489 + // Admin "Charge Now": system subscription with an open invoice whose charge
490 + // is not currently settling at the gateway (processing marker).
491 + $chargeState = $this->isSystem() ? ($this->system_charge_state ?: []) : [];
492 + $canChargeNow = $this->isSystem()
493 + && $hasOpenInvoice
494 + && in_array($status, $chargeableStatuses)
495 + && Arr::get($chargeState, 'status') !== 'processing';
496 +
497 + return [
498 + 'canEdit' => $canEdit,
499 + 'canEditVendorIds' => $this->canEditVendorIds(),
500 + 'canVerifyVendorIds' => $this->canVerifyVendorIds(),
501 + 'canPause' => $this->canPause(),
502 + 'canResume' => $this->canResume(),
503 + 'canFetch' => !$this->usesRenewalEngine() && $hasVendorId,
504 + 'canCancel' => $canCancel,
505 + // Admin one-click reactivate is for store-billed subscriptions only (the REST
506 + // endpoint rejects automatic); automatic reactivation runs through the gateway
507 + // URL flow, gated by canReactivate().
508 + 'canAdminReactivate' => $this->usesRenewalEngine() && $this->canReactivate(),
509 + 'canCreateRenewal' => $canManageRenewal,
510 + 'canSkipRenewal' => $canManageRenewal && !$this->hasPendingSkip(),
511 + 'canChargeNow' => $canChargeNow,
512 + // Surfaced in the Edit modal: an already-issued renewal invoice is
513 + // re-synced to the edited amount when it exists.
514 + 'hasPendingRenewal' => $hasOpenInvoice,
515 + ];
516 + }
517 +
518 + /**
519 + * Check if this is a manual subscription
520 + *
521 + * @return bool
522 + */
523 + public function isManual(): bool
524 + {
525 + return $this->collection_method === 'manual';
526 + }
527 +
528 + /**
529 + * Check if this is a system (auto-charged, store-billed) subscription
530 + *
531 + * @return bool
532 + */
533 + public function isSystem(): bool
534 + {
535 + return $this->collection_method === 'system';
536 + }
537 +
538 + /**
539 + * Check if this is a gateway-billed (automatic) subscription
540 + *
541 + * @return bool
542 + */
543 + public function isAutomatic(): bool
544 + {
545 + return $this->collection_method === Status::SUBSCRIPTION_METHOD_AUTOMATIC;
546 + }
547 +
548 + /**
549 + * Manual and system subscriptions are both billed by FluentCart's invoice
550 + * engine (renewal invoices, overdue escalation, admin invoice actions).
551 + * System additionally auto-charges a stored token per invoice.
552 + *
553 + * @return bool
554 + */
555 + public function usesRenewalEngine(): bool
556 + {
557 + return in_array($this->collection_method, ['manual', 'system'], true);
558 + }
559 +
560 + /**
561 + * Store-billed (manual/system) with a future due date has nothing to charge yet —
562 + * reactivation should flip the subscription active locally instead of checkout.
563 + *
564 + * @return bool
565 + */
566 + public function shouldSubscriptionActiveLocally(): bool
567 + {
568 + return $this->usesRenewalEngine() && $this->next_billing_date && strtotime($this->next_billing_date) > time();
569 + }
570 +
571 + /**
331 572 * Helper method to get subscription info
332 573 *
333 574 * @return string
334 575 */
@@ -344,8 +585,12 @@
344 585 ];
345 586
346 587 $recurringTotal = $this->recurring_total ?? 0;
347 588
589 + if ($schedule = SubscriptionHelper::getBillingSchedule($this)) {
590 + return Helper::generateScheduleSubscriptionInfo($schedule, $otherInfo, $recurringTotal, $this->currency) ?? '';
591 + }
592 +
348 593 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal, $this->currency) ?? '';
349 594 }
350 595
351 596 public function addLog($title, $description = '', $type = 'info', $by = '')
@@ -461,12 +706,72 @@
461 706 ->where('object_id', $this->variation_id)
462 707 ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]);
463 708 }
464 709
710 + /**
711 + * The gateway backing this subscription, or null when there is not one.
712 + *
713 + * `App::gateway()` returns the GatewayManager when its argument is null —
714 + * that is how `App::gateway()` with no argument is meant to work, but
715 + * `current_payment_method` is nullable, so a subscription with no payment
716 + * method resolves to the manager too. The manager is a truthy object, so
717 + * every `if (!$gateway)` guard in this class waved it through, and the next
718 + * line read `$gateway->supportedFeatures` as null.
719 + *
720 + * `in_array($needle, null)` is a TypeError on PHP 8, thrown from
721 + * `getPermissionsAttribute()` — an `$appends` entry — so it fires while
722 + * SERIALIZING. One subscription row with a blank payment method therefore
723 + * took down the entire subscriptions list response, not just its own row.
724 + *
725 + * Resolve through here rather than calling `App::gateway()` directly.
726 + *
727 + * The instanceof is against PaymentGatewayInterface — the manager's
728 + * registration contract — NOT AbstractPaymentGateway, so a third-party
729 + * gateway implementing the interface directly still resolves. The only
730 + * object it rejects is the GatewayManager itself, which does not implement
731 + * the interface.
732 + *
733 + * @return PaymentGatewayInterface|null
734 + */
735 + private function resolveGateway(): ?PaymentGatewayInterface
736 + {
737 + if (empty($this->current_payment_method)) {
738 + return null;
739 + }
740 +
741 + // The one direct App::gateway() call in this class.
742 + $gateway = App::gateway($this->current_payment_method);
743 +
744 + return $gateway instanceof PaymentGatewayInterface ? $gateway : null;
745 + }
746 +
747 + /**
748 + * The `switch_payment_method` entry of `supportedFeatures`, or [] when the
749 + * gateway does not declare one.
750 + *
751 + * Unlike the flat feature flags this is a KEYED entry carrying config
752 + * (`supported_gateways`), so `has()` cannot answer it — it needs the raw
753 + * `supportedFeatures` property, which only AbstractPaymentGateway carries.
754 + * An interface-only gateway therefore reports no switch support rather
755 + * than triggering an undefined-property read.
756 + *
757 + * @return array
758 + */
759 + private function switchPaymentConfig(): array
760 + {
761 + $gateway = $this->resolveGateway();
762 +
763 + if (!$gateway instanceof AbstractPaymentGateway) {
764 + return [];
765 + }
766 +
767 + return (array) Arr::get($gateway->supportedFeatures, 'switch_payment_method', []);
768 + }
769 +
465 770 public function canUpdatePaymentMethod()
466 771 {
467 - $gateway = App::gateway($this->current_payment_method);
468 - if (!$gateway || !in_array('card_update', $gateway->supportedFeatures)) {
772 + $gateway = $this->resolveGateway();
773 + if (!$gateway || !$gateway->has('card_update')) {
469 774 return false;
470 775 }
471 776
472 777 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_INTENDED, Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRING]); // past_due, is fallback for existing subscriptions, on new subscriptions update it will be expiring
@@ -473,11 +778,18 @@
473 778 }
474 779
475 780 public function canSwitchPaymentMethod()
476 781 {
477 - $gateway = App::gateway($this->current_payment_method);
782 + // Switching moves the subscription onto ANOTHER gateway's vendor subscription
783 + // (see PayPal SubscriptionManager::switchPaymentMethod — it creates a live
784 + // PayPal subscription). A store-billed subscription is already owned by the
785 + // invoice engine, so a vendor subscription would bill it a second time. The
786 + // customer changes the card on file instead (canUpdatePaymentMethod).
787 + if ($this->usesRenewalEngine()) {
788 + return false;
789 + }
478 790
479 - if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) {
791 + if (!$this->switchPaymentConfig()) {
480 792 return false;
481 793 }
482 794
483 795 return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]);
@@ -484,36 +796,268 @@
484 796 }
485 797
486 798 public function switchablePaymentMethods()
487 799 {
488 - $gateway = App::gateway($this->current_payment_method);
489 - if (!$gateway || empty($gateway->supportedFeatures['switch_payment_method'])) {
800 + if (!$this->canSwitchPaymentMethod()) {
490 801 return [];
491 802 }
492 803
493 - return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []);
804 + return Arr::get($this->switchPaymentConfig(), 'supported_gateways', []);
494 805 }
495 806
496 - public function canReactive()
807 + public function canPause()
497 808 {
809 + // Store-billed (manual/system) subscriptions can always be paused
810 + // (unless already paused/canceled/expired)
811 + if ($this->usesRenewalEngine()) {
812 + return in_array($this->status, [
813 + Status::SUBSCRIPTION_ACTIVE,
814 + Status::SUBSCRIPTION_TRIALING,
815 + Status::SUBSCRIPTION_PAST_DUE,
816 + Status::SUBSCRIPTION_EXPIRING
817 + ]);
818 + }
819 +
820 + // Automatic subscriptions require gateway support
821 + $gateway = $this->resolveGateway();
822 +
823 + if (!$gateway) {
824 + return false;
825 + }
826 +
827 + // Check if gateway supports pause
828 + if (!$gateway->has('pause_subscription')) {
829 + return false;
830 + }
831 +
832 + // Default behavior for automatic subscriptions
833 + return in_array($this->status, [
834 + Status::SUBSCRIPTION_ACTIVE,
835 + Status::SUBSCRIPTION_TRIALING
836 + ]) && !in_array($this->status, [
837 + Status::SUBSCRIPTION_PAUSED,
838 + Status::SUBSCRIPTION_CANCELED,
839 + Status::SUBSCRIPTION_EXPIRED,
840 + Status::SUBSCRIPTION_COMPLETED
841 + ]);
842 + }
843 +
844 + /**
845 + * A skip is pending when the current upcoming period was reached by an admin
846 + * skip that has not yet elapsed — next_billing_date still equals the value the
847 + * last skip set. Blocks stacking another skip onto the same pending window.
848 + *
849 + * @return bool
850 + */
851 + public function hasPendingSkip(): bool
852 + {
853 + if (!$this->next_billing_date) {
854 + return false;
855 + }
856 +
857 + $skippedTo = $this->getMeta('pending_skip_until');
858 +
859 + if (!$skippedTo) {
860 + return false;
861 + }
862 +
863 + return $skippedTo === $this->next_billing_date
864 + && strtotime($this->next_billing_date) > time();
865 + }
866 +
867 + public function canResume()
868 + {
869 + // Store-billed (manual/system) subscriptions can be resumed from paused state
870 + if ($this->usesRenewalEngine()) {
871 + return $this->status === Status::SUBSCRIPTION_PAUSED;
872 + }
873 +
874 +
875 + $gateway = $this->resolveGateway();
876 +
877 + if (!$gateway) {
878 + return false;
879 + }
880 +
881 + if (!$gateway->has('resume_subscription')) {
882 + return false;
883 + }
884 +
885 + // Default behavior
886 + return $this->status === Status::SUBSCRIPTION_PAUSED;
887 + }
888 +
889 + public function pauseSubscription($reason = '')
890 + {
891 + return SubscriptionService::pauseSubscription($this, $reason);
892 + }
893 +
894 + public function resumeSubscription($reason = '')
895 + {
896 + return SubscriptionService::resumeSubscription($this, $reason);
897 + }
898 +
899 + public function canUpdateDetails()
900 + {
901 + // Only store-billed (manual/system) subscriptions can be fully edited by
902 + // admin — edits to a system subscription take effect on its next invoice.
903 + return $this->usesRenewalEngine();
904 + }
905 +
906 + /**
907 + * Vendor identifiers are the inverse case of canUpdateDetails(): only a
908 + * gateway-billed subscription has them, and correcting them is the one
909 + * admin write an automatic subscription accepts. Billing fields stay
910 + * gateway-owned.
911 + *
912 + * Off by default — this is a migration/support repair tool, and the column it
913 + * writes is what gateway webhooks resolve on. Enable with:
914 + *
915 + * add_filter('fluent_cart/subscription/vendor_id_editing_enabled', '__return_true');
916 + *
917 + * @return bool
918 + */
919 + public function canEditVendorIds(): bool
920 + {
921 + if (!apply_filters('fluent_cart/subscription/vendor_id_editing_enabled', false)) {
922 + return false;
923 + }
924 +
925 + if (!$this->isAutomatic() || !$this->current_payment_method) {
926 + return false;
927 + }
928 +
929 + // `expired` and `canceled` stay editable: a subscription usually lands there
930 + // *because* the id was wrong (webhooks resolved to nothing), so those are the
931 + // states the repair is needed in most. Sync from gateway has no status gate
932 + // either. `completed` is a real end of term, not a lookup failure.
933 + return strtolower($this->status) !== Status::SUBSCRIPTION_COMPLETED;
934 + }
935 +
936 + /**
937 + * Whether the gateway backing this subscription can look a candidate id up
938 + * before it is saved. Editing does not depend on this — a gateway with no
939 + * lookup still accepts a correction, it just cannot preview it.
940 + *
941 + * @return bool
942 + */
943 + public function canVerifyVendorIds(): bool
944 + {
945 + if (!$this->canEditVendorIds()) {
946 + return false;
947 + }
948 +
949 + $gateway = App::gateway($this->current_payment_method);
950 +
951 + return $gateway && $gateway->has('subscriptions') && $gateway->has('verify_vendor_ids');
952 + }
953 +
954 + /**
955 + * Update subscription details (for manual subscriptions)
956 + *
957 + * Allowed fields for manual subscriptions:
958 + * - recurring_total: Update the next invoice/payment amount (in cents)
959 + * - bill_times: Update the number of billing cycles (0 = unlimited)
960 + * - billing_interval: Change billing frequency (daily, weekly, monthly, etc.)
961 + * - expire_at: Update expiration date
962 + * - trial_days: Update trial period
963 + * - next_billing_date: Update next billing date
964 + *
965 + * @param array $data
966 + * @return true|\WP_Error
967 + */
968 + public function updateSubscription(array $data)
969 + {
970 + return SubscriptionService::updateSubscription($this, $data);
971 + }
972 +
973 + /**
974 + * Whether this subscription can be reactivated.
975 + *
976 + * Status-based for BOTH manual and automatic subscriptions — no gateway
977 + * supportedFeatures branch on purpose. Manual reactivation is a local status
978 + * flip; automatic reactivation runs through the Pro re-checkout flow
979 + * (SubscriptionRenewalHandler builds an instant cart and the customer pays
980 + * again), which works with any gateway. Gating on a gateway feature here
981 + * would hide the customer-facing reactivate URL for Stripe/PayPal/etc.
982 + *
983 + * @return bool
984 + */
985 + public function canReactivate()
986 + {
498 987 if (!App::isProActive()) {
499 - return '';
988 + return false;
500 989 }
501 990
502 991 if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) {
503 - return '';
992 + return false;
504 993 }
505 994
506 - $canReactivate = in_array($this->status, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRED, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_PAST_DUE]);
995 + // Paused is intentionally excluded — a paused subscription resumes (see
996 + // canResume()); reactivation is for terminal/lapsed states only.
997 + $canReactivate = in_array($this->status, [
998 + Status::SUBSCRIPTION_CANCELED,
999 + Status::SUBSCRIPTION_FAILING,
1000 + Status::SUBSCRIPTION_EXPIRED,
1001 + Status::SUBSCRIPTION_EXPIRING,
1002 + Status::SUBSCRIPTION_PAST_DUE,
1003 + ]) || (
1004 + in_array($this->status, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED], true)
1005 + && $this->hasReactivationAttempt()
1006 + && ((int) $this->bill_times === 0 || (int) $this->bill_count < (int) $this->bill_times)
1007 + );
507 1008
508 - return apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
1009 + return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
509 1010 'subscription' => $this
510 1011 ]);
511 1012 }
512 1013
1014 + /**
1015 + * Pro records `reactivation_order_id` before a reactivation checkout is paid. A first
1016 + * purchase awaiting gateway activation is also pending/intended and can already be
1017 + * billed, so only this marker separates a retryable reactivation from it.
1018 + */
1019 + public function hasReactivationAttempt(): bool
1020 + {
1021 + return (int) Arr::get($this->config, 'reactivation_order_id', 0) > 0;
1022 + }
1023 +
1024 + public function isVisibleToCustomer(): bool
1025 + {
1026 + return !in_array($this->status, [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED], true)
1027 + || (int) $this->bill_count > 0
1028 + || $this->hasReactivationAttempt();
1029 + }
1030 +
1031 + public function scopeVisibleToCustomer($query)
1032 + {
1033 + return $query->where(function ($query) {
1034 + $query->whereNotIn('status', [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED])
1035 + ->orWhere('bill_count', '>', 0)
1036 + ->orWhere('config', 'LIKE', '%"reactivation_order_id":%');
1037 + });
1038 + }
1039 +
1040 + /**
1041 + * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
1042 + * @return bool
1043 + */
1044 + public function canReactive()
1045 + {
1046 + return $this->canReactivate();
1047 + }
1048 +
1049 + /**
1050 + * These links are minted in email and webhook contexts, where there is no
1051 + * current user. A wp_create_nonce() token bound to that user-less request
1052 + * stops verifying the moment the recipient logs in to act on it, so the link
1053 + * broke for the one journey it exists to serve. Authorization for the
1054 + * endpoint is the subscription-ownership check on the handling side, which
1055 + * a nonce never provided; the uuid alone is inert to anyone else.
1056 + */
513 1057 public function getReactivateUrl()
514 1058 {
515 - if (!$this->canReactive()) {
1059 + if (!$this->canReactivate()) {
516 1060 return '';
517 1061 }
518 1062
519 1063 return add_query_arg([
@@ -548,11 +1092,19 @@
548 1092 if (in_array($this->status, $validAccessStatuses)) {
549 1093 return true;
550 1094 }
551 1095
1096 + // Past-due/expiring/failing keep access while the unpaid invoice is inside its
1097 + // dunning grace window; checkAndExpireSubscriptions() flips them to expired past that.
1098 + if (in_array($this->status, [Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_FAILING])) {
1099 + $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
1100 + $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
1101 +
1102 + return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
1103 + }
1104 +
552 1105 $invalidStatuses = [
553 1106 Status::SUBSCRIPTION_EXPIRED,
554 - Status::SUBSCRIPTION_PAST_DUE,
555 1107 Status::SUBSCRIPTION_INTENDED,
556 1108 Status::SUBSCRIPTION_PENDING
557 1109 ];
558 1110
@@ -575,9 +1127,9 @@
575 1127 }
576 1128
577 1129 public function reSyncFromRemote()
578 1130 {
579 - if ($gateway = App::gateway($this->current_payment_method)) {
1131 + if ($gateway = $this->resolveGateway()) {
580 1132 if ($gateway->has('subscriptions')) {
581 1133 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
582 1134 }
583 1135 }
@@ -597,11 +1149,18 @@
597 1149 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
598 1150 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
599 1151 }
600 1152
601 - $gateway = App::gateway($this->current_payment_method);
1153 + $gateway = $this->resolveGateway();
602 1154
603 - if ($gateway && $gateway->has('subscriptions')) {
1155 + // No vendor subscription (store-billed, or a vendor id that never landed) —
1156 + // nothing to cancel at the gateway.
1157 + if (!$this->vendor_subscription_id) {
1158 + $vendorCanceled = null;
1159 + $updateData = [
1160 + 'canceled_at' => gmdate('Y-m-d H:i:s', time())
1161 + ];
1162 + } elseif ($gateway && $gateway->has('subscriptions')) {
604 1163 $cancelArgs = [
605 1164 'subscription_id' => $this->id,
606 1165 'parent_order_id' => $this->parent_order_id,
607 1166 'mode' => $this->order->mode,
@@ -617,8 +1176,9 @@
617 1176 }
618 1177
619 1178 $updateData = array_filter($vendorCanceled);
620 1179 } else {
1180 + // Vendor subscription exists but this gateway cannot cancel it — it stays live.
621 1181 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
622 1182 $updateData = [
623 1183 'canceled_at' => gmdate('Y-m-d H:i:s', time())
624 1184 ];
@@ -634,21 +1194,26 @@
634 1194 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
635 1195 $updateData['canceled_at'] = NULL;
636 1196 }
637 1197
638 - $config = $this->config;
639 - if ($args['reason']) {
640 - $config['cancellation_reason'] = $args['reason'];
1198 + if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
1199 + $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
641 1200 }
642 - $updateData['config'] = $config;
643 1201
644 - if (Arr::get($args, 'effective_from') === 'immediately') {
645 - $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
1202 + // A completed (EOT) subscription has no upcoming billing — the immediate-cancel
1203 + // date above must not resurrect one (SubscriptionEOT cancels remote subscriptions
1204 + // with effective_from=immediately after syncSubscriptionStates nulled the date).
1205 + if (Arr::get($updateData, 'status') === Status::SUBSCRIPTION_COMPLETED) {
1206 + $updateData['next_billing_date'] = NULL;
646 1207 }
647 1208
648 1209 $this->fill($updateData);
649 1210 $this->save();
650 1211
1212 + if ($args['reason']) {
1213 + $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1214 + }
1215 +
651 1216 $note = $args['note'];
652 1217
653 1218 if (!$note) {
654 1219 $note = 'on customer request';
@@ -653,10 +1218,11 @@
653 1218 if (!$note) {
654 1219 $note = 'on customer request';
655 1220 }
656 1221
657 - if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) {
658 - (new SubscriptionCanceled($this, $this->order, $this->order->customer, $note))->dispatch();
1222 + // Single cancel chokepoint — void open renewals, clear reminders, email once.
1223 + if ($this->status === Status::SUBSCRIPTION_CANCELED) {
1224 + SubscriptionService::finalizeCancellation($this, $note, (bool) $args['fire_hooks']);
659 1225 }
660 1226
661 1227 if ($args['note']) {
662 1228 $this->order->note = $note;
@@ -846,11 +1412,59 @@
846 1412
847 1413 return $query;
848 1414 }
849 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 +
850 1464 public function getReactivationTrialDays()
851 1465 {
852 - if (!$this->hasAccessValidity()) {
1466 + if (!$this->hasReactivationTrialCredit()) {
853 1467 return 0;
854 1468 }
855 1469
856 1470 $lastPaidTransaction = OrderTransaction::query()
@@ -909,16 +1523,17 @@
909 1523 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
910 1524 ->first();
911 1525
912 1526 if ($theLastOrder) {
913 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1527 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1528 +
914 1529 if ($theLastOrder->type == 'renewal') {
915 - $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)));
916 1531 } else {
917 1532 if ($this->trial_days) {
918 - $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);
919 1534 } else {
920 - $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)));
921 1536 }
922 1537 }
923 1538 } else {
924 1539 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -935,9 +1550,9 @@
935 1550 *
936 1551 * Processes all candidates in batches to avoid memory issues.
937 1552 * The query example works as follows:
938 1553 * SELECT * FROM subscriptions WHERE
939 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1554 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
940 1555 AND next_billing_date IS NOT NULL
941 1556 AND id > 0 -- last processed ID for batch cursor
942 1557 AND next_billing_date < DATE_SUB(
943 1558 '2026-02-17 10:00:00',
@@ -985,8 +1600,11 @@
985 1600 $defaultGraceDays = 7;
986 1601 $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
987 1602 $knownIntervals = array_keys($cutoffDates);
988 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
989 1607 $subscriptions = Subscription::query()
990 1608 ->whereIn('status', [
991 1609 Status::SUBSCRIPTION_ACTIVE,
992 1610 Status::SUBSCRIPTION_TRIALING,
@@ -991,10 +1609,12 @@
991 1609 Status::SUBSCRIPTION_ACTIVE,
992 1610 Status::SUBSCRIPTION_TRIALING,
993 1611 Status::SUBSCRIPTION_CANCELED,
994 1612 Status::SUBSCRIPTION_EXPIRING,
1613 + Status::SUBSCRIPTION_FAILING,
995 1614 Status::SUBSCRIPTION_PAST_DUE
996 1615 ])
1616 + ->whereNotIn('collection_method', ['manual', 'system'])
997 1617 ->whereNotNull('next_billing_date')
998 1618 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
999 1619 ->where('id', '>', $lastId)
1000 1620 ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
@@ -1002,8 +1622,9 @@
1002 1622 $subQuery->whereIn('status', [
1003 1623 Status::SUBSCRIPTION_ACTIVE,
1004 1624 Status::SUBSCRIPTION_TRIALING,
1005 1625 Status::SUBSCRIPTION_EXPIRING,
1626 + Status::SUBSCRIPTION_FAILING,
1006 1627 Status::SUBSCRIPTION_PAST_DUE,
1007 1628 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1008 1629 $index = 0;
1009 1630
@@ -1153,5 +1774,5 @@
1153 1774
1154 1775 return $stats;
1155 1776 }
1156 1777
1157 -}
1778 +}