diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index faaf6fcd..4f1aef45 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -122,6 +122,14 @@ class HandleInertiaRequests extends Middleware 'valid_until' => $connection->valid_until?->toIso8601String(), 'reconnect_url' => route('open-banking.reconnect', $connection), ]) : [], + 'bankingConnections' => fn () => $user ? $user->bankingConnections() + ->get(['id', 'aspsp_name', 'provider', 'status']) + ->map(fn (BankingConnection $connection): array => [ + 'id' => $connection->id, + 'aspsp_name' => $connection->aspsp_name, + 'provider' => $connection->provider->value, + 'status' => $connection->status->value, + ]) : [], 'accounts' => fn () => $user ? $user->accounts() ->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id']) ->orderBy('name') diff --git a/lang/es.json b/lang/es.json index 2cb11591..b8536fdf 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1834,6 +1834,7 @@ "You agree to indemnify, defend, and hold\\n harmless Whisper Money and its officers,\\n directors, employees, and agents from any\\n claims, liabilities, damages, losses, and\\n expenses, including reasonable legal fees,\\n arising out of or related to your use of the\\n service, violation of these Terms, or violation\\n of any rights of another party.": "Aceptas indemnizar, defender y eximir de responsabilidad a Whisper Money y sus directivos, directores, empleados y agentes de cualquier reclamación, responsabilidad, daño, pérdida y gasto, incluidos los honorarios legales razonables, derivados de o relacionados con tu uso del servicio, la violación de estos Términos o la violación de los derechos de otra parte.", "You agree to pay all applicable fees as described at the time of purchase": "Aceptas pagar todas las tarifas aplicables según se describan en el momento de la compra", "You agree to pay all applicable fees as\\n described at the time of purchase": "Aceptas pagar todas las tarifas aplicables tal como se describen en el momento de la compra", + "You already have a connection with this bank. Reconnect it.": "Ya tienes una conexión con este banco, reconéctala.", "You already have accounts set up. Let's continue with the onboarding.": "Ya tienes cuentas configuradas. Continuemos con la configuración.", "You are always the owner of your data": "Siempre eres el dueño de tus datos", "You are being redirected in 3 seconds.": "Serás redirigido en 3 segundos.", diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index 5c9b5246..92d245d3 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -16,6 +16,7 @@ import { import { getCsrfToken } from '@/lib/csrf'; import { SharedData } from '@/types'; import { Account } from '@/types/account'; +import type { BankingConnection } from '@/types/banking'; import { __ } from '@/utils/i18n'; import { router, usePage } from '@inertiajs/react'; import { Link2, PenLine } from 'lucide-react'; @@ -35,12 +36,17 @@ export function CreateAccountDialog({ auth, subscriptionsEnabled, accounts: sharedAccounts, + bankingConnections: sharedConnections, } = usePage().props; const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan; const sharedAccountsList = useMemo( () => (sharedAccounts as Account[]) || [], [sharedAccounts], ); + const connections = useMemo( + () => (sharedConnections as BankingConnection[]) || [], + [sharedConnections], + ); const availableLoanAccounts = useMemo( () => sharedAccountsList.filter((a) => a.type === 'loan'), [sharedAccountsList], @@ -333,6 +339,7 @@ export function CreateAccountDialog({ = {}, +): BankingConnection { + return { + id: crypto.randomUUID(), + provider: 'enablebanking', + aspsp_name: 'Bankinter', + aspsp_country: 'ES', + status: 'active', + valid_until: null, + last_synced_at: null, + error_message: null, + accounts_count: 1, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('alreadyConnectedBankNames', () => { + it('includes active, error and expired EnableBanking banks', () => { + const names = alreadyConnectedBankNames([ + connection({ aspsp_name: 'Bankinter', status: 'active' }), + connection({ aspsp_name: 'BBVA', status: 'error' }), + connection({ aspsp_name: 'ING', status: 'expired' }), + ]); + + expect(names).toEqual(new Set(['Bankinter', 'BBVA', 'ING'])); + }); + + it('excludes pending connections so a stale attempt does not block re-adding', () => { + const names = alreadyConnectedBankNames([ + connection({ aspsp_name: 'Bankinter', status: 'pending' }), + ]); + + expect(names.has('Bankinter')).toBe(false); + }); + + it('ignores non-EnableBanking providers', () => { + const names = alreadyConnectedBankNames([ + connection({ provider: 'binance', aspsp_name: 'Binance' }), + ]); + + expect(names.has('Binance')).toBe(false); + }); +}); diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index 82ad32f5..67e27d27 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -18,6 +18,11 @@ import { SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { getCsrfToken } from '@/lib/csrf'; import type { BankingConnection, @@ -82,6 +87,25 @@ const WISE_INSTITUTION: EnableBankingInstitution = { maximum_consent_validity: null, }; +/** + * Names of EnableBanking ASPSPs the user already has a connection to. + * + * Pending connections are excluded: they are throwaway, mid-flow attempts, so a + * stale one must not block re-adding the bank. Any other status (active, error, + * expired, …) counts as already connected and should be re-used via reconnect. + */ +export function alreadyConnectedBankNames( + connections: BankingConnection[], +): Set { + return new Set( + connections + .filter( + (c) => c.provider === 'enablebanking' && c.status !== 'pending', + ) + .map((c) => c.aspsp_name), + ); +} + interface ConnectAccountDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -140,6 +164,11 @@ export function ConnectAccountDialog({ const isWise = useMemo(() => selectedBank?.name === 'Wise', [selectedBank]); + const connectedBankNames = useMemo( + () => alreadyConnectedBankNames(connections), + [connections], + ); + const resetState = useCallback(() => { setStep('country'); setCountry(''); @@ -198,11 +227,6 @@ export function ConnectAccountDialog({ const data = await response.json(); - const connectedEnableBankingNames = new Set( - connections - .filter((c) => c.provider === 'enablebanking') - .map((c) => c.aspsp_name), - ); const hasProvider = (provider: string) => connections.some((c) => c.provider === provider); @@ -230,7 +254,7 @@ export function ConnectAccountDialog({ if (institution.name === 'Indexa Capital') { return !hasProvider('indexacapital'); } - return !connectedEnableBankingNames.has(institution.name); + return true; }) .sort((a, b) => a.name.localeCompare(b.name)); @@ -419,27 +443,58 @@ export function ConnectAccountDialog({ />
- {filteredInstitutions.map((institution) => ( - - ))} + {filteredInstitutions.map((institution) => { + const isConnected = connectedBankNames.has( + institution.name, + ); + + const item = ( + + ); + + if (!isConnected) { + return item; + } + + return ( + + + {item} + + + {__( + 'You already have a connection with this bank. Reconnect it.', + )} + + + ); + })} {filteredInstitutions.length === 0 && (

{__('No banks found.')} diff --git a/tests/Feature/InertiaSharedDataTest.php b/tests/Feature/InertiaSharedDataTest.php index a9b54856..120aab4f 100644 --- a/tests/Feature/InertiaSharedDataTest.php +++ b/tests/Feature/InertiaSharedDataTest.php @@ -109,6 +109,25 @@ test('authenticated users receive expired banking connection reconnect links', f ); }); +test('authenticated users receive their banking connections in shared props', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Bankinter', + 'status' => BankingConnectionStatus::Active, + ]); + + $response = actingAs($user)->withoutVite()->get(route('dashboard')); + + $response->assertInertia(fn (Assert $page) => $page + ->has('bankingConnections', 1) + ->where('bankingConnections.0.id', $connection->id) + ->where('bankingConnections.0.aspsp_name', 'Bankinter') + ->where('bankingConnections.0.provider', $connection->provider->value) + ->where('bankingConnections.0.status', 'active') + ); +}); + test('shared currency options split profile and account currencies', function () { $response = $this->withoutVite()->get(route('home'));