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 +111 -12 1.6.2 → 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,
@@ -981,9 +999,13 @@
981 999 Status::SUBSCRIPTION_FAILING,
982 1000 Status::SUBSCRIPTION_EXPIRED,
983 1001 Status::SUBSCRIPTION_EXPIRING,
984 1002 Status::SUBSCRIPTION_PAST_DUE,
985 - ]);
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 + );
986 1008
987 1009 return (bool) apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [
988 1010 'subscription' => $this
989 1011 ]);
@@ -989,8 +1011,34 @@
989 1011 ]);
990 1012 }
991 1013
992 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 + /**
993 1041 * @deprecated Use canReactivate(). Kept as a backward-compatible alias.
994 1042 * @return bool
995 1043 */
996 1044 public function canReactive()
@@ -1044,11 +1092,11 @@
1044 1092 if (in_array($this->status, $validAccessStatuses)) {
1045 1093 return true;
1046 1094 }
1047 1095
1048 - // Past-due keeps access while the unpaid invoice is inside its dunning
1049 - // grace window; the expiry crons flip it to expired past that.
1050 - 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])) {
1051 1099 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
1052 1100 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
1053 1101
1054 1102 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
@@ -1364,11 +1412,59 @@
1364 1412
1365 1413 return $query;
1366 1414 }
1367 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 +
1368 1464 public function getReactivationTrialDays()
1369 1465 {
1370 - if (!$this->hasAccessValidity()) {
1466 + if (!$this->hasReactivationTrialCredit()) {
1371 1467 return 0;
1372 1468 }
1373 1469
1374 1470 $lastPaidTransaction = OrderTransaction::query()
@@ -1427,16 +1523,17 @@
1427 1523 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1428 1524 ->first();
1429 1525
1430 1526 if ($theLastOrder) {
1431 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1527 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1528 +
1432 1529 if ($theLastOrder->type == 'renewal') {
1433 - $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)));
1434 1531 } else {
1435 1532 if ($this->trial_days) {
1436 - $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);
1437 1534 } else {
1438 - $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)));
1439 1536 }
1440 1537 }
1441 1538 } else {
1442 1539 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -1453,9 +1550,9 @@
1453 1550 *
1454 1551 * Processes all candidates in batches to avoid memory issues.
1455 1552 * The query example works as follows:
1456 1553 * SELECT * FROM subscriptions WHERE
1457 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1554 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
1458 1555 AND next_billing_date IS NOT NULL
1459 1556 AND id > 0 -- last processed ID for batch cursor
1460 1557 AND next_billing_date < DATE_SUB(
1461 1558 '2026-02-17 10:00:00',
@@ -1512,8 +1609,9 @@
1512 1609 Status::SUBSCRIPTION_ACTIVE,
1513 1610 Status::SUBSCRIPTION_TRIALING,
1514 1611 Status::SUBSCRIPTION_CANCELED,
1515 1612 Status::SUBSCRIPTION_EXPIRING,
1613 + Status::SUBSCRIPTION_FAILING,
1516 1614 Status::SUBSCRIPTION_PAST_DUE
1517 1615 ])
1518 1616 ->whereNotIn('collection_method', ['manual', 'system'])
1519 1617 ->whereNotNull('next_billing_date')
@@ -1524,8 +1622,9 @@
1524 1622 $subQuery->whereIn('status', [
1525 1623 Status::SUBSCRIPTION_ACTIVE,
1526 1624 Status::SUBSCRIPTION_TRIALING,
1527 1625 Status::SUBSCRIPTION_EXPIRING,
1626 + Status::SUBSCRIPTION_FAILING,
1528 1627 Status::SUBSCRIPTION_PAST_DUE,
1529 1628 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1530 1629 $index = 0;
1531 1630