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:
parent
7e36bbafef
commit
10442c1e32
|
|
@ -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!",
|
"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.",
|
"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": "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",
|
"Rules created": "Reglas creadas",
|
||||||
"We created :rules rules and categorized :count transactions for you.": "Hemos creado :rules reglas y categorizado :count transacciones por ti.",
|
"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",
|
"Looking for patterns": "Buscando patrones",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||||
import { StepAiSuggestions } from './step-ai-suggestions';
|
import { StepAiSuggestions } from './step-ai-suggestions';
|
||||||
|
|
||||||
const UPGRADE_NOTICE =
|
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.
|
// Mutable so each test can flip whether an upgrade is required before render.
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -55,19 +55,53 @@ vi.mock('@inertiajs/react', () => ({
|
||||||
|
|
||||||
describe('StepAiSuggestions upgrade notice', () => {
|
describe('StepAiSuggestions upgrade notice', () => {
|
||||||
it('warns free users that AI suggestions require a paid plan', async () => {
|
it('warns free users that AI suggestions require a paid plan', async () => {
|
||||||
|
state.consented = false;
|
||||||
state.requires_upgrade = true;
|
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();
|
expect(await screen.findByText(UPGRADE_NOTICE)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the notice when no upgrade is required', async () => {
|
it('omits the notice when no upgrade is required', async () => {
|
||||||
|
state.consented = false;
|
||||||
state.requires_upgrade = false;
|
state.requires_upgrade = false;
|
||||||
render(<StepAiSuggestions categories={[]} onComplete={vi.fn()} />);
|
render(
|
||||||
|
<StepAiSuggestions
|
||||||
|
categories={[]}
|
||||||
|
hasConnectedAccount={false}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('Suggest my rules with AI'),
|
await screen.findByText('Suggest my rules with AI'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(UPGRADE_NOTICE)).not.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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,11 @@ describe('StepAiSuggestions generating state', () => {
|
||||||
|
|
||||||
function renderStep() {
|
function renderStep() {
|
||||||
return render(
|
return render(
|
||||||
<StepAiSuggestions categories={[]} onComplete={vi.fn()} />,
|
<StepAiSuggestions
|
||||||
|
categories={[]}
|
||||||
|
hasConnectedAccount={false}
|
||||||
|
onComplete={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,13 @@ interface AcceptResponse {
|
||||||
|
|
||||||
interface StepAiSuggestionsProps {
|
interface StepAiSuggestionsProps {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
hasConnectedAccount: boolean;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepAiSuggestions({
|
export function StepAiSuggestions({
|
||||||
categories,
|
categories,
|
||||||
|
hasConnectedAccount,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: StepAiSuggestionsProps) {
|
}: StepAiSuggestionsProps) {
|
||||||
const [state, setState] = useState<SuggestionState | null>(null);
|
const [state, setState] = useState<SuggestionState | null>(null);
|
||||||
|
|
@ -130,6 +132,16 @@ export function StepAiSuggestions({
|
||||||
!data.run
|
!data.run
|
||||||
) {
|
) {
|
||||||
startGenerate();
|
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 {
|
} catch {
|
||||||
// Never block onboarding if the AI step can't load.
|
// 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">
|
<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">
|
<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>
|
</p>
|
||||||
{cheapestMonthlyPrice !== null && (
|
{cheapestMonthlyPrice !== null && (
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,7 @@ export default function Onboarding({
|
||||||
return (
|
return (
|
||||||
<StepAiSuggestions
|
<StepAiSuggestions
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
hasConnectedAccount={hasConnectedAccount}
|
||||||
onComplete={goNext}
|
onComplete={goNext}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -599,6 +599,69 @@ it('completes entire onboarding flow with account creation, transaction import,
|
||||||
expect($transactions->pluck('currency_code')->unique()->first())->toBe('EUR');
|
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
|
// 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')
|
->assertSee('Continue for free')
|
||||||
->assertNoJavascriptErrors();
|
->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();
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue