diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index a08c5559..5f58938f 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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, ]); } diff --git a/app/Http/Middleware/EnsureUserIsSubscribed.php b/app/Http/Middleware/EnsureUserIsSubscribed.php index 3b50690d..74ce9109 100644 --- a/app/Http/Middleware/EnsureUserIsSubscribed.php +++ b/app/Http/Middleware/EnsureUserIsSubscribed.php @@ -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); } diff --git a/app/Models/User.php b/app/Models/User.php index 8ef194e1..e6ef6873 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ public function setting(): HasOne { diff --git a/database/migrations/2026_03_03_114620_add_paywall_seen_at_to_users_table.php b/database/migrations/2026_03_03_114620_add_paywall_seen_at_to_users_table.php new file mode 100644 index 00000000..bbe4e01f --- /dev/null +++ b/database/migrations/2026_03_03_114620_add_paywall_seen_at_to_users_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 972c638e..1d0e7eda 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/onboarding/step-complete.tsx b/resources/js/components/onboarding/step-complete.tsx index e314d2d4..7798fdeb 100644 --- a/resources/js/components/onboarding/step-complete.tsx +++ b/resources/js/components/onboarding/step-complete.tsx @@ -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); }, diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx index f7bf97f7..400f9bac 100644 --- a/resources/js/components/onboarding/step-create-account.tsx +++ b/resources/js/components/onboarding/step-create-account.tsx @@ -456,7 +456,7 @@ export function StepCreateAccount({ > {subscriptionsEnabled && ( - Pro + Standard )}
@@ -488,7 +488,7 @@ export function StepCreateAccount({

{__( - "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.", )}

diff --git a/resources/js/pages/subscription/paywall.tsx b/resources/js/pages/subscription/paywall.tsx index ca792529..f02260b3 100644 --- a/resources/js/pages/subscription/paywall.tsx +++ b/resources/js/pages/subscription/paywall.tsx @@ -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().props; + const { pricing, stats, canUseFreePlan } = + usePage().props; const planEntries = Object.entries(pricing.plans); if (planEntries.length === 0) { @@ -392,6 +395,17 @@ export default function Paywall() { /> {pricing.promo.enabled && } + + {canUseFreePlan && ( +
+ +
+ )}
diff --git a/tests/Browser/BudgetsFeatureNavigationTest.php b/tests/Browser/BudgetsFeatureNavigationTest.php index d6cbdb78..31e35302 100644 --- a/tests/Browser/BudgetsFeatureNavigationTest.php +++ b/tests/Browser/BudgetsFeatureNavigationTest.php @@ -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}") diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index e2356bcf..78ecb692 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -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(); +}); diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index eeae8b32..e7bec62b 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -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) + ); +});