Keep past_due subscribers in-app instead of forcing re-subscribe

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.
This commit is contained in:
Paulo Castellano 2026-06-14 15:46:17 -03:00
parent 11c27dfd62
commit 8e334cd676
10 changed files with 143 additions and 0 deletions

View file

@ -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,

View file

@ -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()) {

View file

@ -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();

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
const subscriptionPastDue = computed<boolean>(() => Boolean(page.props.auth.subscriptionPastDue));
const mainNavItems = computed<NavItem[]>(() => [
{
@ -208,6 +211,29 @@ const handleCreateWorkspace = () => {
</SidebarContent>
<SidebarFooter>
<div
v-if="subscriptionPastDue"
dusk="past-due-notice"
class="mx-1 mb-1 rounded-md border-2 border-destructive bg-destructive/10 p-3"
>
<div class="flex items-center gap-2 text-destructive">
<IconAlertTriangle class="size-4 shrink-0" />
<span class="text-sm font-semibold">{{ $t('billing.past_due_notice.title') }}</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('billing.past_due_notice.description') }}
</p>
<Button
as="a"
:href="portal.url()"
variant="destructive"
size="sm"
class="mt-2 w-full"
dusk="past-due-cta"
>
{{ $t('billing.past_due_notice.cta') }}
</Button>
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton

View file

@ -32,6 +32,7 @@ export interface Auth {
account: AuthAccount | null;
plan: AuthPlan | null;
hasActiveSubscription: boolean;
subscriptionPastDue: boolean;
}
export interface Usage {

View file

@ -92,6 +92,34 @@
$response->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]);

View file

@ -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)]);