fix: allow managing canceled connections (#417)

## Summary
- add paywall settings CTA for onboarded users with ended canceled
subscriptions and bank connections
- keep onboarding and normal unpaid connected users out of this escape
path
- cover paywall props and connection settings access

## Tests
- php artisan test --compact tests/Feature/SubscriptionTest.php
- npx eslint resources/js/pages/subscription/paywall.tsx
This commit is contained in:
Víctor Falcón 2026-05-22 10:52:50 +01:00 committed by GitHub
parent 88faa5beb6
commit eaba315196
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 113 additions and 2 deletions

View File

@ -24,7 +24,8 @@ class SubscriptionController extends Controller
return redirect()->route('dashboard');
}
$canUseFreePlan = ! $user->bankingConnections()->exists();
$hasBankConnections = $user->bankingConnections()->exists();
$canUseFreePlan = ! $hasBankConnections;
// Mark the paywall as seen so the middleware stops redirecting here.
if ($canUseFreePlan && ! $user->hasSeenPaywall()) {
@ -34,6 +35,9 @@ class SubscriptionController extends Controller
return Inertia::render('subscription/paywall', [
'stats' => $this->getUserStats($user),
'canUseFreePlan' => $canUseFreePlan,
'canManageConnectionsForFreePlan' => $user->isOnboarded()
&& $hasBankConnections
&& $user->hasCanceledSubscription(),
]);
}

View File

@ -179,6 +179,19 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
&& ! $subscription->ended();
}
public function hasCanceledSubscription(): bool
{
if (! config('subscriptions.enabled')) {
return false;
}
$subscription = $this->subscription('default');
return $subscription !== null
&& $subscription->stripe_status === 'canceled'
&& $subscription->ended();
}
/**
* The tax rates that should apply to the customer's subscriptions.
*

View File

@ -632,6 +632,7 @@
"Get started quickly with your existing financial data.": "Comienza rápidamente con tus datos financieros existentes.",
"Github": "Github",
"Go to Dashboard": "Ir al Panel",
"Go to Settings": "Ir a Configuración",
"Go to your account's transaction history": "Ve al historial de transacciones de tu cuenta",
"Go to your dashboard and click \"Import Transactions\". Select your CSV file and I'll map the columns automatically.": "Ve a tu panel y haz clic en \"Importar Transacciones\". Selecciona tu archivo CSV y mapearé las columnas automáticamente.",
"Go to your dashboard and click \"Import Transactions\". Select your CSV file and we'll map the columns automatically.": "Ve a tu panel y haz clic en \"Importar Transacciones\". Selecciona tu archivo CSV y mapearemos las columnas automáticamente.",
@ -1508,6 +1509,7 @@
"Visualize your money flow over time. See income vs. expenses and spot trends before they become problems.": "Visualiza el flujo de tu dinero a lo largo del tiempo. Ve ingresos frente a gastos e identifica tendencias antes de que se conviertan en problemas.",
"Víctor & Álvaro": "Víctor y Álvaro",
"Want to connect your bank directly?": "¿Quieres conectar tu banco directamente?",
"Want to continue for free? Disconnect all bank connections in Settings.": "¿Quieres continuar gratis? Desconecta todas las conexiones bancarias en Configuración.",
"Warning": "Advertencia",
"We Can't Read It": "No Podemos Leerlo",
"We also support Open Banking connections so your transactions sync automatically. Head to Settings > Connections to link your bank directly.": "También admitimos conexiones de Open Banking para que tus transacciones se sincronicen automáticamente. Ve a Configuración > Conexiones para vincular tu banco directamente.",

View File

@ -4,6 +4,7 @@ import { useCountUp } from '@/hooks/use-count-up';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { dashboard } from '@/routes';
import { index as connectionsIndex } from '@/routes/settings/connections';
import { checkout } from '@/routes/subscribe';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
@ -34,6 +35,7 @@ interface PaywallStats {
interface PaywallPageProps extends SharedData {
stats: PaywallStats;
canUseFreePlan: boolean;
canManageConnectionsForFreePlan: boolean;
}
function getEquivalentBillingLabel(
@ -332,11 +334,13 @@ function PricingSection({
defaultPlan,
currency,
canUseFreePlan,
canManageConnectionsForFreePlan,
}: {
planEntries: [string, Plan][];
defaultPlan: string;
currency: string;
canUseFreePlan: boolean;
canManageConnectionsForFreePlan: boolean;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const [freeButtonVisible, setFreeButtonVisible] = useState(false);
@ -380,6 +384,23 @@ function PricingSection({
</Button>
</a>
{canManageConnectionsForFreePlan && (
<div className="rounded-lg border bg-muted/30 p-3 text-center">
<p className="mb-3 text-sm text-muted-foreground">
{__(
'Want to continue for free? Disconnect all bank connections in Settings.',
)}
</p>
<Button
variant="outline"
className="w-full"
onClick={() => router.visit(connectionsIndex().url)}
>
{__('Go to Settings')}
</Button>
</div>
)}
{canUseFreePlan && (
<div
className={cn(
@ -403,7 +424,7 @@ function PricingSection({
}
export default function Paywall() {
const { pricing, stats, canUseFreePlan } =
const { pricing, stats, canUseFreePlan, canManageConnectionsForFreePlan } =
usePage<PaywallPageProps>().props;
const planEntries = Object.entries(pricing.plans);
@ -426,6 +447,9 @@ export default function Paywall() {
defaultPlan={pricing.defaultPlan}
currency={pricing.currency}
canUseFreePlan={canUseFreePlan}
canManageConnectionsForFreePlan={
canManageConnectionsForFreePlan
}
/>
</div>
</div>

View File

@ -150,6 +150,73 @@ test('canceled subscribed users cannot use paid bank connection features', funct
$this->get(route('dashboard'))->assertRedirect(route('subscribe'));
});
test('canceled subscribed users with bank connections can access connections settings', function () {
$user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]);
BankingConnection::factory()->for($user)->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_canceled_settings_test123',
'stripe_status' => 'canceled',
'stripe_price' => 'price_test123',
'ends_at' => now()->subMinute(),
]);
$this->actingAs($user);
$this->get(route('settings.connections.index'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('settings/connections')
->has('connections', 1)
);
});
test('paywall lets canceled subscribed users with bank connections manage connections for free plan', function () {
$user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]);
BankingConnection::factory()->for($user)->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_canceled_manage_connections_test123',
'stripe_status' => 'canceled',
'stripe_price' => 'price_test123',
'ends_at' => now()->subMinute(),
]);
$this->actingAs($user);
$this->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('canUseFreePlan', false)
->where('canManageConnectionsForFreePlan', true)
);
});
test('paywall does not show manage connections option during onboarding', function () {
$user = User::factory()->notOnboarded()->create(['paywall_seen_at' => now()]);
BankingConnection::factory()->for($user)->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_canceled_onboarding_test123',
'stripe_status' => 'canceled',
'stripe_price' => 'price_test123',
'ends_at' => now()->subMinute(),
]);
$this->actingAs($user);
$this->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('canManageConnectionsForFreePlan', false)
);
});
test('users can view the success page after subscribing', function () {
$user = User::factory()->onboarded()->create();
@ -336,6 +403,7 @@ test('paywall shows canUseFreePlan false when user has a bank connection', funct
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('canUseFreePlan', false)
->where('canManageConnectionsForFreePlan', false)
);
});