PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
← All changes | app/Models/Subscription.php +80 -11 1.6.3 → 1.6.5 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,
@@ -1044,11 +1062,11 @@
1044 1062 if (in_array($this->status, $validAccessStatuses)) {
1045 1063 return true;
1046 1064 }
1047 1065
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) {
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])) {
1051 1069 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
1052 1070 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
1053 1071
1054 1072 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
@@ -1364,11 +1382,59 @@
1364 1382
1365 1383 return $query;
1366 1384 }
1367 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 +
1368 1434 public function getReactivationTrialDays()
1369 1435 {
1370 - if (!$this->hasAccessValidity()) {
1436 + if (!$this->hasReactivationTrialCredit()) {
1371 1437 return 0;
1372 1438 }
1373 1439
1374 1440 $lastPaidTransaction = OrderTransaction::query()
@@ -1427,16 +1493,17 @@
1427 1493 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1428 1494 ->first();
1429 1495
1430 1496 if ($theLastOrder) {
1431 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1497 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1498 +
1432 1499 if ($theLastOrder->type == 'renewal') {
1433 - $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)));
1434 1501 } else {
1435 1502 if ($this->trial_days) {
1436 - $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);
1437 1504 } else {
1438 - $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)));
1439 1506 }
1440 1507 }
1441 1508 } else {
1442 1509 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -1453,9 +1520,9 @@
1453 1520 *
1454 1521 * Processes all candidates in batches to avoid memory issues.
1455 1522 * The query example works as follows:
1456 1523 * SELECT * FROM subscriptions WHERE
1457 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1524 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
1458 1525 AND next_billing_date IS NOT NULL
1459 1526 AND id > 0 -- last processed ID for batch cursor
1460 1527 AND next_billing_date < DATE_SUB(
1461 1528 '2026-02-17 10:00:00',
@@ -1512,8 +1579,9 @@
1512 1579 Status::SUBSCRIPTION_ACTIVE,
1513 1580 Status::SUBSCRIPTION_TRIALING,
1514 1581 Status::SUBSCRIPTION_CANCELED,
1515 1582 Status::SUBSCRIPTION_EXPIRING,
1583 + Status::SUBSCRIPTION_FAILING,
1516 1584 Status::SUBSCRIPTION_PAST_DUE
1517 1585 ])
1518 1586 ->whereNotIn('collection_method', ['manual', 'system'])
1519 1587 ->whereNotNull('next_billing_date')
@@ -1524,8 +1592,9 @@
1524 1592 $subQuery->whereIn('status', [
1525 1593 Status::SUBSCRIPTION_ACTIVE,
1526 1594 Status::SUBSCRIPTION_TRIALING,
1527 1595 Status::SUBSCRIPTION_EXPIRING,
1596 + Status::SUBSCRIPTION_FAILING,
1528 1597 Status::SUBSCRIPTION_PAST_DUE,
1529 1598 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1530 1599 $index = 0;
1531 1600