From 10442c1e3293270dcc75a299414b793c5db14610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 1 Jul 2026 09:26:52 +0200 Subject: [PATCH] feat(onboarding): auto-enable AI for connected banks, ask the rest (#618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why During onboarding we prompt for AI suggestions only because it's a **paid feature** — enabling it forces the user to pick a plan at the end of onboarding. A user who has **already connected a bank** is committed to a paid plan regardless, so the prompt gives them nothing new. For them we should just turn AI on. ## What - **Connected bank → activate AI directly.** The consent prompt is skipped and consent is recorded automatically (reuses the existing `/ai/consent` endpoint, so the categorization backfill still runs). `setBusy` batches with the state update so the consent screen never flashes. - **No bank → unchanged opt-in.** Free users still explicitly accept. The notice is reworded to make the paid framing clear: *"AI suggestions are a paid feature. Enable them and you'll choose a plan at the end of the onboarding."* (translated in `es.json`). - No backend change needed: `RuleSuggestionController::requiresUpgrade()` already treats bank-connected users as not needing an upgrade, and `SubscriptionController` already gates the free plan on `!hasBank && !hasConsent`. ## Tests Browser tests in `OnboardingFlowTest`: - Connected bank → consent prompt skipped and `hasActiveAiConsent()` becomes true. - No bank → prompt (with the paid notice) shown; consent recorded only after accepting. - `/subscribe` forces a plan (no "Continue for free") when a bank is connected or AI consent is active. Vitest specs for `StepAiSuggestions` updated (new prop + reworded notice) plus a new case asserting bank-connected users auto-activate. All green: 5/5 browser tests (24 assertions), 6/6 vitest. --- lang/es.json | 2 +- .../step-ai-suggestions-upgrade.test.tsx | 40 +++++++- .../onboarding/step-ai-suggestions.test.tsx | 6 +- .../onboarding/step-ai-suggestions.tsx | 14 ++- resources/js/pages/onboarding/index.tsx | 1 + tests/Browser/OnboardingFlowTest.php | 95 +++++++++++++++++++ 6 files changed, 152 insertions(+), 6 deletions(-) diff --git a/lang/es.json b/lang/es.json index d94d041a..f7bb1966 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2150,7 +2150,7 @@ "The app is intuitive, functional, and a real help for managing my finances day to day. What stands out most is how much the free version offers — it really shows your commitment to your users. I’ll keep recommending it!": "La aplicación es intuitiva, funcional y de gran ayuda para gestionar mis finanzas en el día a día. Lo que más destaca es la cantidad de opciones de la versión gratuita: demuestra vuestro compromiso con los usuarios. ¡Seguiré recomendándola!", "I built the app I needed to make better decisions. Understanding how I spend and where my income comes from has brought me real financial peace of mind.": "He construido la app que necesitaba para tomar mejores decisiones. Entender cómo gasto y de dónde vienen mis ingresos me ha dado una verdadera tranquilidad financiera.", "AI Suggestions": "Sugerencias de IA", - "AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.": "Las sugerencias de IA son una función del plan Standard. Elegirás un plan al final del proceso de incorporación.", + "AI suggestions are a paid feature. Enable them and you'll choose a plan at the end of the onboarding.": "Las sugerencias de IA son una función de pago. Actívalas y elegirás un plan al final del proceso de incorporación.", "Rules created": "Reglas creadas", "We created :rules rules and categorized :count transactions for you.": "Hemos creado :rules reglas y categorizado :count transacciones por ti.", "Looking for patterns": "Buscando patrones", diff --git a/resources/js/components/onboarding/step-ai-suggestions-upgrade.test.tsx b/resources/js/components/onboarding/step-ai-suggestions-upgrade.test.tsx index decae8ee..8e19dc3a 100644 --- a/resources/js/components/onboarding/step-ai-suggestions-upgrade.test.tsx +++ b/resources/js/components/onboarding/step-ai-suggestions-upgrade.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { StepAiSuggestions } from './step-ai-suggestions'; const UPGRADE_NOTICE = - "AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding."; + "AI suggestions are a paid feature. Enable them and you'll choose a plan at the end of the onboarding."; // Mutable so each test can flip whether an upgrade is required before render. const state = { @@ -55,19 +55,53 @@ vi.mock('@inertiajs/react', () => ({ describe('StepAiSuggestions upgrade notice', () => { it('warns free users that AI suggestions require a paid plan', async () => { + state.consented = false; state.requires_upgrade = true; - render(); + render( + , + ); expect(await screen.findByText(UPGRADE_NOTICE)).toBeInTheDocument(); }); it('omits the notice when no upgrade is required', async () => { + state.consented = false; state.requires_upgrade = false; - render(); + render( + , + ); expect( await screen.findByText('Suggest my rules with AI'), ).toBeInTheDocument(); expect(screen.queryByText(UPGRADE_NOTICE)).not.toBeInTheDocument(); }); + + it('activates AI directly for users with a connected bank', async () => { + state.consented = false; + state.requires_upgrade = false; + render( + , + ); + + // Auto-consent runs, so the prompt is skipped and we jump to generating. + expect( + await screen.findByText('Looking for patterns'), + ).toBeInTheDocument(); + expect( + screen.queryByText('Suggest my rules with AI'), + ).not.toBeInTheDocument(); + }); }); diff --git a/resources/js/components/onboarding/step-ai-suggestions.test.tsx b/resources/js/components/onboarding/step-ai-suggestions.test.tsx index 320c14e0..dec41213 100644 --- a/resources/js/components/onboarding/step-ai-suggestions.test.tsx +++ b/resources/js/components/onboarding/step-ai-suggestions.test.tsx @@ -27,7 +27,11 @@ describe('StepAiSuggestions generating state', () => { function renderStep() { return render( - , + , ); } diff --git a/resources/js/components/onboarding/step-ai-suggestions.tsx b/resources/js/components/onboarding/step-ai-suggestions.tsx index 1ac5b69b..74d5a60a 100644 --- a/resources/js/components/onboarding/step-ai-suggestions.tsx +++ b/resources/js/components/onboarding/step-ai-suggestions.tsx @@ -39,11 +39,13 @@ interface AcceptResponse { interface StepAiSuggestionsProps { categories: Category[]; + hasConnectedAccount: boolean; onComplete: () => void; } export function StepAiSuggestions({ categories, + hasConnectedAccount, onComplete, }: StepAiSuggestionsProps) { const [state, setState] = useState(null); @@ -130,6 +132,16 @@ export function StepAiSuggestions({ !data.run ) { startGenerate(); + } else if (!data.consented && hasConnectedAccount) { + // A linked bank already commits the user to a paid plan, so + // enabling AI adds no cost — skip the consent prompt and turn + // it on directly. setBusy batches with applyState above, so + // the consent screen never flashes. + setBusy(true); + await axios.post(storeConsent().url); + if (!cancelled) { + startGenerate(); + } } } catch { // Never block onboarding if the AI step can't load. @@ -438,7 +450,7 @@ function UpgradeNotice() {

{__( - "AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.", + "AI suggestions are a paid feature. Enable them and you'll choose a plan at the end of the onboarding.", )}

{cheapestMonthlyPrice !== null && ( diff --git a/resources/js/pages/onboarding/index.tsx b/resources/js/pages/onboarding/index.tsx index 0b395c8d..59342c45 100644 --- a/resources/js/pages/onboarding/index.tsx +++ b/resources/js/pages/onboarding/index.tsx @@ -223,6 +223,7 @@ export default function Onboarding({ return ( ); diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 15a5867f..7871ed50 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -599,6 +599,69 @@ it('completes entire onboarding flow with account creation, transaction import, expect($transactions->pluck('currency_code')->unique()->first())->toBe('EUR'); }); +// ============================================================================= +// AI Suggestions Consent Tests +// ============================================================================= + +it('activates AI directly without a consent prompt when a bank is connected', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->create(['onboarded_at' => null]); + + $bank = Bank::factory()->create(['name' => 'Connected AI Bank']); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'banking_connection_id' => $connection->id, + 'type' => 'checking', + 'currency_code' => 'EUR', + ]); + + $this->actingAs($user); + + $page = visit('/onboarding?step=ai-suggestions'); + + // A linked bank already commits the user to a paid plan, so the consent + // prompt is skipped and AI is turned on for them. With no transactions yet + // the run stops at the "need more data" screen instead of calling the AI. + $page->wait(3) + ->assertDontSee('Let AI organize your money') + ->assertDontSee('Suggest my rules with AI') + ->assertSee('AI suggestions need more data') + ->assertNoJavascriptErrors(); + + expect($user->refresh()->hasActiveAiConsent())->toBeTrue(); +}); + +it('asks for consent before activating AI when no bank is connected', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->create(['onboarded_at' => null]); + + $this->actingAs($user); + + expect($user->hasActiveAiConsent())->toBeFalse(); + + $page = visit('/onboarding?step=ai-suggestions'); + + // Free users must opt in, and are told AI commits them to picking a plan. + $page->wait(2) + ->assertSee('Let AI organize your money') + ->assertSee("AI suggestions are a paid feature. Enable them and you'll choose a plan at the end of the onboarding.") + ->assertSee('Suggest my rules with AI') + ->assertNoJavascriptErrors(); + + // Nothing is activated until they explicitly accept. + expect($user->refresh()->hasActiveAiConsent())->toBeFalse(); + + $page->click('Suggest my rules with AI') + ->wait(3) + ->assertNoJavascriptErrors(); + + expect($user->refresh()->hasActiveAiConsent())->toBeTrue(); +}); + // ============================================================================= // Subscribe Page Free Plan Tests // ============================================================================= @@ -616,3 +679,35 @@ it('shows free plan option on subscribe page when no bank was connected', functi ->assertSee('Continue for free') ->assertNoJavascriptErrors(); }); + +it('forces a plan choice on subscribe when a bank is connected', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->onboarded()->create(); + BankingConnection::factory()->create(['user_id' => $user->id]); + + $this->actingAs($user); + + $page = visit('/subscribe'); + + $page->assertPathIs('/subscribe') + ->assertSee('Start My Financial Journey') + ->assertDontSee('Continue for free') + ->assertNoJavascriptErrors(); +}); + +it('forces a plan choice on subscribe when AI consent is active', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->onboarded()->create(); + $user->recordAiConsent(); + + $this->actingAs($user); + + $page = visit('/subscribe'); + + $page->assertPathIs('/subscribe') + ->assertSee('Start My Financial Journey') + ->assertDontSee('Continue for free') + ->assertNoJavascriptErrors(); +});