From 8e334cd6762f3e421febf75ba51cc5429b63318c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 14 Jun 2026 15:46:17 -0300 Subject: [PATCH] Keep past_due subscribers in-app instead of forcing re-subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A past_due subscription made subscribed() return false (Cashier default), so EnsureAccountReady redirected to /subscribe — which starts a brand-new checkout and creates a second subscription. Past-due users already have a subscription; they only need to update their payment method. Enable Cashier::keepPastDueSubscriptionsActive() so past_due counts as active and users keep navigating. Surface a past-due notice in the sidebar footer linking to the Stripe billing portal (not /subscribe). Both trial modes (subscription trial / generic trial) are unaffected. --- .../Middleware/App/HandleInertiaRequests.php | 1 + app/Models/Account.php | 9 +++ app/Providers/AppServiceProvider.php | 1 + lang/en/billing.php | 6 ++ lang/es/billing.php | 6 ++ lang/pt-BR/billing.php | 6 ++ resources/js/components/AppSidebar.vue | 26 ++++++++ resources/js/types/index.d.ts | 1 + .../Middleware/TrialMiddlewareAccessTest.php | 28 +++++++++ tests/Unit/Models/AccountTest.php | 59 +++++++++++++++++++ 10 files changed, 143 insertions(+) diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index b266b584..5d46fa0c 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -44,6 +44,7 @@ public function share(Request $request): array 'account' => $account ? AuthAccountResource::make($account) : null, 'plan' => $account && $account->plan ? AuthPlanResource::make($account, $account->plan) : null, 'hasActiveSubscription' => $account ? $account->hasActiveSubscription() : false, + 'subscriptionPastDue' => $account ? $account->isPastDue() : false, ], 'usage' => $account && ! $isSelfHosted ? $account->usage() : null, 'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null, diff --git a/app/Models/Account.php b/app/Models/Account.php index f4c04c99..eafefef1 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -91,6 +91,15 @@ public function hasActiveSubscription(): bool return $this->subscribed(self::SUBSCRIPTION_NAME); } + public function isPastDue(): bool + { + if (config('trypost.self_hosted')) { + return false; + } + + return (bool) $this->subscription(self::SUBSCRIPTION_NAME)?->pastDue(); + } + public function isOnTrial(): bool { if (! (bool) config('trypost.billing.require_card_for_trial', true) && $this->onGenericTrial()) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3888f6ee..b7b2cdf0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -88,6 +88,7 @@ public function boot(): void Cashier::useCustomerModel(Account::class); Cashier::useSubscriptionModel(Subscription::class); Cashier::useSubscriptionItemModel(SubscriptionItem::class); + Cashier::keepPastDueSubscriptionsActive(); Feature::resolveScopeUsing(fn () => auth()->user()?->account); Feature::useMorphMap(); diff --git a/lang/en/billing.php b/lang/en/billing.php index eac2f116..915b0eca 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -3,6 +3,12 @@ return [ 'title' => 'Billing', + 'past_due_notice' => [ + 'title' => 'Payment past due', + 'description' => 'Update your payment method to keep your subscription active.', + 'cta' => 'Update payment', + ], + 'upgrade_dialog' => [ 'title' => 'Upgrade your plan', 'description' => 'Pick a plan that fits your needs.', diff --git a/lang/es/billing.php b/lang/es/billing.php index f077718d..28f6b422 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -3,6 +3,12 @@ return [ 'title' => 'Facturación', + 'past_due_notice' => [ + 'title' => 'Pago vencido', + 'description' => 'Actualiza tu método de pago para mantener tu suscripción activa.', + 'cta' => 'Actualizar pago', + ], + 'upgrade_dialog' => [ 'title' => 'Actualiza tu plan', 'description' => 'Elige un plan que se adapte a tus necesidades.', diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 6a74b23d..618a9672 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -3,6 +3,12 @@ return [ 'title' => 'Faturamento', + 'past_due_notice' => [ + 'title' => 'Pagamento em atraso', + 'description' => 'Atualize sua forma de pagamento para manter sua assinatura ativa.', + 'cta' => 'Atualizar pagamento', + ], + 'upgrade_dialog' => [ 'title' => 'Faça upgrade do seu plano', 'description' => 'Escolha um plano que se encaixe nas suas necessidades.', diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 6c7ba6ee..e48ce749 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -2,6 +2,7 @@ import { Link, router, usePage } from '@inertiajs/vue3'; import { IconAffiliate, + IconAlertTriangle, IconBolt, IconCalendar, IconChartBar, @@ -45,6 +46,7 @@ import { useActiveUrl } from '@/composables/useActiveUrl'; import { useFeatureAccess } from '@/composables/useFeatureAccess'; import { useUpgradeDialog } from '@/composables/useUpgradeDialog'; import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app'; +import { portal } from '@/routes/app/billing'; import { index as assets } from '@/routes/app/assets'; import { index as automations } from '@/routes/app/automations'; import { index as labels } from '@/routes/app/labels'; @@ -61,6 +63,7 @@ interface Workspace { const page = usePage(); const currentWorkspace = computed(() => page.props.auth.currentWorkspace as Workspace | null); const workspaces = computed(() => page.props.auth.workspaces as Workspace[]); +const subscriptionPastDue = computed(() => Boolean(page.props.auth.subscriptionPastDue)); const mainNavItems = computed(() => [ { @@ -208,6 +211,29 @@ const handleCreateWorkspace = () => { +
+
+ + {{ $t('billing.past_due_notice.title') }} +
+

+ {{ $t('billing.past_due_notice.description') }} +

+ +
assertOk(); }); +test('user with past_due subscription can access the app instead of being forced to subscribe', function () { + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'past_due', + 'stripe_price' => 'price_123', + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $response = $this->actingAs($user->fresh())->get(route('app.accounts')); + + $response->assertOk(); + $response->assertSessionMissing('errors'); +}); + test('user on generic trial can access the app when card is not required', function () { config(['trypost.billing.require_card_for_trial' => false]); diff --git a/tests/Unit/Models/AccountTest.php b/tests/Unit/Models/AccountTest.php index 4d4e416c..09288f69 100644 --- a/tests/Unit/Models/AccountTest.php +++ b/tests/Unit/Models/AccountTest.php @@ -14,6 +14,65 @@ Carbon::setTestNow('2026-05-14 12:00:00'); }); +test('isPastDue returns false without a subscription', function () { + config(['trypost.self_hosted' => false]); + + $account = Account::factory()->create(['trial_ends_at' => null]); + + expect($account->isPastDue())->toBeFalse(); +}); + +test('isPastDue returns true for a past_due subscription', function () { + config(['trypost.self_hosted' => false]); + + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'past_due', + 'stripe_price' => 'price_123', + ]); + + expect($account->isPastDue())->toBeTrue(); +}); + +test('isPastDue returns false for an active subscription', function () { + config(['trypost.self_hosted' => false]); + + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + expect($account->isPastDue())->toBeFalse(); +}); + +test('isPastDue returns false when self-hosted', function () { + config(['trypost.self_hosted' => true]); + + $account = Account::factory()->create([ + 'trial_ends_at' => null, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'past_due', + 'stripe_price' => 'price_123', + ]); + + expect($account->isPastDue())->toBeFalse(); +}); + test('isOnTrial ignores generic trial when there is no subscription', function () { $account = Account::factory()->create(['trial_ends_at' => now()->addDays(7)]);