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!",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ describe('StepAiSuggestions generating state', () => {
|
|||
|
||||
function renderStep() {
|
||||
return render(
|
||||
<StepAiSuggestions categories={[]} onComplete={vi.fn()} />,
|
||||
<StepAiSuggestions
|
||||
categories={[]}
|
||||
hasConnectedAccount={false}
|
||||
onComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ export default function Onboarding({
|
|||
return (
|
||||
<StepAiSuggestions
|
||||
categories={categories}
|
||||
hasConnectedAccount={hasConnectedAccount}
|
||||
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');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue