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 +185 -21 1.6.1 → 1.7.0 View file →
@@ -12,9 +12,8 @@
12 12 use FluentCart\App\Modules\PaymentMethods\Core\PaymentGatewayInterface;
13 13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 14 use FluentCart\App\Models\Concerns\CanUpdateBatch;
15 15 use FluentCart\App\Models\Concerns\HasActivity;
16 -use FluentCart\App\Services\Payments\PaymentHelper;
17 16 use FluentCart\App\Services\Payments\SubscriptionHelper;
18 17 use FluentCart\App\Services\TemplateService;
19 18 use FluentCart\Framework\Database\Orm\Relations\BelongsTo;
20 19 use FluentCart\Framework\Database\Orm\Relations\HasMany;
@@ -41,9 +40,9 @@
41 40 protected $table = 'fct_subscriptions';
42 41
43 42 protected $primaryKey = 'id';
44 43
45 - 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'];
46 45
47 46 protected $guarded = ['id'];
48 47
49 48 protected $fillable = [
@@ -278,8 +277,27 @@
278 277
279 278 return $postTitle !== '' ? $postTitle . ' - ' . $attributeDisplayTitleString : $attributeDisplayTitleString;
280 279 }
281 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 +
282 300 public function getUrlAttribute($value)
283 301 {
284 302 return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [
285 303 'vendor_subscription_id' => $this->vendor_subscription_id,
@@ -298,9 +316,8 @@
298 316 * use overriden status to show the correct status for customer
299 317 */
300 318 public function getOverriddenStatusAttribute($value)
301 319 {
302 - $variation = ProductVariation::find($this->variation_id);
303 320 if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) {
304 321 return Status::SUBSCRIPTION_ACTIVE;
305 322 }
306 323
@@ -478,8 +495,10 @@
478 495 && Arr::get($chargeState, 'status') !== 'processing';
479 496
480 497 return [
481 498 'canEdit' => $canEdit,
499 + 'canEditVendorIds' => $this->canEditVendorIds(),
500 + 'canVerifyVendorIds' => $this->canVerifyVendorIds(),
482 501 'canPause' => $this->canPause(),
483 502 'canResume' => $this->canResume(),
484 503 'canFetch' => !$this->usesRenewalEngine() && $hasVendorId,
485 504 'canCancel' => $canCancel,
@@ -516,8 +535,18 @@
516 535 return $this->collection_method === 'system';
517 536 }
518 537
519 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 + /**
520 549 * Manual and system subscriptions are both billed by FluentCart's invoice
521 550 * engine (renewal invoices, overdue escalation, admin invoice actions).
522 551 * System additionally auto-charges a stored token per invoice.
523 552 *
@@ -556,8 +585,12 @@
556 585 ];
557 586
558 587 $recurringTotal = $this->recurring_total ?? 0;
559 588
589 + if ($schedule = SubscriptionHelper::getBillingSchedule($this)) {
590 + return Helper::generateScheduleSubscriptionInfo($schedule, $otherInfo, $recurringTotal, $this->currency) ?? '';
591 + }
592 +
560 593 return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal, $this->currency) ?? '';
561 594 }
562 595
563 596 public function addLog($title, $description = '', $type = 'info', $by = '')
@@ -870,8 +903,56 @@
870 903 return $this->usesRenewalEngine();
871 904 }
872 905
873 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 + /**
874 955 * Update subscription details (for manual subscriptions)
875 956 *
876 957 * Allowed fields for manual subscriptions:
877 958 * - recurring_total: Update the next invoice/payment amount (in cents)
@@ -918,9 +999,13 @@
918 999 Status::SUBSCRIPTION_FAILING,
919 1000 Status::SUBSCRIPTION_EXPIRED,
920 1001 Status::SUBSCRIPTION_EXPIRING,
921 1002 Status::SUBSCRIPTION_PAST_DUE,
922 - ]);
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 + );
923 1008
924 1009 return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
925 1010 'subscription' => $this
926 1011 ]);
@@ -926,8 +1011,34 @@
926 1011 ]);
927 1012 }
928 1013
929 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 + /**
930 1041 * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
931 1042 * @return bool
932 1043 */
933 1044 public function canReactive()
@@ -934,16 +1045,19 @@
934 1045 {
935 1046 return $this->canReactivate();
936 1047 }
937 1048
938 - public function getReactivationNonceAction()
939 - {
940 - return 'fluent_cart_reactivate_subscription_' . $this->uuid;
941 - }
942 -
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 + */
943 1057 public function getReactivateUrl()
944 1058 {
945 - if (!$this->canReactive()) {
1059 + if (!$this->canReactivate()) {
946 1060 return '';
947 1061 }
948 1062
949 1063 return add_query_arg([
@@ -948,9 +1062,8 @@
948 1062
949 1063 return add_query_arg([
950 1064 'fluent-cart' => 'reactivate-subscription',
951 1065 'subscription_hash' => $this->uuid,
952 - '_wpnonce' => wp_create_nonce($this->getReactivationNonceAction()),
953 1066 ], home_url('/'));
954 1067 }
955 1068
956 1069 public function getReactivateUrlAttribute()
@@ -979,11 +1092,11 @@
979 1092 if (in_array($this->status, $validAccessStatuses)) {
980 1093 return true;
981 1094 }
982 1095
983 - // Past-due keeps access while the unpaid invoice is inside its dunning
984 - // grace window; the expiry crons flip it to expired past that.
985 - if ($this->status === Status::SUBSCRIPTION_PAST_DUE) {
1096 + // Past-due/expiring/failing keep access while the unpaid invoice is inside its
1097 + // dunning grace window; checkAndExpireSubscriptions() flips them to expired past that.
1098 + if (in_array($this->status, [Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_FAILING])) {
986 1099 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
987 1100 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
988 1101
989 1102 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
@@ -1299,11 +1412,59 @@
1299 1412
1300 1413 return $query;
1301 1414 }
1302 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 +
1303 1464 public function getReactivationTrialDays()
1304 1465 {
1305 - if (!$this->hasAccessValidity()) {
1466 + if (!$this->hasReactivationTrialCredit()) {
1306 1467 return 0;
1307 1468 }
1308 1469
1309 1470 $lastPaidTransaction = OrderTransaction::query()
@@ -1362,16 +1523,17 @@
1362 1523 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1363 1524 ->first();
1364 1525
1365 1526 if ($theLastOrder) {
1366 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1527 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1528 +
1367 1529 if ($theLastOrder->type == 'renewal') {
1368 - $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)));
1369 1531 } else {
1370 1532 if ($this->trial_days) {
1371 - $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);
1372 1534 } else {
1373 - $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)));
1374 1536 }
1375 1537 }
1376 1538 } else {
1377 1539 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -1388,9 +1550,9 @@
1388 1550 *
1389 1551 * Processes all candidates in batches to avoid memory issues.
1390 1552 * The query example works as follows:
1391 1553 * SELECT * FROM subscriptions WHERE
1392 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1554 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
1393 1555 AND next_billing_date IS NOT NULL
1394 1556 AND id > 0 -- last processed ID for batch cursor
1395 1557 AND next_billing_date < DATE_SUB(
1396 1558 '2026-02-17 10:00:00',
@@ -1447,8 +1609,9 @@
1447 1609 Status::SUBSCRIPTION_ACTIVE,
1448 1610 Status::SUBSCRIPTION_TRIALING,
1449 1611 Status::SUBSCRIPTION_CANCELED,
1450 1612 Status::SUBSCRIPTION_EXPIRING,
1613 + Status::SUBSCRIPTION_FAILING,
1451 1614 Status::SUBSCRIPTION_PAST_DUE
1452 1615 ])
1453 1616 ->whereNotIn('collection_method', ['manual', 'system'])
1454 1617 ->whereNotNull('next_billing_date')
@@ -1459,8 +1622,9 @@
1459 1622 $subQuery->whereIn('status', [
1460 1623 Status::SUBSCRIPTION_ACTIVE,
1461 1624 Status::SUBSCRIPTION_TRIALING,
1462 1625 Status::SUBSCRIPTION_EXPIRING,
1626 + Status::SUBSCRIPTION_FAILING,
1463 1627 Status::SUBSCRIPTION_PAST_DUE,
1464 1628 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1465 1629 $index = 0;
1466 1630
@@ -1610,5 +1774,5 @@
1610 1774
1611 1775 return $stats;
1612 1776 }
1613 1777
1614 -}
1778 +}