From 993c91a6b6f1f65ee200c96764ecb8c0ad2fbdc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 3 Mar 2026 09:49:42 +0000 Subject: [PATCH] feat(onboarding): inline connected account flow with auto-account creation and step deep-linking (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🚪 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. --- app/Http/Controllers/OnboardingController.php | 2 +- .../OpenBanking/AccountMappingController.php | 72 ++- .../OpenBanking/AuthorizationController.php | 72 ++- .../OpenBanking/BinanceController.php | 5 +- .../OpenBanking/BitpandaController.php | 5 +- .../OpenBanking/IndexaCapitalController.php | 5 +- .../ActivateDevelopmentFeatures.php | 26 + .../Middleware/EnsureOnboardingComplete.php | 7 + bootstrap/app.php | 2 + lang/es.json | 7 + .../onboarding/step-create-account.tsx | 278 ++++++++-- .../onboarding/step-more-accounts.tsx | 17 +- .../onboarding/step-smart-rules.tsx | 2 +- .../open-banking/connect-account-inline.tsx | 499 ++++++++++++++++++ resources/js/hooks/use-onboarding-state.ts | 14 +- resources/js/pages/onboarding/index.tsx | 40 +- routes/web.php | 5 +- tests/Browser/OnboardingFlowTest.php | 15 +- 18 files changed, 993 insertions(+), 80 deletions(-) create mode 100644 app/Http/Middleware/ActivateDevelopmentFeatures.php create mode 100644 resources/js/components/open-banking/connect-account-inline.tsx diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 64ff0566..ab975096 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -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, diff --git a/app/Http/Controllers/OpenBanking/AccountMappingController.php b/app/Http/Controllers/OpenBanking/AccountMappingController.php index a45c3ea7..0907e800 100644 --- a/app/Http/Controllers/OpenBanking/AccountMappingController.php +++ b/app/Http/Controllers/OpenBanking/AccountMappingController.php @@ -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, + ]); + } } diff --git a/app/Http/Controllers/OpenBanking/AuthorizationController.php b/app/Http/Controllers/OpenBanking/AuthorizationController.php index e018aa8c..663b5ac5 100644 --- a/app/Http/Controllers/OpenBanking/AuthorizationController.php +++ b/app/Http/Controllers/OpenBanking/AuthorizationController.php @@ -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. */ diff --git a/app/Http/Controllers/OpenBanking/BinanceController.php b/app/Http/Controllers/OpenBanking/BinanceController.php index edec476a..e1c29161 100644 --- a/app/Http/Controllers/OpenBanking/BinanceController.php +++ b/app/Http/Controllers/OpenBanking/BinanceController.php @@ -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, ]); } diff --git a/app/Http/Controllers/OpenBanking/BitpandaController.php b/app/Http/Controllers/OpenBanking/BitpandaController.php index 51ad640d..037d8ad8 100644 --- a/app/Http/Controllers/OpenBanking/BitpandaController.php +++ b/app/Http/Controllers/OpenBanking/BitpandaController.php @@ -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, ]); } diff --git a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php index dbead00f..356f8aa1 100644 --- a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php +++ b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php @@ -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, ]); } diff --git a/app/Http/Middleware/ActivateDevelopmentFeatures.php b/app/Http/Middleware/ActivateDevelopmentFeatures.php new file mode 100644 index 00000000..bbb76957 --- /dev/null +++ b/app/Http/Middleware/ActivateDevelopmentFeatures.php @@ -0,0 +1,26 @@ +isLocal() && $request->user()) { + Feature::for($request->user())->activate('open-banking'); + Feature::for($request->user())->activate('account-mapping'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureOnboardingComplete.php b/app/Http/Middleware/EnsureOnboardingComplete.php index 2d115c14..43c71de2 100644 --- a/app/Http/Middleware/EnsureOnboardingComplete.php +++ b/app/Http/Middleware/EnsureOnboardingComplete.php @@ -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'); } diff --git a/bootstrap/app.php b/bootstrap/app.php index 756e2e08..632b6d1b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ alias([ diff --git a/lang/es.json b/lang/es.json index 8c04d76b..643a5e4e 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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:", diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx index 8e1315a2..317ed940 100644 --- a/resources/js/components/onboarding/step-create-account.tsx +++ b/resources/js/components/onboarding/step-create-account.tsx @@ -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().props; + const [mode, setMode] = useState('select'); + const [selectedMode, setSelectedMode] = useState<'manual' | 'connected'>( + 'manual', + ); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const formDataRef = useRef({ 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 { 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 ( -
- + // Show existing accounts view + if (hasExistingAccounts) { + return ( +
+ - {hasExistingAccounts ? (
{existingAccounts.map((account) => ( @@ -234,13 +269,31 @@ export function StepCreateAccount({

- {account.bank?.name || 'Account'} + {account.name || 'Account'}

-

- {formatAccountType( - account.type as AccountType, - )}{' '} - • {account.currency_code} +

+ + {account.bank?.name ?? `Bank`} + + + – + + + {account.type + .split('_') + .map( + (w) => + w + .charAt(0) + .toUpperCase() + + w.slice(1), + ) + .join(' ')} + + + – + + {account.currency_code}

@@ -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, }) } />
- ) : ( +
+ ); + } + + // Connected account inline flow + if (mode === 'connected') { + return ( +
+ + setMode('select')} /> +
+ ); + } + + // Manual account form + if (mode === 'manual') { + return ( +
+ +
- - - {isFirstAccount && ( -
-

- {__( - 'Your first account must be a checking account.', - )} -

-
- )} + {error && (
@@ -300,11 +374,22 @@ export function StepCreateAccount({ text={__('Create Account')} /> + + {!isFirstAccount && onSkip && ( )} - )} +
+ ); + } + + // Account type selection screen (default) + return ( +
+ + +
+
+ {/* Manual Account Card */} + + + {/* Connected Account Card */} + +
+ + {selectedMode === 'connected' && subscriptionsEnabled && ( +
+

+ {__( + "Connected accounts are a Pro feature. You'll choose a plan at the end of the onboarding.", + )} +

+
+ )} + + setMode(selectedMode)} + /> + + {!isFirstAccount && onSkip && ( + + )} +
); } diff --git a/resources/js/components/onboarding/step-more-accounts.tsx b/resources/js/components/onboarding/step-more-accounts.tsx index 779733f9..c2a12a95 100644 --- a/resources/js/components/onboarding/step-more-accounts.tsx +++ b/resources/js/components/onboarding/step-more-accounts.tsx @@ -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({

{account.name}

-

- {formatAccountType(account.type)} •{' '} - {account.currencyCode} +

+ {account.bankName ?? 'Bank'} + + {account.currencyCode}

@@ -85,11 +85,12 @@ export function StepMoreAccounts({

- {account.bank?.name || 'Account'} + {account.name || 'Account'}

-

- {formatAccountType(account.type)} •{' '} - {account.currency_code} +

+ {account.bank?.name ?? `Bank`} + + {account.currency_code}

diff --git a/resources/js/components/onboarding/step-smart-rules.tsx b/resources/js/components/onboarding/step-smart-rules.tsx index f8a073a8..7ce4fc94 100644 --- a/resources/js/components/onboarding/step-smart-rules.tsx +++ b/resources/js/components/onboarding/step-smart-rules.tsx @@ -116,7 +116,7 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) { - + ); } diff --git a/resources/js/components/open-banking/connect-account-inline.tsx b/resources/js/components/open-banking/connect-account-inline.tsx new file mode 100644 index 00000000..babdaa05 --- /dev/null +++ b/resources/js/components/open-banking/connect-account-inline.tsx @@ -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('country'); + const [country, setCountry] = useState(''); + const [institutions, setInstitutions] = useState< + EnableBankingInstitution[] + >([]); + const [filteredInstitutions, setFilteredInstitutions] = useState< + EnableBankingInstitution[] + >([]); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedBank, setSelectedBank] = + useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
+ {error && ( +

+ {error} +

+ )} + + {step === 'country' && ( +
+
+ + +
+ +
+ + + +
+
+ )} + + {step === 'bank' && ( +
+ setSearchQuery(e.target.value)} + autoFocus + /> + +
+ {filteredInstitutions.map((institution) => ( + + ))} + {filteredInstitutions.length === 0 && ( +

+ {__('No banks found.')} +

+ )} +
+ +
+ + +
+
+ )} + + {step === 'confirm' && selectedBank && ( +
+
+
+ +
+

+ {selectedBank.name} +

+

+ {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.', + )} +

+
+
+
+ + {isIndexaCapital && ( +
+ + setApiToken(e.target.value)} + placeholder={__( + 'Paste your Indexa Capital API token', + )} + /> +

+ {__( + 'You can generate your API token from your Indexa Capital dashboard under', + )}{' '} + + {__('Settings > Applications')} + + . +

+
+ )} + + {isBinance && ( +
+
+ + setApiKey(e.target.value)} + placeholder={__( + 'Paste your Binance API Key', + )} + /> +
+
+ + + setApiSecret(e.target.value) + } + placeholder={__( + 'Paste your Binance API Secret', + )} + /> +
+

+ {__( + 'You can create API keys from your Binance account under', + )}{' '} + + {__('API Management')} + + . +

+
+ )} + + {isBitpanda && ( +
+ + + setBitpandaApiKey(e.target.value) + } + placeholder={__('Paste your Bitpanda API Key')} + /> +

+ {__( + 'You can create API keys from your Bitpanda account under', + )}{' '} + + {__('API Key Management')} + + . +

+
+ )} + + +
+ )} +
+ ); +} diff --git a/resources/js/hooks/use-onboarding-state.ts b/resources/js/hooks/use-onboarding-state.ts index a85260a0..40058323 100644 --- a/resources/js/hooks/use-onboarding-state.ts +++ b/resources/js/hooks/use-onboarding-state.ts @@ -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(initialStep); + const [currentStep, setCurrentStep] = + useState(resolvedInitialStep); const [createdAccounts, setCreatedAccounts] = useState( [], ); diff --git a/resources/js/pages/onboarding/index.tsx b/resources/js/pages/onboarding/index.tsx index f8fad619..a714813c 100644 --- a/resources/js/pages/onboarding/index.tsx +++ b/resources/js/pages/onboarding/index.tsx @@ -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', diff --git a/routes/web.php b/routes/web.php index 14bbc09e..3778d2ec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 4f7d7ec9..3e49b0bf 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -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