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:
parent
fdc9d14c47
commit
d8f6a680ce
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue