PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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 +628 -37 1.5.5 → 1.6.6 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,238 @@
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 + ]);
507 1004
508 - return apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
1005 + return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
509 1006 'subscription' => $this
510 1007 ]);
511 1008 }
512 1009
1010 + /**
1011 + * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
1012 + * @return bool
1013 + */
1014 + public function canReactive()
1015 + {
1016 + return $this->canReactivate();
1017 + }
1018 +
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 + */
513 1027 public function getReactivateUrl()
514 1028 {
515 - if (!$this->canReactive()) {
1029 + if (!$this->canReactivate()) {
516 1030 return '';
517 1031 }
518 1032
519 1033 return add_query_arg([
@@ -548,11 +1062,19 @@
548 1062 if (in_array($this->status, $validAccessStatuses)) {
549 1063 return true;
550 1064 }
551 1065
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])) {
1069 + $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
1070 + $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
1071 +
1072 + return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
1073 + }
1074 +
552 1075 $invalidStatuses = [
553 1076 Status::SUBSCRIPTION_EXPIRED,
554 - Status::SUBSCRIPTION_PAST_DUE,
555 1077 Status::SUBSCRIPTION_INTENDED,
556 1078 Status::SUBSCRIPTION_PENDING
557 1079 ];
558 1080
@@ -575,9 +1097,9 @@
575 1097 }
576 1098
577 1099 public function reSyncFromRemote()
578 1100 {
579 - if ($gateway = App::gateway($this->current_payment_method)) {
1101 + if ($gateway = $this->resolveGateway()) {
580 1102 if ($gateway->has('subscriptions')) {
581 1103 return $gateway->subscriptions->reSyncSubscriptionFromRemote($this);
582 1104 }
583 1105 }
@@ -597,11 +1119,18 @@
597 1119 if ($this->status === Status::SUBSCRIPTION_CANCELED) {
598 1120 return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart'));
599 1121 }
600 1122
601 - $gateway = App::gateway($this->current_payment_method);
1123 + $gateway = $this->resolveGateway();
602 1124
603 - if ($gateway && $gateway->has('subscriptions')) {
1125 + // No vendor subscription (store-billed, or a vendor id that never landed) —
1126 + // nothing to cancel at the gateway.
1127 + if (!$this->vendor_subscription_id) {
1128 + $vendorCanceled = null;
1129 + $updateData = [
1130 + 'canceled_at' => gmdate('Y-m-d H:i:s', time())
1131 + ];
1132 + } elseif ($gateway && $gateway->has('subscriptions')) {
604 1133 $cancelArgs = [
605 1134 'subscription_id' => $this->id,
606 1135 'parent_order_id' => $this->parent_order_id,
607 1136 'mode' => $this->order->mode,
@@ -617,8 +1146,9 @@
617 1146 }
618 1147
619 1148 $updateData = array_filter($vendorCanceled);
620 1149 } else {
1150 + // Vendor subscription exists but this gateway cannot cancel it — it stays live.
621 1151 $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart'));
622 1152 $updateData = [
623 1153 'canceled_at' => gmdate('Y-m-d H:i:s', time())
624 1154 ];
@@ -634,21 +1164,26 @@
634 1164 $updateData['status'] = Status::SUBSCRIPTION_COMPLETED;
635 1165 $updateData['canceled_at'] = NULL;
636 1166 }
637 1167
638 - $config = $this->config;
639 - if ($args['reason']) {
640 - $config['cancellation_reason'] = $args['reason'];
1168 + if (Arr::get($args, 'effective_from') === 'immediately' && $updateData['status'] !== Status::SUBSCRIPTION_COMPLETED) {
1169 + $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
641 1170 }
642 - $updateData['config'] = $config;
643 1171
644 - if (Arr::get($args, 'effective_from') === 'immediately') {
645 - $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time());
1172 + // A completed (EOT) subscription has no upcoming billing — the immediate-cancel
1173 + // date above must not resurrect one (SubscriptionEOT cancels remote subscriptions
1174 + // with effective_from=immediately after syncSubscriptionStates nulled the date).
1175 + if (Arr::get($updateData, 'status') === Status::SUBSCRIPTION_COMPLETED) {
1176 + $updateData['next_billing_date'] = NULL;
646 1177 }
647 1178
648 1179 $this->fill($updateData);
649 1180 $this->save();
650 1181
1182 + if ($args['reason']) {
1183 + $this->mergeConfig(['cancellation_reason' => $args['reason']]);
1184 + }
1185 +
651 1186 $note = $args['note'];
652 1187
653 1188 if (!$note) {
654 1189 $note = 'on customer request';
@@ -653,10 +1188,11 @@
653 1188 if (!$note) {
654 1189 $note = 'on customer request';
655 1190 }
656 1191
657 - if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) {
658 - (new SubscriptionCanceled($this, $this->order, $this->order->customer, $note))->dispatch();
1192 + // Single cancel chokepoint — void open renewals, clear reminders, email once.
1193 + if ($this->status === Status::SUBSCRIPTION_CANCELED) {
1194 + SubscriptionService::finalizeCancellation($this, $note, (bool) $args['fire_hooks']);
659 1195 }
660 1196
661 1197 if ($args['note']) {
662 1198 $this->order->note = $note;
@@ -846,11 +1382,59 @@
846 1382
847 1383 return $query;
848 1384 }
849 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 +
850 1434 public function getReactivationTrialDays()
851 1435 {
852 - if (!$this->hasAccessValidity()) {
1436 + if (!$this->hasReactivationTrialCredit()) {
853 1437 return 0;
854 1438 }
855 1439
856 1440 $lastPaidTransaction = OrderTransaction::query()
@@ -909,16 +1493,17 @@
909 1493 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
910 1494 ->first();
911 1495
912 1496 if ($theLastOrder) {
913 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1497 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1498 +
914 1499 if ($theLastOrder->type == 'renewal') {
915 - $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)));
916 1501 } else {
917 1502 if ($this->trial_days) {
918 - $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);
919 1504 } else {
920 - $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)));
921 1506 }
922 1507 }
923 1508 } else {
924 1509 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -935,9 +1520,9 @@
935 1520 *
936 1521 * Processes all candidates in batches to avoid memory issues.
937 1522 * The query example works as follows:
938 1523 * SELECT * FROM subscriptions WHERE
939 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1524 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
940 1525 AND next_billing_date IS NOT NULL
941 1526 AND id > 0 -- last processed ID for batch cursor
942 1527 AND next_billing_date < DATE_SUB(
943 1528 '2026-02-17 10:00:00',
@@ -985,8 +1570,11 @@
985 1570 $defaultGraceDays = 7;
986 1571 $defaultCutoff = gmdate('Y-m-d H:i:s', $currentTime - ($defaultGraceDays * DAY_IN_SECONDS));
987 1572 $knownIntervals = array_keys($cutoffDates);
988 1573
1574 + // Include canceled subscriptions to check if validity is yet to expired
1575 + // Exclude store-billed (manual/system) subscriptions — their expiry is
1576 + // handled by the invoice-based overdue flow
989 1577 $subscriptions = Subscription::query()
990 1578 ->whereIn('status', [
991 1579 Status::SUBSCRIPTION_ACTIVE,
992 1580 Status::SUBSCRIPTION_TRIALING,
@@ -991,10 +1579,12 @@
991 1579 Status::SUBSCRIPTION_ACTIVE,
992 1580 Status::SUBSCRIPTION_TRIALING,
993 1581 Status::SUBSCRIPTION_CANCELED,
994 1582 Status::SUBSCRIPTION_EXPIRING,
1583 + Status::SUBSCRIPTION_FAILING,
995 1584 Status::SUBSCRIPTION_PAST_DUE
996 1585 ])
1586 + ->whereNotIn('collection_method', ['manual', 'system'])
997 1587 ->whereNotNull('next_billing_date')
998 1588 ->where('next_billing_date', '>', '0000-00-00 00:00:00')
999 1589 ->where('id', '>', $lastId)
1000 1590 ->where(function ($query) use ($now, $cutoffDates, $knownIntervals, $defaultCutoff) {
@@ -1002,8 +1592,9 @@
1002 1592 $subQuery->whereIn('status', [
1003 1593 Status::SUBSCRIPTION_ACTIVE,
1004 1594 Status::SUBSCRIPTION_TRIALING,
1005 1595 Status::SUBSCRIPTION_EXPIRING,
1596 + Status::SUBSCRIPTION_FAILING,
1006 1597 Status::SUBSCRIPTION_PAST_DUE,
1007 1598 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1008 1599 $index = 0;
1009 1600
@@ -1153,5 +1744,5 @@
1153 1744
1154 1745 return $stats;
1155 1746 }
1156 1747
1157 -}
1748 +}