feat(onboarding): inline connected account flow with auto-account creation and step deep-linking (#184)

## 🚪 Why?

### Problem
The onboarding flow had no integration with connected (bank-linked)
accounts. Users who connected a bank via OAuth were redirected to a
separate account-mapping page, breaking the onboarding flow. After
returning, there was no way to resume at the correct step. Additionally,
connected accounts were incorrectly shown in the manual import steps
(import-transactions / import-balances), which don't apply to them.

## 🔑 What?

### Changes
- **Inline bank connect wizard**: Added `ConnectAccountInline` component
that embeds the full 3-step bank OAuth flow within the onboarding
`create-account` step, replacing the redirect to a separate page.
- **Manual / Connected selection UI**: `StepCreateAccount` now presents
a choice between manual and connected account setup before proceeding.
- **Auto-account creation on OAuth callback**:
`AccountMappingController` and `AuthorizationController` auto-create
accounts for non-onboarded users and redirect back to
`/onboarding?step=create-account` instead of showing the mapping UI.
- **`?step=` deep-linking**: The onboarding page reads a `?step=` query
param on mount and starts at that step, so returning from bank OAuth
lands at the right place.
- **Skip import steps for connected accounts**: `handleAccountCreated`
detects `connected: true` and skips `import-transactions` /
`import-balances`, going directly to `category-types` (first account) or
`more-accounts` (subsequent accounts).
- **Fix phantom account in more-accounts**: Connected accounts are no
longer added to `createdAccounts` state (they already appear via
`existingAccounts`), preventing a duplicate nameless entry in the final
account list.
- **Feature flags enabled in non-production**: `open-banking` and
`account-mapping` Pennant flags now resolve to `! app()->isProduction()`
instead of always `false`.
- **Open-banking routes accessible during onboarding**: Removed
`onboarded`/`subscribed` middleware from open-banking routes;
`EnsureOnboardingComplete` middleware explicitly allows `open-banking.*`
routes through.

##  Verification

### Tests
- Updated `tests/Browser/OnboardingFlowTest.php` to reflect new UI
(manual/connected selection).
- Existing feature tests for open-banking controllers pass (pre-existing
failures in Binance/Bitpanda/IndexaCapital/AuthorizationController tests
are from a prior commit unrelated to this work).

### Manual Verification
- Connected account flow tested end-to-end: bank OAuth → auto-account
creation → redirect to `create-account` step → Continue → skip import
steps → `category-types`.
- 7-account BBVA user correctly shows all accounts in `more-accounts`
via `filteredExistingAccounts` with no phantom entry.
- Feature flags confirmed active in local environment via tinker.
This commit is contained in:
Víctor Falcón 2026-03-03 09:49:42 +00:00 committed by GitHub
parent c391732d0d
commit 993c91a6b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 993 additions and 80 deletions

View File

@ -22,7 +22,7 @@ class OnboardingController extends Controller
$accounts = $user->accounts()
->with('bank:id,name,logo')
->get(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank_id']);
->get(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank_id', 'banking_connection_id']);
return Inertia::render('onboarding/index', [
'banks' => $banks,

View File

@ -9,6 +9,7 @@ use App\Http\Requests\OpenBanking\MapAccountsRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -21,11 +22,25 @@ class AccountMappingController extends Controller
abort(403);
}
$user = auth()->user();
if (! $connection->hasPendingAccounts()) {
return redirect()->route('settings.connections.index');
$redirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding';
$params = $user->isOnboarded() ? [] : ['step' => 'create-account'];
return redirect()->route($redirect, $params);
}
$existingAccounts = auth()->user()
// During onboarding, skip the mapping UI — auto-create all accounts directly
if (! $user->isOnboarded()) {
$this->autoCreateAccounts($user, $connection);
SyncBankingConnectionJob::dispatch($connection);
return redirect()->route('onboarding', ['step' => 'create-account'])
->with('success', 'Bank account connected successfully.');
}
$existingAccounts = $user
->accounts()
->whereNull('banking_connection_id')
->with('bank')
@ -109,7 +124,58 @@ class AccountMappingController extends Controller
SyncBankingConnectionJob::dispatch($connection);
return redirect()->route('settings.connections.index')
$successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding';
$redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account'];
return redirect()->route($successRedirect, $redirectParams)
->with('success', 'Bank account connected successfully.');
}
/**
* Auto-create all pending accounts without user interaction.
*/
private function autoCreateAccounts(User $user, BankingConnection $connection): void
{
$bank = Bank::firstOrCreate(
['name' => $connection->aspsp_name, 'user_id' => null],
['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo],
);
if (! $bank->logo && $connection->aspsp_logo) {
$bank->update(['logo' => $connection->aspsp_logo]);
}
$accountType = ($connection->isIndexaCapital() || $connection->isBinance())
? AccountType::Investment
: AccountType::Checking;
foreach ($connection->pending_accounts_data ?? [] as $accountData) {
$uid = $accountData['uid'] ?? null;
if (! $uid) {
continue;
}
$currency = $accountData['currency'] ?? 'EUR';
$name = $accountData['name']
?? $accountData['account_id']['iban']
?? $connection->aspsp_name.' Account';
$user->accounts()->create([
'name' => $name,
'name_iv' => null,
'encrypted' => false,
'bank_id' => $bank->id,
'currency_code' => $currency,
'type' => $accountType->value,
'banking_connection_id' => $connection->id,
'external_account_id' => $uid,
]);
}
$connection->update([
'status' => BankingConnectionStatus::Active,
'pending_accounts_data' => null,
]);
}
}

View File

@ -54,26 +54,29 @@ class AuthorizationController extends Controller
*/
public function callback(Request $request, BankingProviderInterface $provider): RedirectResponse
{
$user = auth()->user();
$errorRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding';
if ($request->has('error')) {
Log::warning('EnableBanking authorization error', [
'error' => $request->query('error'),
'description' => $request->query('error_description'),
]);
auth()->user()->bankingConnections()
$user->bankingConnections()
->where('status', BankingConnectionStatus::Pending)
->latest()
->first()
?->delete();
return redirect()->route('settings.connections.index')
return redirect()->route($errorRedirect)
->with('error', $request->query('error_description', 'Authorization was denied or cancelled.'));
}
$code = $request->query('code');
if (! $code) {
return redirect()->route('settings.connections.index')
return redirect()->route($errorRedirect)
->with('error', 'No authorization code received.');
}
@ -82,19 +85,17 @@ class AuthorizationController extends Controller
} catch (\Throwable $e) {
Log::error('EnableBanking session creation failed', ['error' => $e->getMessage()]);
return redirect()->route('settings.connections.index')
return redirect()->route($errorRedirect)
->with('error', 'Failed to connect to your bank. Please try again.');
}
$user = auth()->user();
$connection = $user->bankingConnections()
->where('status', BankingConnectionStatus::Pending)
->latest()
->first();
if (! $connection) {
return redirect()->route('settings.connections.index')
return redirect()->route($errorRedirect)
->with('error', 'No pending connection found.');
}
@ -106,6 +107,14 @@ class AuthorizationController extends Controller
'pending_accounts_data' => $sessionData['accounts'],
]);
if (! $user->isOnboarded()) {
$this->createAccountsFromPending($user, $connection);
SyncBankingConnectionJob::dispatch($connection);
return redirect()->route('onboarding', ['step' => 'create-account'])
->with('success', 'Bank account connected successfully.');
}
return redirect()->route('open-banking.map-accounts', $connection);
}
@ -119,10 +128,57 @@ class AuthorizationController extends Controller
SyncBankingConnectionJob::dispatch($connection);
return redirect()->route('settings.connections.index')
$successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding';
$redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account'];
return redirect()->route($successRedirect, $redirectParams)
->with('success', 'Bank account connected successfully.');
}
/**
* Auto-create accounts from pending_accounts_data without user interaction.
*/
private function createAccountsFromPending($user, BankingConnection $connection): void
{
$bank = Bank::firstOrCreate(
['name' => $connection->aspsp_name, 'user_id' => null],
['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo],
);
if (! $bank->logo && $connection->aspsp_logo) {
$bank->update(['logo' => $connection->aspsp_logo]);
}
foreach ($connection->pending_accounts_data ?? [] as $accountData) {
$uid = $accountData['uid'] ?? null;
if (! $uid) {
continue;
}
$currency = $accountData['currency'] ?? 'EUR';
$name = $accountData['name']
?? $accountData['account_id']['iban']
?? $connection->aspsp_name.' Account';
$user->accounts()->create([
'name' => $name,
'name_iv' => null,
'encrypted' => false,
'bank_id' => $bank->id,
'currency_code' => $currency,
'type' => AccountType::Checking->value,
'banking_connection_id' => $connection->id,
'external_account_id' => $uid,
]);
}
$connection->update([
'status' => BankingConnectionStatus::Active,
'pending_accounts_data' => null,
]);
}
/**
* Create local accounts from the EnableBanking session data.
*/

View File

@ -85,8 +85,11 @@ class BinanceController extends Controller
SyncBankingConnectionJob::dispatch($connection);
$successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding';
$redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account'];
return response()->json([
'redirect_url' => route('settings.connections.index'),
'redirect_url' => route($successRedirect, $redirectParams),
'connection_id' => $connection->id,
]);
}

View File

@ -84,8 +84,11 @@ class BitpandaController extends Controller
SyncBankingConnectionJob::dispatch($connection);
$successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding';
$redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account'];
return response()->json([
'redirect_url' => route('settings.connections.index'),
'redirect_url' => route($successRedirect, $redirectParams),
'connection_id' => $connection->id,
]);
}

View File

@ -80,8 +80,11 @@ class IndexaCapitalController extends Controller
SyncBankingConnectionJob::dispatch($connection);
$successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding';
$redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account'];
return response()->json([
'redirect_url' => route('settings.connections.index'),
'redirect_url' => route($successRedirect, $redirectParams),
'connection_id' => $connection->id,
]);
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Laravel\Pennant\Feature;
use Symfony\Component\HttpFoundation\Response;
class ActivateDevelopmentFeatures
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (app()->isLocal() && $request->user()) {
Feature::for($request->user())->activate('open-banking');
Feature::for($request->user())->activate('account-mapping');
}
return $next($request);
}
}

View File

@ -25,6 +25,7 @@ class EnsureOnboardingComplete
}
$isOnboardingRoute = $request->routeIs('onboarding') || $request->routeIs('onboarding.*');
$isOpenBankingRoute = $request->routeIs('open-banking.*');
if ($user->isOnboarded()) {
if ($isOnboardingRoute) {
@ -34,6 +35,12 @@ class EnsureOnboardingComplete
return $next($request);
}
// Allow non-onboarded users through open-banking routes so they can
// connect their bank during the onboarding flow.
if ($isOpenBankingRoute) {
return $next($request);
}
if (! $isOnboardingRoute) {
return redirect()->route('onboarding');
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\ActivateDevelopmentFeatures;
use App\Http\Middleware\EnsureOpenBankingFeature;
use App\Http\Middleware\EnsureUserIsSubscribed;
use App\Http\Middleware\HandleAppearance;
@ -35,6 +36,7 @@ return Application::configure(basePath: dirname(__DIR__))
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
\App\Http\Middleware\BlockDemoAccountActions::class.':auto',
ActivateDevelopmentFeatures::class,
]);
$middleware->alias([

View File

@ -163,6 +163,7 @@
"Automation rules": "Reglas de automatización",
"Automation rules settings": "Configuración de reglas de automatización",
"Automation rules to categorize transactions automatically.": "Reglas de automatización para categorizar transacciones automáticamente.",
"Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco",
"Available:": "Disponible:",
"Back": "Atrás",
"Blue": "Azul",
@ -255,6 +256,7 @@
"Colorful": "Colorido",
"Connect": "Conectar",
"Connect Bank": "Conectar Banco",
"Connect Your Bank": "Conecta tu banco",
"Connect Bank Account": "Conectar Cuenta Bancaria",
"Connect your Binance account using your API Key and Secret.": "Conecta tu cuenta de Binance usando tu Clave API y Secreto.",
"Connect your Bitpanda account using your API Key.": "Conecta tu cuenta de Bitpanda usando tu Clave API.",
@ -262,6 +264,7 @@
"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.",
"Connecting...": "Conectando...",
"Connections": "Conexiones",
"Consider saving more if possible.": "Considera ahorrar más si es posible.",
@ -404,6 +407,7 @@
"Enter your encryption password to decrypt transactions information and accounts name.": "Ingresa tu contraseña de encriptación para descifrar la información de transacciones y nombres de cuentas.",
"Enter your encryption password to decrypt your transactions information.": "Introduce tu contraseña de cifrado para descifrar la información de tus transacciones.",
"Enter your new API credentials for :provider.": "Introduce tus nuevas credenciales API para :provider.",
"Enter transactions yourself or import a CSV file": "Introduce transacciones tú mismo o importa un archivo CSV",
"Entertainment (Movies, Sports, Hobbies)": "Entretenimiento (Películas, Deportes, Hobbies)",
"Error": "Error",
"Errors (": "Errores (",
@ -486,6 +490,7 @@
"Hi! It's Victor, the founder of Whisper Money. I see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Veo que ya has empezado a importar tus transacciones, ¡genial! Estás en camino de tomar el control de tus finanzas mientras mantienes tus datos privados.",
"Housing (Rent, Utilities, Maintenance)": "Vivienda (Alquiler, Suministros, Mantenimiento)",
"How We Protect Your Privacy": "Cómo Protegemos Tu Privacidad",
"How would you like to set up this account?": "¿Cómo te gustaría configurar esta cuenta?",
"How is my financial data kept private?": "¿Cómo se mantienen privados mis datos financieros?",
"How much do you want to budget per period?": "¿Cuánto deseas presupuestar por período?",
"How to Export from Your Bank:": "Cómo Exportar desde Tu Banco:",
@ -569,6 +574,7 @@
"Latest transactions in this account": "Últimas transacciones en esta cuenta",
"Let's Get Started": "Comencemos",
"Let's Import Your Transactions": "Importemos Tus Transacciones",
"Let's set up your first account to start tracking your finances.": "Configuremos tu primera cuenta para empezar a rastrear tus finanzas.",
"Let's start with your main checking account. You can add more accounts later.": "Empecemos con tu cuenta corriente principal. Puedes agregar más cuentas después.",
"Lifetime": "De por vida",
"Lifetime License": "Licencia de Por Vida",
@ -869,6 +875,7 @@
"Select row": "Seleccionar fila",
"Select the country where your bank is located.": "Selecciona el país donde está ubicado tu banco.",
"Select your bank.": "Selecciona tu banco.",
"Select your country and bank to get started.": "Selecciona tu país y banco para empezar.",
"Selected": "Seleccionado",
"Selected account not found": "Cuenta seleccionada no encontrada",
"Service Availability:": "Disponibilidad del Servicio:",

View File

@ -5,14 +5,26 @@ import {
AccountFormData,
} from '@/components/accounts/account-form';
import { StepHeader } from '@/components/onboarding/step-header';
import { ConnectAccountInline } from '@/components/open-banking/connect-account-inline';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { type AccountType, formatAccountType } from '@/types/account';
import { cn } from '@/lib/utils';
import { type SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { AlertCircle, CheckCircle2, CreditCard } from 'lucide-react';
import { usePage } from '@inertiajs/react';
import {
AlertCircle,
CheckCircle2,
CreditCard,
Link2,
PencilLine,
Zap,
} from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { StepButton } from './step-button';
type AccountMode = 'select' | 'manual' | 'connected';
interface ExistingAccount {
id: string;
name: string;
@ -21,6 +33,7 @@ interface ExistingAccount {
type: string;
currency_code: string;
bank_id: string;
banking_connection_id: string | null;
bank?: {
id: string;
name: string;
@ -37,28 +50,49 @@ interface StepCreateAccountProps {
}
export function StepCreateAccount({
banks,
isFirstAccount,
existingAccounts = [],
onAccountCreated,
onSkip,
}: StepCreateAccountProps) {
const { pricing, subscriptionsEnabled } = usePage<SharedData>().props;
const [mode, setMode] = useState<AccountMode>('select');
const [selectedMode, setSelectedMode] = useState<'manual' | 'connected'>(
'manual',
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: null,
type: isFirstAccount ? 'checking' : null,
type: null,
currencyCode: null,
customBank: null,
});
// Compute cheapest monthly equivalent across all plans
const cheapestMonthlyPrice = useMemo(() => {
const plans = Object.values(pricing.plans);
if (plans.length === 0) {
return null;
}
return Math.min(
...plans.map((plan) =>
plan.billing_period === 'year' ? plan.price / 12 : plan.price,
),
);
}, [pricing.plans]);
const handleFormChange = useCallback((data: AccountFormData) => {
formDataRef.current = data;
}, []);
async function createBankAndGetId(): Promise<string | null> {
const customBank = formDataRef.current.customBank;
if (!customBank) return null;
if (!customBank) {
return null;
}
const formData = new FormData();
formData.append('name', customBank.name);
@ -106,11 +140,6 @@ export function StepCreateAccount({
return;
}
if (isFirstAccount && type !== 'checking') {
setError(__('Your first account must be a checking account.'));
return;
}
setIsSubmitting(true);
try {
@ -167,11 +196,16 @@ export function StepCreateAccount({
const accountData = await response.json();
const bankName =
formDataRef.current.customBank?.name ??
banks.find((b) => String(b.id) === String(finalBankId))?.name;
onAccountCreated({
id: accountData.id || finalBankId,
name: displayName,
type: type,
currencyCode: currencyCode,
bankName,
});
setIsSubmitting(false);
} catch (err) {
@ -200,7 +234,7 @@ export function StepCreateAccount({
return {
title: __('Create an Account'),
description: __(
"Let's start with your main checking account. You can add more accounts later.",
"Let's set up your first account to start tracking your finances.",
),
};
}
@ -212,16 +246,17 @@ export function StepCreateAccount({
};
}, [hasExistingAccounts, isFirstAccount]);
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={CreditCard}
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-teal-500"
title={title}
description={description}
/>
// Show existing accounts view
if (hasExistingAccounts) {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={CreditCard}
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-teal-500"
title={title}
description={description}
/>
{hasExistingAccounts ? (
<div className="w-full max-w-md">
<div className="mb-6 space-y-2">
{existingAccounts.map((account) => (
@ -234,13 +269,31 @@ export function StepCreateAccount({
</div>
<div className="flex-1">
<p className="font-medium">
{account.bank?.name || 'Account'}
{account.name || 'Account'}
</p>
<p className="text-sm text-muted-foreground">
{formatAccountType(
account.type as AccountType,
)}{' '}
{account.currency_code}
<p className="flex gap-2 text-sm text-muted-foreground">
<span>
{account.bank?.name ?? `Bank`}
</span>
<span className="opacity-50">
&ndash;
</span>
<span>
{account.type
.split('_')
.map(
(w) =>
w
.charAt(0)
.toUpperCase() +
w.slice(1),
)
.join(' ')}
</span>
<span className="opacity-50">
&ndash;
</span>
<span>{account.currency_code}</span>
</p>
</div>
</div>
@ -254,35 +307,56 @@ export function StepCreateAccount({
onAccountCreated({
id: existingAccounts[0].id,
name:
existingAccounts[0].bank?.name || 'Account',
existingAccounts[0].name ||
existingAccounts[0].bank?.name ||
'Account',
type: existingAccounts[0].type,
currencyCode: existingAccounts[0].currency_code,
bankName: existingAccounts[0].bank?.name,
connected:
!!existingAccounts[0].banking_connection_id,
})
}
/>
</div>
) : (
</div>
);
}
// Connected account inline flow
if (mode === 'connected') {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Link2}
iconContainerClassName="bg-gradient-to-br from-blue-400 to-indigo-500"
title={__('Connect Your Bank')}
description={__(
'Select your country and bank to get started.',
)}
/>
<ConnectAccountInline onBack={() => setMode('select')} />
</div>
);
}
// Manual account form
if (mode === 'manual') {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={CreditCard}
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-teal-500"
title={title}
description={description}
/>
<form
onSubmit={handleSubmit}
autoFocus
className="w-full max-w-md space-y-4"
>
<AccountForm
forceAccountType={
isFirstAccount ? 'checking' : undefined
}
onChange={handleFormChange}
/>
{isFirstAccount && (
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
<p className="text-center">
{__(
'Your first account must be a checking account.',
)}
</p>
</div>
)}
<AccountForm onChange={handleFormChange} />
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
@ -300,11 +374,22 @@ export function StepCreateAccount({
text={__('Create Account')}
/>
<Button
type="button"
size="lg"
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
variant="ghost"
onClick={() => setMode('select')}
>
{__('Back')}
</Button>
{!isFirstAccount && onSkip && (
<Button
type="button"
size="lg"
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
variant={'ghost'}
variant="ghost"
disabled={isSubmitting}
onClick={() => onSkip()}
>
@ -312,7 +397,110 @@ export function StepCreateAccount({
</Button>
)}
</form>
)}
</div>
);
}
// Account type selection screen (default)
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={CreditCard}
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-teal-500"
title={title}
description={__('How would you like to set up this account?')}
/>
<div className="w-full max-w-md space-y-4">
<div className="grid grid-cols-2 gap-3">
{/* Manual Account Card */}
<button
type="button"
onClick={() => setSelectedMode('manual')}
className={cn(
'flex flex-col rounded-xl border p-4 text-left transition-all duration-200',
selectedMode === 'manual'
? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30'
: 'border-border bg-card hover:border-muted-foreground/40',
)}
>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/40">
<PencilLine className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<p className="text-sm font-semibold">{__('Manual')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{__(
'Enter transactions yourself or import a CSV file',
)}
</p>
</button>
{/* Connected Account Card */}
<button
type="button"
onClick={() => setSelectedMode('connected')}
className={cn(
'relative flex flex-col rounded-xl border p-4 text-left transition-all duration-200',
selectedMode === 'connected'
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500 dark:bg-blue-950/30'
: 'border-border bg-card hover:border-muted-foreground/40',
)}
>
{subscriptionsEnabled && (
<span className="absolute top-2.5 right-2.5 rounded-full bg-gradient-to-r from-blue-500 to-indigo-500 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white uppercase">
Pro
</span>
)}
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/40">
<Zap className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<p className="text-sm font-semibold">
{__('Connected')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{__(
'Auto-sync transactions directly from your bank',
)}
</p>
{subscriptionsEnabled &&
cheapestMonthlyPrice !== null && (
<p className="mt-2 text-xs font-medium text-blue-600 dark:text-blue-400">
{__('From')} $
{cheapestMonthlyPrice.toFixed(0)}
{__('/month')}
</p>
)}
</button>
</div>
{selectedMode === 'connected' && subscriptionsEnabled && (
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
<p className="text-center text-sm text-blue-700 dark:text-blue-300">
{__(
"Connected accounts are a Pro feature. You'll choose a plan at the end of the onboarding.",
)}
</p>
</div>
)}
<StepButton
className="w-full sm:w-full"
text={__('Continue')}
onClick={() => setMode(selectedMode)}
/>
{!isFirstAccount && onSkip && (
<Button
type="button"
size="lg"
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
variant="ghost"
onClick={() => onSkip()}
>
{__('Ignore')}
</Button>
)}
</div>
</div>
);
}

View File

@ -1,7 +1,6 @@
import { StepHeader } from '@/components/onboarding/step-header';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { formatAccountType } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Check, CheckCircle2, Plus, Wallet } from 'lucide-react';
import { StepButton } from './step-button';
@ -67,9 +66,10 @@ export function StepMoreAccounts({
</div>
<div className="flex-1">
<p className="font-medium">{account.name}</p>
<p className="text-sm text-muted-foreground">
{formatAccountType(account.type)} {' '}
{account.currencyCode}
<p className="flex gap-2 text-sm text-muted-foreground">
<span>{account.bankName ?? 'Bank'}</span>
<span className="opacity-50">&ndash;</span>
<span>{account.currencyCode}</span>
</p>
</div>
<Check className="h-5 w-5 text-emerald-500" />
@ -85,11 +85,12 @@ export function StepMoreAccounts({
</div>
<div className="flex-1">
<p className="font-medium text-muted-foreground">
{account.bank?.name || 'Account'}
{account.name || 'Account'}
</p>
<p className="text-sm text-muted-foreground">
{formatAccountType(account.type)} {' '}
{account.currency_code}
<p className="flex gap-2 text-sm text-muted-foreground">
<span>{account.bank?.name ?? `Bank`}</span>
<span className="opacity-50">&ndash;</span>
<span>{account.currency_code}</span>
</p>
</div>
<Check className="h-5 w-5 text-emerald-500" />

View File

@ -116,7 +116,7 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
</div>
</div>
<StepButton text={__('Continue to Import')} onClick={onContinue} />
<StepButton text={__('Continue')} onClick={onContinue} />
</div>
);
}

View File

@ -0,0 +1,499 @@
import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { EnableBankingInstitution } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { ArrowLeft } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
const COUNTRIES = [
{ code: 'ES', name: 'Spain' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'PT', name: 'Portugal' },
{ code: 'BE', name: 'Belgium' },
{ code: 'AT', name: 'Austria' },
{ code: 'FI', name: 'Finland' },
{ code: 'IE', name: 'Ireland' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LV', name: 'Latvia' },
{ code: 'EE', name: 'Estonia' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'PL', name: 'Poland' },
{ code: 'GB', name: 'United Kingdom' },
] as const;
const INDEXA_CAPITAL_INSTITUTION: EnableBankingInstitution = {
name: 'Indexa Capital',
country: 'ES',
logo: '/images/banks/logos/indexa-capital.jpg',
maximum_consent_validity: null,
};
const BINANCE_INSTITUTION: EnableBankingInstitution = {
name: 'Binance',
country: 'ALL',
logo: 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png',
maximum_consent_validity: null,
};
const BITPANDA_INSTITUTION: EnableBankingInstitution = {
name: 'Bitpanda',
country: 'ALL',
logo: 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png',
maximum_consent_validity: null,
};
type Step = 'country' | 'bank' | 'confirm';
function getCsrfToken(): string {
return decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
}
interface ConnectAccountInlineProps {
onBack: () => void;
}
export function ConnectAccountInline({ onBack }: ConnectAccountInlineProps) {
const [step, setStep] = useState<Step>('country');
const [country, setCountry] = useState<string>('');
const [institutions, setInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [filteredInstitutions, setFilteredInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedBank, setSelectedBank] =
useState<EnableBankingInstitution | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiToken, setApiToken] = useState('');
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
const [bitpandaApiKey, setBitpandaApiKey] = useState('');
const isIndexaCapital = useMemo(
() => selectedBank?.name === 'Indexa Capital',
[selectedBank],
);
const isBinance = useMemo(
() => selectedBank?.name === 'Binance',
[selectedBank],
);
const isBitpanda = useMemo(
() => selectedBank?.name === 'Bitpanda',
[selectedBank],
);
useEffect(() => {
if (searchQuery) {
setFilteredInstitutions(
institutions.filter((i) =>
i.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
);
} else {
setFilteredInstitutions(institutions);
}
}, [searchQuery, institutions]);
const handleBack = useCallback(() => {
if (step === 'country') {
onBack();
} else if (step === 'bank') {
setStep('country');
setInstitutions([]);
setFilteredInstitutions([]);
setSearchQuery('');
setSelectedBank(null);
} else if (step === 'confirm') {
setStep('bank');
}
}, [step, onBack]);
async function fetchInstitutions(countryCode: string) {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/open-banking/institutions?country=${countryCode}`,
{
headers: {
Accept: 'application/json',
'X-XSRF-TOKEN': getCsrfToken(),
},
},
);
if (!response.ok) {
throw new Error('Failed to fetch banks');
}
const data = await response.json();
const extraInstitutions = [
BINANCE_INSTITUTION,
BITPANDA_INSTITUTION,
];
if (countryCode === 'ES') {
extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION);
}
const allInstitutions = [...extraInstitutions, ...data].sort(
(a, b) => a.name.localeCompare(b.name),
);
setInstitutions(allInstitutions);
setFilteredInstitutions(allInstitutions);
setStep('bank');
} catch {
setError(__('Failed to load banks. Please try again.'));
} finally {
setIsLoading(false);
}
}
async function handleAuthorize() {
if (!selectedBank) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const url = isBitpanda
? '/open-banking/bitpanda/connect'
: isBinance
? '/open-banking/binance/connect'
: isIndexaCapital
? '/open-banking/indexa-capital/connect'
: '/open-banking/authorize';
const body = isBitpanda
? { api_key: bitpandaApiKey, country }
: isBinance
? { api_key: apiKey, api_secret: apiSecret, country }
: isIndexaCapital
? { api_token: apiToken }
: {
aspsp_name: selectedBank.name,
country,
logo: selectedBank.logo,
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(
data.message || 'Failed to start authorization',
);
}
const data = await response.json();
window.location.href = data.redirect_url;
} catch (e) {
setError(
e instanceof Error
? e.message
: __('Failed to connect. Please try again.'),
);
setIsSubmitting(false);
}
}
return (
<div className="w-full max-w-md space-y-4">
{error && (
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
{step === 'country' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>{__('Country')}</Label>
<Select value={country} onValueChange={setCountry}>
<SelectTrigger>
<SelectValue
placeholder={__('Select country')}
/>
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{__(c.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Button
className="w-full"
size="lg"
disabled={!country || isLoading}
onClick={() => fetchInstitutions(country)}
>
{isLoading ? __('Loading...') : __('Continue')}
</Button>
<Button
variant={'ghost'}
type="button"
onClick={handleBack}
className="w-full"
>
<ArrowLeft className="h-4 w-4" />
{__('Back')}
</Button>
</div>
</div>
)}
{step === 'bank' && (
<div className="space-y-4">
<Input
placeholder={__('Search banks...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded-lg border p-1">
{filteredInstitutions.map((institution) => (
<button
key={institution.name}
type="button"
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedBank?.name === institution.name
? 'bg-accent'
: ''
}`}
onClick={() => setSelectedBank(institution)}
>
<BankLogo
src={institution.logo}
className="h-6 w-6"
/>
<span>{institution.name}</span>
</button>
))}
{filteredInstitutions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
{__('No banks found.')}
</p>
)}
</div>
<div className="space-y-2">
<Button
className="w-full"
size="lg"
disabled={!selectedBank}
onClick={() => setStep('confirm')}
>
{__('Continue')}
</Button>
<Button
variant={'ghost'}
type="button"
onClick={handleBack}
className="w-full"
>
<ArrowLeft className="h-4 w-4" />
{__('Back')}
</Button>
</div>
</div>
)}
{step === 'confirm' && selectedBank && (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-3">
<BankLogo
src={selectedBank.logo}
className="size-12 p-1"
/>
<div>
<p className="font-medium">
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{isBitpanda
? __(
'Connect your Bitpanda account using your API Key.',
)
: isBinance
? __(
'Connect your Binance account using your API Key and Secret.',
)
: isIndexaCapital
? __(
'Connect your Indexa Capital account using your API token.',
)
: __(
'You will be redirected to authorize access to your account data.',
)}
</p>
</div>
</div>
</div>
{isIndexaCapital && (
<div className="space-y-2">
<Label htmlFor="api-token">{__('API Token')}</Label>
<Input
id="api-token"
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder={__(
'Paste your Indexa Capital API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can generate your API token from your Indexa Capital dashboard under',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">{__('API Key')}</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={__(
'Paste your Binance API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-secret">
{__('API Secret')}
</Label>
<Input
id="api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</div>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="bitpanda-api-key"
type="password"
value={bitpandaApiKey}
onChange={(e) =>
setBitpandaApiKey(e.target.value)
}
placeholder={__('Paste your Bitpanda API Key')}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
<Button
className="w-full"
size="lg"
onClick={handleAuthorize}
disabled={
isSubmitting ||
(isIndexaCapital && !apiToken) ||
(isBinance && (!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey)
}
>
{isSubmitting ? __('Connecting...') : __('Connect')}
</Button>
</div>
)}
</div>
);
}

View File

@ -40,23 +40,27 @@ export interface CreatedAccount {
name: string;
type: string;
currencyCode: string;
bankName?: string;
connected?: boolean;
}
interface UseOnboardingStateOptions {
existingAccountsCount?: number;
initialStep?: OnboardingStep;
}
export function useOnboardingState(options: UseOnboardingStateOptions = {}) {
const { existingAccountsCount = 0 } = options;
const { existingAccountsCount = 0, initialStep } = options;
const primarySteps = PRIMARY_STEPS;
// Determine initial step based on existing state
const initialStep = useMemo((): OnboardingStep => {
return 'welcome';
}, []);
const resolvedInitialStep = useMemo((): OnboardingStep => {
return initialStep ?? 'welcome';
}, [initialStep]);
const [currentStep, setCurrentStep] = useState<OnboardingStep>(initialStep);
const [currentStep, setCurrentStep] =
useState<OnboardingStep>(resolvedInitialStep);
const [createdAccounts, setCreatedAccounts] = useState<CreatedAccount[]>(
[],
);

View File

@ -18,7 +18,7 @@ import OnboardingLayout from '@/layouts/onboarding-layout';
import { type Bank } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Head } from '@inertiajs/react';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
interface ExistingAccount {
id: string;
@ -28,6 +28,7 @@ interface ExistingAccount {
type: string;
currency_code: string;
bank_id: string;
banking_connection_id: string | null;
bank?: {
id: string;
name: string;
@ -40,10 +41,30 @@ interface OnboardingProps {
accounts: ExistingAccount[];
}
const VALID_STEPS: OnboardingStep[] = [
'welcome',
'account-types',
'create-account',
'import-transactions',
'import-balances',
'category-types',
'customize-categories',
'smart-rules',
'more-accounts',
'complete',
];
export default function Onboarding({ banks, accounts }: OnboardingProps) {
const { sync } = useSyncContext();
const hasSyncedRef = useRef(false);
// Read ?step= from URL to allow deep-linking into a specific step
const initialStep = useMemo((): OnboardingStep | undefined => {
const params = new URLSearchParams(window.location.search);
const step = params.get('step') as OnboardingStep | null;
return step && VALID_STEPS.includes(step) ? step : undefined;
}, []);
// Sync banks on mount to ensure IndexedDB has the latest data
useEffect(() => {
if (!hasSyncedRef.current) {
@ -63,14 +84,29 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) {
addCreatedAccount,
} = useOnboardingState({
existingAccountsCount: accounts.length,
initialStep,
});
const handleAccountCreated = async (account: CreatedAccount) => {
addCreatedAccount(account);
// Connected accounts already exist server-side (in existingAccounts prop);
// don't add them to createdAccounts — they'll show via filteredExistingAccounts.
if (!account.connected) {
addCreatedAccount(account);
}
// Sync with backend to get the new account in local DB
await sync();
// Connected accounts (bank-linked) don't need manual import steps
if (account.connected) {
if (createdAccounts.length === 0) {
goToStep('category-types');
} else {
goToStep('more-accounts');
}
return;
}
const needsTransactionImport = [
'checking',
'savings',

View File

@ -73,7 +73,10 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
Route::post('transactions/{transaction}/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'single'])->name('transactions.re-evaluate-rules.single');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'open-banking'])->prefix('open-banking')->group(function () {
// Open-banking routes are accessible without the onboarded/subscribed middleware
// so that users can connect their bank during the onboarding flow.
// The 'open-banking' middleware (EnsureOpenBankingFeature) checks the Pennant flag.
Route::middleware(['auth', 'verified', 'open-banking'])->prefix('open-banking')->group(function () {
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize');
Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback');

View File

@ -244,9 +244,14 @@ it('completes entire onboarding flow with account creation, transaction import,
->click('Create Your First Account')
->wait(1);
// Step 3: Create Account (checking with EUR currency)
// Step 3: Create Account - select Manual mode then fill the form
$page->assertSee('Create an Account')
->assertSee('Your first account must be a')
->assertSee('Manual')
->assertSee('Connected')
->click('Manual')
->wait(1)
->click('Continue')
->wait(1)
->fill('#display_name', 'My Checking Account')
->click('Select bank...')
->wait(1)
@ -254,6 +259,10 @@ it('completes entire onboarding flow with account creation, transaction import,
->wait(2)
->click('Chase Bank')
->wait(1)
->click('Select account type')
->wait(1)
->click('[role="option"]:has-text("Checking")')
->wait(1)
->click('Select currency')
->wait(1)
->click('[role="option"]:has-text("EUR")')
@ -291,7 +300,7 @@ it('completes entire onboarding flow with account creation, transaction import,
// Smart Rules
$page->assertSee('Smart Automation Rules')
->click('Continue to Import')
->click('Continue')
->wait(1);
// More Accounts - verify account is listed, then finish