feat(onboarding): auto-enable AI for connected banks, ask the rest (#618)

## 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.
This commit is contained in:
Víctor Falcón 2026-07-01 09:26:52 +02:00 committed by GitHub
parent 7e36bbafef
commit 10442c1e32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 152 additions and 6 deletions

View File

@ -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. Ill 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",

View File

@ -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(<StepAiSuggestions categories={[]} onComplete={vi.fn()} />);
render(
<StepAiSuggestions
categories={[]}
hasConnectedAccount={false}
onComplete={vi.fn()}
/>,
);
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(<StepAiSuggestions categories={[]} onComplete={vi.fn()} />);
render(
<StepAiSuggestions
categories={[]}
hasConnectedAccount={false}
onComplete={vi.fn()}
/>,
);
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(
<StepAiSuggestions
categories={[]}
hasConnectedAccount
onComplete={vi.fn()}
/>,
);
// 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();
});
});

View File

@ -27,7 +27,11 @@ describe('StepAiSuggestions generating state', () => {
function renderStep() {
return render(
<StepAiSuggestions categories={[]} onComplete={vi.fn()} />,
<StepAiSuggestions
categories={[]}
hasConnectedAccount={false}
onComplete={vi.fn()}
/>,
);
}

View File

@ -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<SuggestionState | null>(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() {
<div className="w-full max-w-md rounded-lg border border-emerald-100 bg-emerald-50 p-3 dark:border-emerald-900/50 dark:bg-emerald-900/20">
<p className="text-center text-sm text-balance text-emerald-700 dark:text-emerald-300">
{__(
"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.",
)}
</p>
{cheapestMonthlyPrice !== null && (

View File

@ -223,6 +223,7 @@ export default function Onboarding({
return (
<StepAiSuggestions
categories={categories}
hasConnectedAccount={hasConnectedAccount}
onComplete={goNext}
/>
);

View File

@ -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();
});