feat(subscription): allow free plan for open banking users without connected banks (#188)

## Summary

- Open Banking users who complete onboarding **without** connecting a
bank are no longer blocked at `/subscribe` — they can continue for free
via a new \"Continue for free\" button on the paywall
- Users who **did** connect a bank during onboarding see the standard
paywall with no free option (bank sync is a paid/Standard feature)
- Renamed the paid plan badge from **Pro** → **Standard** in the
onboarding UI

## Changes

### Backend
- `EnsureUserIsSubscribed` middleware: grants free access when
`open-banking` feature is active and the user has no
`banking_connections`
- `SubscriptionController::index()`: passes a `canUseFreePlan` boolean
prop to the paywall page

### Frontend
- `paywall.tsx`: accepts `canUseFreePlan` prop and renders a "Continue
for free" button that navigates to the dashboard
- `step-create-account.tsx`: badge and info text updated from "Pro" →
"Standard"

### Tests
- Two new browser tests in `OnboardingFlowTest.php`:
  - Manual account flow → `/subscribe` shows "Continue for free"
- BBVA connected bank flow (EnableBanking sandbox) → `/subscribe` does
NOT show "Continue for free"
This commit is contained in:
Víctor Falcón 2026-03-03 22:28:50 +00:00 committed by GitHub
parent fdc9d14c47
commit d8f6a680ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 182 additions and 13 deletions

View File

@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Cashier\Checkout;
use Laravel\Pennant\Feature;
class SubscriptionController extends Controller
{
@ -20,8 +21,17 @@ class SubscriptionController extends Controller
return redirect()->route('dashboard');
}
$canUseFreePlan = Feature::for($user)->active('open-banking')
&& ! $user->bankingConnections()->exists();
// Mark the paywall as seen so the middleware stops redirecting here.
if ($canUseFreePlan && ! $user->hasSeenPaywall()) {
$user->update(['paywall_seen_at' => now()]);
}
return Inertia::render('subscription/paywall', [
'stats' => $this->getUserStats($user),
'canUseFreePlan' => $canUseFreePlan,
]);
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Laravel\Pennant\Feature;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsSubscribed
@ -17,7 +18,20 @@ class EnsureUserIsSubscribed
return $next($request);
}
if ($request->user()?->hasProPlan()) {
$user = $request->user();
if ($user?->hasProPlan()) {
return $next($request);
}
// If Open Banking is enabled and the user has no bank connections,
// they may use the app for free — but they must first see the paywall
// so they can make an informed choice.
if ($user && Feature::for($user)->active('open-banking') && ! $user->bankingConnections()->exists()) {
if (! $user->hasSeenPaywall()) {
return redirect()->route('subscribe');
}
return $next($request);
}

View File

@ -33,6 +33,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
'password',
'encryption_salt',
'onboarded_at',
'paywall_seen_at',
'currency_code',
'locale',
];
@ -61,6 +62,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'onboarded_at' => 'datetime',
'paywall_seen_at' => 'datetime',
];
}
@ -69,6 +71,11 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
return $this->onboarded_at !== null;
}
public function hasSeenPaywall(): bool
{
return $this->paywall_seen_at !== null;
}
/** @return HasOne<UserSetting, $this> */
public function setting(): HasOne
{

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('paywall_seen_at')->nullable()->after('onboarded_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('paywall_seen_at');
});
}
};

View File

@ -264,11 +264,12 @@
"Connect your bank and sync transactions automatically.": "Conecta tu banco y sincroniza las transacciones automáticamente.",
"Connected": "Conectado",
"Connected account": "Cuenta conectada",
"Connected accounts are a Pro feature. You'll choose a plan at the end of the onboarding.": "Las cuentas conectadas son una función Pro. Elegirás un plan al final del proceso de incorporación.",
"Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.": "Las cuentas conectadas son una función del plan Standard. Elegirás un plan al final del proceso de incorporación.",
"Connecting...": "Conectando...",
"Connections": "Conexiones",
"Consider saving more if possible.": "Considera ahorrar más si es posible.",
"Continue": "Continuar",
"Continue for free": "Continuar gratis",
"Continue Setup": "Continuar Configuración",
"Continue to Import": "Continuar con Importación",
"Country": "País",

View File

@ -1,6 +1,5 @@
import { complete } from '@/actions/App/Http/Controllers/OnboardingController';
import { StepButton } from '@/components/onboarding/step-button';
import { dashboard } from '@/routes';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { PartyPopper } from 'lucide-react';
@ -16,9 +15,6 @@ export function StepComplete() {
complete.url(),
{},
{
onSuccess: () => {
router.visit(dashboard().url);
},
onError: () => {
setIsRedirecting(false);
},

View File

@ -456,7 +456,7 @@ export function StepCreateAccount({
>
{subscriptionsEnabled && (
<span className="absolute top-2.5 right-2.5 rounded-full bg-gradient-to-r from-blue-500 to-indigo-500 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white uppercase">
Pro
Standard
</span>
)}
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/40">
@ -488,7 +488,7 @@ export function StepCreateAccount({
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
<p className="text-center text-sm text-blue-700 dark:text-blue-300">
{__(
"Connected accounts are a Pro feature. You'll choose a plan at the end of the onboarding.",
"Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.",
)}
</p>
</div>

View File

@ -9,12 +9,13 @@ import {
import { useCountUp } from '@/hooks/use-count-up';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { dashboard } from '@/routes';
import { checkout } from '@/routes/subscribe';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import { Head, router, usePage } from '@inertiajs/react';
import {
CheckIcon,
FolderIcon,
@ -37,6 +38,7 @@ interface PaywallStats {
interface PaywallPageProps extends SharedData {
stats: PaywallStats;
canUseFreePlan: boolean;
}
function getEquivalentBillingLabel(
@ -369,7 +371,8 @@ function PromoSection() {
}
export default function Paywall() {
const { pricing, stats } = usePage<PaywallPageProps>().props;
const { pricing, stats, canUseFreePlan } =
usePage<PaywallPageProps>().props;
const planEntries = Object.entries(pricing.plans);
if (planEntries.length === 0) {
@ -392,6 +395,17 @@ export default function Paywall() {
/>
{pricing.promo.enabled && <PromoSection />}
{canUseFreePlan && (
<div className="text-center">
<button
onClick={() => router.visit(dashboard().url)}
className="text-sm text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
>
{__('Continue for free')}
</button>
</div>
)}
</div>
</div>
</>

View File

@ -44,7 +44,7 @@ test('user can view budgets list with existing budgets', function () {
$page = $this->actingAs($user)->visit('/budgets');
$page->assertSee('Budgets')
$page->waitForText('Budgets', 10)
->assertSee('Test Budget')
->assertNoJavascriptErrors();
});
@ -56,7 +56,7 @@ test('user can open create budget dialog', function () {
$page = $this->actingAs($user)->visit('/budgets');
$page->assertSee('Budgets')
$page->waitForText('Budgets', 10)
->click('Create Budget')
->wait(1)
->assertSee('Create Budget')
@ -75,7 +75,7 @@ test('user can view a specific budget', function () {
$page = $this->actingAs($user)->visit('/budgets');
$page->assertSee('My Monthly Budget')
$page->waitForText('My Monthly Budget', 10)
->click('View Details')
->wait(2)
->assertPathIs("/budgets/{$budget->id}")

View File

@ -3,6 +3,7 @@
use App\Models\Account;
use App\Models\Bank;
use App\Models\User;
use Laravel\Pennant\Feature;
// =============================================================================
// Basic Redirect Tests
@ -342,3 +343,24 @@ it('completes entire onboarding flow with account creation, transaction import,
expect($transactions)->toHaveCount(5);
expect($transactions->pluck('currency_code')->unique()->first())->toBe('EUR');
});
// =============================================================================
// Subscribe Page Free Plan Tests
// =============================================================================
it('shows free plan option on subscribe page when open banking is enabled and no bank was connected', function () {
config(['subscriptions.enabled' => true]);
// Create an onboarded user with open-banking active and no banking connections
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
Feature::for($user)->activate('open-banking');
$page = visit('/subscribe');
$page->assertPathIs('/subscribe')
->assertSee('Continue for free')
->assertNoJavascriptErrors();
});

View File

@ -2,9 +2,11 @@
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\BankingConnection;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Laravel\Pennant\Feature;
beforeEach(function () {
config(['subscriptions.enabled' => true]);
@ -199,3 +201,78 @@ test('pricing config includes all plan details', function () {
)
);
});
test('open banking users without bank connections are redirected to paywall on first visit', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user);
$this->get(route('dashboard'))->assertRedirect(route('subscribe'));
});
test('open banking users without bank connections can access protected routes after seeing paywall', function () {
$user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]);
Feature::for($user)->activate('open-banking');
$this->actingAs($user);
$this->get(route('dashboard'))->assertOk();
});
test('open banking users with a bank connection are redirected to paywall', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->for($user)->create();
$this->actingAs($user);
$this->get(route('dashboard'))->assertRedirect(route('subscribe'));
});
test('paywall shows canUseFreePlan true when open banking is active and no bank connected', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user);
$this->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('canUseFreePlan', true)
);
});
test('paywall shows canUseFreePlan false when open banking is active but user has a bank connection', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->for($user)->create();
$this->actingAs($user);
$this->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('canUseFreePlan', false)
);
});
test('paywall shows canUseFreePlan false when open banking is not active', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
$this->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('canUseFreePlan', false)
);
});