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.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 trunk All 48 releases
← All changes | app/Models/Subscription.php +154 -20 1.6.1 → 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,
@@ -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)
@@ -934,16 +1015,19 @@
934 1015 {
935 1016 return $this->canReactivate();
936 1017 }
937 1018
938 - public function getReactivationNonceAction()
939 - {
940 - return 'fluent_cart_reactivate_subscription_' . $this->uuid;
941 - }
942 -
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 + */
943 1027 public function getReactivateUrl()
944 1028 {
945 - if (!$this->canReactive()) {
1029 + if (!$this->canReactivate()) {
946 1030 return '';
947 1031 }
948 1032
949 1033 return add_query_arg([
@@ -948,9 +1032,8 @@
948 1032
949 1033 return add_query_arg([
950 1034 'fluent-cart' => 'reactivate-subscription',
951 1035 'subscription_hash' => $this->uuid,
952 - '_wpnonce' => wp_create_nonce($this->getReactivationNonceAction()),
953 1036 ], home_url('/'));
954 1037 }
955 1038
956 1039 public function getReactivateUrlAttribute()
@@ -979,11 +1062,11 @@
979 1062 if (in_array($this->status, $validAccessStatuses)) {
980 1063 return true;
981 1064 }
982 1065
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) {
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])) {
986 1069 $dueTimestamp = $this->next_billing_date ? strtotime($this->next_billing_date) : 0;
987 1070 $graceDays = SubscriptionHelper::getGracePeriodDaysForInterval((string) $this->billing_interval);
988 1071
989 1072 return $dueTimestamp && time() < $dueTimestamp + ($graceDays * DAY_IN_SECONDS);
@@ -1299,11 +1382,59 @@
1299 1382
1300 1383 return $query;
1301 1384 }
1302 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 +
1303 1434 public function getReactivationTrialDays()
1304 1435 {
1305 - if (!$this->hasAccessValidity()) {
1436 + if (!$this->hasReactivationTrialCredit()) {
1306 1437 return 0;
1307 1438 }
1308 1439
1309 1440 $lastPaidTransaction = OrderTransaction::query()
@@ -1362,16 +1493,17 @@
1362 1493 ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses())
1363 1494 ->first();
1364 1495
1365 1496 if ($theLastOrder) {
1366 - $days = PaymentHelper::getIntervalDays($this->billing_interval);
1497 + $paidAnchor = SubscriptionHelper::resolvePaidAnchor($theLastOrder);
1498 +
1367 1499 if ($theLastOrder->type == 'renewal') {
1368 - $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)));
1369 1501 } else {
1370 1502 if ($this->trial_days) {
1371 - $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);
1372 1504 } else {
1373 - $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)));
1374 1506 }
1375 1507 }
1376 1508 } else {
1377 1509 $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS);
@@ -1388,9 +1520,9 @@
1388 1520 *
1389 1521 * Processes all candidates in batches to avoid memory issues.
1390 1522 * The query example works as follows:
1391 1523 * SELECT * FROM subscriptions WHERE
1392 - status IN ('active', 'trialing', 'canceled', 'expiring', 'past_due')
1524 + status IN ('active', 'trialing', 'canceled', 'expiring', 'failing', 'past_due')
1393 1525 AND next_billing_date IS NOT NULL
1394 1526 AND id > 0 -- last processed ID for batch cursor
1395 1527 AND next_billing_date < DATE_SUB(
1396 1528 '2026-02-17 10:00:00',
@@ -1447,8 +1579,9 @@
1447 1579 Status::SUBSCRIPTION_ACTIVE,
1448 1580 Status::SUBSCRIPTION_TRIALING,
1449 1581 Status::SUBSCRIPTION_CANCELED,
1450 1582 Status::SUBSCRIPTION_EXPIRING,
1583 + Status::SUBSCRIPTION_FAILING,
1451 1584 Status::SUBSCRIPTION_PAST_DUE
1452 1585 ])
1453 1586 ->whereNotIn('collection_method', ['manual', 'system'])
1454 1587 ->whereNotNull('next_billing_date')
@@ -1459,8 +1592,9 @@
1459 1592 $subQuery->whereIn('status', [
1460 1593 Status::SUBSCRIPTION_ACTIVE,
1461 1594 Status::SUBSCRIPTION_TRIALING,
1462 1595 Status::SUBSCRIPTION_EXPIRING,
1596 + Status::SUBSCRIPTION_FAILING,
1463 1597 Status::SUBSCRIPTION_PAST_DUE,
1464 1598 ])->where(function ($dateQuery) use ($cutoffDates, $knownIntervals, $defaultCutoff) {
1465 1599 $index = 0;
1466 1600
@@ -1610,5 +1744,5 @@
1610 1744
1611 1745 return $stats;
1612 1746 }
1613 1747
1614 -}
1748 +}