From db7b6e4da7d6513c9fb088f4460256550cce246f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 12 Feb 2026 09:09:28 +0100 Subject: [PATCH] feat: Integrate EnableBanking as open banking provider (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds **EnableBanking** as the first open banking provider, allowing users to connect real bank accounts and automatically sync transactions and balances - Uses a **`BankingProviderInterface`** contract so future providers (Plaid, GoCardless, etc.) can be added by implementing the same interface - Feature-flagged behind the **`open-banking`** Pennant flag (default: off) - Connected accounts are **unencrypted** and transactions have `source = 'enablebanking'` ### What's included **Backend:** - `BankingProviderInterface` contract + `EnableBankingProvider` implementation (JWT RS256 auth) - `BankingConnection` model with full lifecycle (pending → awaiting_mapping → active → expired/revoked/error) - `TransactionSyncService` — pagination, deduplication by `external_transaction_id`, amount/date mapping - `BalanceSyncService` — preferred balance type selection (CLBD → ITAV fallback) - Authorization flow: start auth → bank redirect → callback → session creation → account mapping → sync - `SyncBankingConnectionJob` (unique per connection, 3 retries) + scheduled every 6 hours - `banking:sync` artisan command - 5 migrations: `banking_connections` table, account fields, transaction `external_transaction_id`, `pending_accounts_data`, `linked_at` **Frontend:** - Manual vs Connected account choice in the create account dialog - Multi-step bank connection dialog (country → bank selection → confirmation → redirect) - Account mapping page — map discovered bank accounts to existing accounts, create new ones, or skip - Settings/Connections page with status badges, sync/disconnect actions - "Connected" badge on linked accounts in settings **Tests:** - 49 tests covering feature flags, controllers, account mapping, transaction sync, balance sync, deduplication, and pagination ### Feature Flags This PR introduces **two Pennant feature flags**: 1. **`open-banking`** — Gates the entire open banking feature (institutions endpoint, authorization flow, connections page). When disabled, all open banking routes return 404. 2. **`account-mapping`** — Controls whether users see an intermediate account mapping step after connecting a bank. When **enabled**, users are redirected to a mapping page where they can choose to create new accounts, link to existing ones, or skip each discovered bank account. When **disabled**, all discovered accounts are automatically created (original behavior). Linked accounts only sync transactions from their last transaction date and only update the current balance from the provider (no historical balance calculation or daily balance tracking). Enable per-user: ```bash php artisan feature:enable open-banking user@example.com php artisan feature:enable account-mapping user@example.com ``` Enable for all users: ```bash php artisan feature:enable open-banking all php artisan feature:enable account-mapping all ``` ## Test plan - [x] Enable feature flag: `php artisan feature:enable open-banking` - [x] Verify "Connected" option appears in create account dialog - [x] Start authorization flow and verify redirect to bank - [x] With `account-mapping` **disabled**: verify callback creates accounts directly and dispatches sync - [x] With `account-mapping` **enabled**: verify callback redirects to mapping page - [x] Test mapping page: create new, link to existing, and skip actions - [x] Verify linked accounts sync only from last transaction date and only update current balance - [x] Verify connections page shows "Setup Required" badge for awaiting_mapping status - [x] Run `php artisan banking:sync` and verify transactions sync - [x] Verify connections page shows status, sync, and disconnect actions - [x] Run full test suite: `php artisan test --compact` --- .env.example | 5 + .gitignore | 1 + .../Commands/Concerns/ResolvesFeatures.php | 2 +- .../Commands/SyncBankingConnections.php | 22 ++ app/Contracts/BankingProviderInterface.php | 53 +++ app/Enums/BankingConnectionStatus.php | 13 + app/Enums/TransactionSource.php | 1 + .../OpenBanking/AccountMappingController.php | 111 ++++++ .../OpenBanking/AuthorizationController.php | 169 +++++++++ .../OpenBanking/ConnectionController.php | 93 +++++ .../OpenBanking/InstitutionController.php | 18 + .../Settings/AccountController.php | 2 +- .../Middleware/EnsureOpenBankingFeature.php | 29 ++ app/Http/Middleware/HandleInertiaRequests.php | 2 + .../OpenBanking/DestroyConnectionRequest.php | 24 ++ .../OpenBanking/ListInstitutionsRequest.php | 24 ++ .../OpenBanking/MapAccountsRequest.php | 32 ++ .../OpenBanking/StartAuthorizationRequest.php | 26 ++ app/Jobs/SyncAllBankingConnectionsJob.php | 29 ++ app/Jobs/SyncBankingConnectionJob.php | 97 ++++++ app/Models/Account.php | 19 + app/Models/BankingConnection.php | 68 ++++ app/Models/Transaction.php | 3 + app/Models/User.php | 5 + app/Providers/AppServiceProvider.php | 11 + app/Services/Banking/BalanceSyncService.php | 123 +++++++ .../Banking/EnableBankingProvider.php | 157 +++++++++ .../Banking/TransactionSyncService.php | 185 ++++++++++ bootstrap/app.php | 2 + composer.json | 1 + composer.lock | 67 +++- config/services.php | 6 + database/factories/AccountFactory.php | 18 + .../factories/BankingConnectionFactory.php | 82 +++++ database/factories/TransactionFactory.php | 10 + ...44915_create_banking_connections_table.php | 38 ++ ...ng_connection_fields_to_accounts_table.php | 32 ++ ...l_transaction_id_to_transactions_table.php | 31 ++ ...spsp_logo_to_banking_connections_table.php | 28 ++ ...639_add_raw_data_to_transactions_table.php | 28 ++ ...unts_data_to_banking_connections_table.php | 25 ++ ...113119_add_linked_at_to_accounts_table.php | 25 ++ .../components/accounts/account-list-card.tsx | 22 +- .../js/components/accounts/bank-combobox.tsx | 31 +- .../accounts/create-account-dialog.tsx | 158 +++++++-- .../import-balance-step-account.tsx | 19 +- .../create-automation-rule-dialog.tsx | 5 +- resources/js/components/bank-logo.tsx | 60 ++++ .../categories/create-category-dialog.tsx | 3 +- .../dashboard/account-balance-card.tsx | 13 +- .../components/labels/create-label-dialog.tsx | 3 +- .../open-banking/connect-account-dialog.tsx | 324 ++++++++++++++++++ .../open-banking/connection-status-badge.tsx | 40 +++ .../open-banking/disconnect-dialog.tsx | 170 +++++++++ .../transactions/edit-transaction-dialog.tsx | 2 +- .../transactions/import-step-account.tsx | 19 +- .../transactions/transaction-columns.tsx | 13 +- .../transactions/transaction-list.tsx | 3 +- resources/js/components/ui/create-button.tsx | 18 + resources/js/layouts/settings/layout.tsx | 21 +- resources/js/pages/Accounts/Show.tsx | 20 +- .../js/pages/open-banking/map-accounts.tsx | 261 ++++++++++++++ resources/js/pages/settings/accounts.tsx | 36 +- resources/js/pages/settings/connections.tsx | 244 +++++++++++++ resources/js/types/account.ts | 3 + resources/js/types/banking.ts | 38 ++ resources/js/types/index.d.ts | 2 + routes/console.php | 1 + routes/settings.php | 8 + routes/web.php | 11 + .../OpenBanking/AccountMappingTest.php | 312 +++++++++++++++++ .../AuthorizationControllerTest.php | 181 ++++++++++ .../OpenBanking/BalanceSyncServiceTest.php | 183 ++++++++++ .../OpenBanking/ConnectionControllerTest.php | 192 +++++++++++ .../OpenBanking/InstitutionControllerTest.php | 51 +++ .../OpenBankingFeatureFlagTest.php | 82 +++++ .../SyncBankingConnectionJobTest.php | 120 +++++++ .../TransactionSyncServiceTest.php | 295 ++++++++++++++++ 78 files changed, 4534 insertions(+), 147 deletions(-) create mode 100644 app/Console/Commands/SyncBankingConnections.php create mode 100644 app/Contracts/BankingProviderInterface.php create mode 100644 app/Enums/BankingConnectionStatus.php create mode 100644 app/Http/Controllers/OpenBanking/AccountMappingController.php create mode 100644 app/Http/Controllers/OpenBanking/AuthorizationController.php create mode 100644 app/Http/Controllers/OpenBanking/ConnectionController.php create mode 100644 app/Http/Controllers/OpenBanking/InstitutionController.php create mode 100644 app/Http/Middleware/EnsureOpenBankingFeature.php create mode 100644 app/Http/Requests/OpenBanking/DestroyConnectionRequest.php create mode 100644 app/Http/Requests/OpenBanking/ListInstitutionsRequest.php create mode 100644 app/Http/Requests/OpenBanking/MapAccountsRequest.php create mode 100644 app/Http/Requests/OpenBanking/StartAuthorizationRequest.php create mode 100644 app/Jobs/SyncAllBankingConnectionsJob.php create mode 100644 app/Jobs/SyncBankingConnectionJob.php create mode 100644 app/Models/BankingConnection.php create mode 100644 app/Services/Banking/BalanceSyncService.php create mode 100644 app/Services/Banking/EnableBankingProvider.php create mode 100644 app/Services/Banking/TransactionSyncService.php create mode 100644 database/factories/BankingConnectionFactory.php create mode 100644 database/migrations/2026_02_09_144915_create_banking_connections_table.php create mode 100644 database/migrations/2026_02_09_144959_add_banking_connection_fields_to_accounts_table.php create mode 100644 database/migrations/2026_02_09_144959_add_external_transaction_id_to_transactions_table.php create mode 100644 database/migrations/2026_02_10_085759_add_aspsp_logo_to_banking_connections_table.php create mode 100644 database/migrations/2026_02_10_095639_add_raw_data_to_transactions_table.php create mode 100644 database/migrations/2026_02_11_113115_add_pending_accounts_data_to_banking_connections_table.php create mode 100644 database/migrations/2026_02_11_113119_add_linked_at_to_accounts_table.php create mode 100644 resources/js/components/bank-logo.tsx create mode 100644 resources/js/components/open-banking/connect-account-dialog.tsx create mode 100644 resources/js/components/open-banking/connection-status-badge.tsx create mode 100644 resources/js/components/open-banking/disconnect-dialog.tsx create mode 100644 resources/js/components/ui/create-button.tsx create mode 100644 resources/js/pages/open-banking/map-accounts.tsx create mode 100644 resources/js/pages/settings/connections.tsx create mode 100644 resources/js/types/banking.ts create mode 100644 tests/Feature/OpenBanking/AccountMappingTest.php create mode 100644 tests/Feature/OpenBanking/AuthorizationControllerTest.php create mode 100644 tests/Feature/OpenBanking/BalanceSyncServiceTest.php create mode 100644 tests/Feature/OpenBanking/ConnectionControllerTest.php create mode 100644 tests/Feature/OpenBanking/InstitutionControllerTest.php create mode 100644 tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php create mode 100644 tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php create mode 100644 tests/Feature/OpenBanking/TransactionSyncServiceTest.php diff --git a/.env.example b/.env.example index 5a8197a6..063c2384 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,11 @@ FORWARD_MAILHOG_DASHBOARD_PORT=8025 # Dev Mode (set to false for full Docker deployment via setup.sh) DEV_MODE=true +# EnableBanking (Open Banking) +ENABLEBANKING_APP_ID= +ENABLEBANKING_PRIVATE_KEY_PATH= +ENABLEBANKING_REDIRECT_URL="${APP_URL}/open-banking/callback" + # Demo Account Configuration DEMO_EMAIL=demo@whisper.money DEMO_PASSWORD=demo diff --git a/.gitignore b/.gitignore index b9a61eb0..899fe212 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /resources/js/routes /resources/js/wayfinder /storage/*.key +/storage/keys/ /storage/pail /vendor .DS_Store diff --git a/app/Console/Commands/Concerns/ResolvesFeatures.php b/app/Console/Commands/Concerns/ResolvesFeatures.php index 395a0f0f..e908d0d0 100644 --- a/app/Console/Commands/Concerns/ResolvesFeatures.php +++ b/app/Console/Commands/Concerns/ResolvesFeatures.php @@ -49,6 +49,6 @@ trait ResolvesFeatures private function getStringBasedFeatures(): array { - return ['budgets', 'plaintext-transactions']; + return ['budgets', 'plaintext-transactions', 'open-banking', 'account-mapping']; } } diff --git a/app/Console/Commands/SyncBankingConnections.php b/app/Console/Commands/SyncBankingConnections.php new file mode 100644 index 00000000..bec292ad --- /dev/null +++ b/app/Console/Commands/SyncBankingConnections.php @@ -0,0 +1,22 @@ +info('Banking sync jobs dispatched.'); + + return Command::SUCCESS; + } +} diff --git a/app/Contracts/BankingProviderInterface.php b/app/Contracts/BankingProviderInterface.php new file mode 100644 index 00000000..a17b0f2b --- /dev/null +++ b/app/Contracts/BankingProviderInterface.php @@ -0,0 +1,53 @@ + + */ + public function getInstitutions(string $countryCode): array; + + /** + * Start a user authorization flow. + * + * @return array{url: string, authorization_id: string} + */ + public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl): array; + + /** + * Exchange a callback code for a session with accounts. + * + * @return array{session_id: string, accounts: array, aspsp: array, access: array} + */ + public function createSession(string $code): array; + + /** + * Fetch transactions for an account. + * + * @return array{transactions: array, continuation_key: string|null} + */ + public function getTransactions(string $accountId, string $dateFrom, string $dateTo, ?string $continuationKey = null, ?string $strategy = null): array; + + /** + * Fetch balances for an account. + * + * @return array{balances: array} + */ + public function getBalances(string $accountId): array; + + /** + * Get session details and status. + * + * @return array{status: string, access: array, accounts: array} + */ + public function getSession(string $sessionId): array; + + /** + * Revoke a session and its consent. + */ + public function revokeSession(string $sessionId): void; +} diff --git a/app/Enums/BankingConnectionStatus.php b/app/Enums/BankingConnectionStatus.php new file mode 100644 index 00000000..5a038012 --- /dev/null +++ b/app/Enums/BankingConnectionStatus.php @@ -0,0 +1,13 @@ +user_id !== auth()->id()) { + abort(403); + } + + if (! $connection->hasPendingAccounts()) { + return redirect()->route('settings.connections.index'); + } + + $existingAccounts = auth()->user() + ->accounts() + ->whereNull('banking_connection_id') + ->with('bank') + ->get(); + + return Inertia::render('open-banking/map-accounts', [ + 'connection' => $connection, + 'bankAccounts' => $connection->pending_accounts_data, + 'existingAccounts' => $existingAccounts, + ]); + } + + public function store(MapAccountsRequest $request, BankingConnection $connection): RedirectResponse + { + if ($connection->user_id !== auth()->id()) { + abort(403); + } + + $user = auth()->user(); + $mappings = $request->validated()['mappings']; + + $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]); + } + + $pendingAccounts = collect($connection->pending_accounts_data) + ->keyBy('uid'); + + foreach ($mappings as $mapping) { + $uid = $mapping['bank_account_uid']; + $action = $mapping['action']; + $accountData = $pendingAccounts->get($uid); + + if (! $accountData) { + continue; + } + + if ($action === 'create') { + $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, + ]); + } elseif ($action === 'link') { + $existingAccount = $user->accounts()->find($mapping['existing_account_id']); + + if ($existingAccount) { + $existingAccount->update([ + 'banking_connection_id' => $connection->id, + 'external_account_id' => $uid, + 'bank_id' => $bank->id, + 'linked_at' => now(), + ]); + } + } + } + + $connection->update([ + 'status' => BankingConnectionStatus::Active, + 'pending_accounts_data' => null, + ]); + + SyncBankingConnectionJob::dispatch($connection); + + return redirect()->route('settings.connections.index') + ->with('success', 'Bank account connected successfully.'); + } +} diff --git a/app/Http/Controllers/OpenBanking/AuthorizationController.php b/app/Http/Controllers/OpenBanking/AuthorizationController.php new file mode 100644 index 00000000..47afb3fe --- /dev/null +++ b/app/Http/Controllers/OpenBanking/AuthorizationController.php @@ -0,0 +1,169 @@ +user(); + $validated = $request->validated(); + + $redirectUrl = config('services.enablebanking.redirect_url'); + + $result = $provider->startAuthorization( + $validated['aspsp_name'], + $validated['country'], + $redirectUrl, + ); + + $connection = $user->bankingConnections()->create([ + 'provider' => 'enablebanking', + 'authorization_id' => $result['authorization_id'], + 'aspsp_name' => $validated['aspsp_name'], + 'aspsp_country' => $validated['country'], + 'aspsp_logo' => $validated['logo'] ?? null, + 'status' => BankingConnectionStatus::Pending, + ]); + + return response()->json([ + 'redirect_url' => $result['url'], + 'connection_id' => $connection->id, + ]); + } + + /** + * Handle the callback from bank authorization. + */ + public function callback(Request $request, BankingProviderInterface $provider): RedirectResponse + { + if ($request->has('error')) { + Log::warning('EnableBanking authorization error', [ + 'error' => $request->query('error'), + 'description' => $request->query('error_description'), + ]); + + return redirect()->route('settings.connections.index') + ->with('error', $request->query('error_description', 'Authorization was denied or cancelled.')); + } + + $code = $request->query('code'); + + if (! $code) { + return redirect()->route('settings.connections.index') + ->with('error', 'No authorization code received.'); + } + + try { + $sessionData = $provider->createSession($code); + } catch (\Throwable $e) { + Log::error('EnableBanking session creation failed', ['error' => $e->getMessage()]); + + return redirect()->route('settings.connections.index') + ->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') + ->with('error', 'No pending connection found.'); + } + + if (Feature::for($user)->active('account-mapping')) { + $connection->update([ + 'session_id' => $sessionData['session_id'], + 'status' => BankingConnectionStatus::AwaitingMapping, + 'valid_until' => $sessionData['access']['valid_until'] ?? null, + 'pending_accounts_data' => $sessionData['accounts'] ?? [], + ]); + + return redirect()->route('open-banking.map-accounts', $connection); + } + + $connection->update([ + 'session_id' => $sessionData['session_id'], + 'status' => BankingConnectionStatus::Active, + 'valid_until' => $sessionData['access']['valid_until'] ?? null, + ]); + + $this->createAccountsFromSession($user, $connection, $sessionData); + + SyncBankingConnectionJob::dispatch($connection); + + return redirect()->route('settings.connections.index') + ->with('success', 'Bank account connected successfully.'); + } + + /** + * Create local accounts from the EnableBanking session data. + */ + private function createAccountsFromSession($user, BankingConnection $connection, array $sessionData): 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]); + } + + $accounts = $sessionData['accounts'] ?? []; + + foreach ($accounts as $accountData) { + $uid = $accountData['uid'] ?? null; + + if (! $uid) { + continue; + } + + $existingAccount = $user->accounts() + ->where('banking_connection_id', $connection->id) + ->where('external_account_id', $uid) + ->first(); + + if ($existingAccount) { + 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, + ]); + } + } +} diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php new file mode 100644 index 00000000..01a17ca6 --- /dev/null +++ b/app/Http/Controllers/OpenBanking/ConnectionController.php @@ -0,0 +1,93 @@ +user() + ->bankingConnections() + ->withCount('accounts') + ->orderByDesc('created_at') + ->get() + ->each(function ($connection) { + $connection->has_pending_accounts = $connection->hasPendingAccounts(); + }); + + return Inertia::render('settings/connections', [ + 'connections' => $connections, + ]); + } + + /** + * Manually trigger a sync for a connection. + */ + public function sync(BankingConnection $connection): RedirectResponse + { + if ($connection->user_id !== auth()->id()) { + abort(403); + } + + if (! $connection->isActive()) { + return back()->with('error', 'Connection is not active.'); + } + + SyncBankingConnectionJob::dispatch($connection); + + return back()->with('success', 'Sync started. Transactions will be updated shortly.'); + } + + /** + * Revoke and delete a banking connection. + */ + public function destroy(DestroyConnectionRequest $request, BankingConnection $connection, BankingProviderInterface $provider): RedirectResponse + { + if ($connection->session_id && $connection->isActive()) { + try { + $provider->revokeSession($connection->session_id); + } catch (\Throwable $e) { + Log::warning('Failed to revoke EnableBanking session', [ + 'session_id' => $connection->session_id, + 'error' => $e->getMessage(), + ]); + } + } + + if ($request->boolean('delete_accounts')) { + $connection->accounts->each(function ($account) { + $account->transactions()->delete(); + $account->balances()->delete(); + $account->delete(); + }); + } else { + $connection->accounts()->update([ + 'banking_connection_id' => null, + 'external_account_id' => null, + ]); + } + + $connection->update(['status' => BankingConnectionStatus::Revoked]); + $connection->delete(); + + return redirect()->route('settings.connections.index') + ->with('success', 'Banking connection disconnected.'); + } +} diff --git a/app/Http/Controllers/OpenBanking/InstitutionController.php b/app/Http/Controllers/OpenBanking/InstitutionController.php new file mode 100644 index 00000000..95a79485 --- /dev/null +++ b/app/Http/Controllers/OpenBanking/InstitutionController.php @@ -0,0 +1,18 @@ +getInstitutions($request->validated('country')); + + return response()->json($institutions); + } +} diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index 6e500235..37f3ce5c 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -25,7 +25,7 @@ class AccountController extends Controller ->accounts() ->with('bank:id,name,logo') ->orderBy('name') - ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']); + ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']); return Inertia::render('settings/accounts', [ 'accounts' => $accounts, diff --git a/app/Http/Middleware/EnsureOpenBankingFeature.php b/app/Http/Middleware/EnsureOpenBankingFeature.php new file mode 100644 index 00000000..1374b920 --- /dev/null +++ b/app/Http/Middleware/EnsureOpenBankingFeature.php @@ -0,0 +1,29 @@ +user(); + + if (! $user || ! Feature::for($user)->active('open-banking')) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2aab5a0c..d32cef2f 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -71,6 +71,8 @@ class HandleInertiaRequests extends Middleware 'cashflow' => true, 'budgets' => $user ? Feature::for($user)->active('budgets') : false, 'plaintext-transactions' => $user ? Feature::for($user)->active('plaintext-transactions') : false, + 'open-banking' => $user ? Feature::for($user)->active('open-banking') : false, + 'account-mapping' => $user ? Feature::for($user)->active('account-mapping') : false, ], 'accounts' => fn () => $user ? $user->accounts() ->with('bank:id,name,logo') diff --git a/app/Http/Requests/OpenBanking/DestroyConnectionRequest.php b/app/Http/Requests/OpenBanking/DestroyConnectionRequest.php new file mode 100644 index 00000000..adcb922b --- /dev/null +++ b/app/Http/Requests/OpenBanking/DestroyConnectionRequest.php @@ -0,0 +1,24 @@ +route('connection')->user_id === $this->user()->id; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'delete_accounts' => ['required', 'boolean'], + 'confirmation' => ['required_if:delete_accounts,true', 'nullable', 'string', 'in:delete all'], + ]; + } +} diff --git a/app/Http/Requests/OpenBanking/ListInstitutionsRequest.php b/app/Http/Requests/OpenBanking/ListInstitutionsRequest.php new file mode 100644 index 00000000..56b4e6cc --- /dev/null +++ b/app/Http/Requests/OpenBanking/ListInstitutionsRequest.php @@ -0,0 +1,24 @@ +user())->active('open-banking'); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'country' => ['required', 'string', 'size:2'], + ]; + } +} diff --git a/app/Http/Requests/OpenBanking/MapAccountsRequest.php b/app/Http/Requests/OpenBanking/MapAccountsRequest.php new file mode 100644 index 00000000..ee35efc8 --- /dev/null +++ b/app/Http/Requests/OpenBanking/MapAccountsRequest.php @@ -0,0 +1,32 @@ +route('connection')->user_id === $this->user()->id; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'mappings' => ['required', 'array', 'min:1'], + 'mappings.*.bank_account_uid' => ['required', 'string'], + 'mappings.*.action' => ['required', 'in:create,link,skip'], + 'mappings.*.existing_account_id' => [ + 'nullable', + 'uuid', + 'required_if:mappings.*.action,link', + Rule::exists('accounts', 'id')->where('user_id', $this->user()->id), + ], + ]; + } +} diff --git a/app/Http/Requests/OpenBanking/StartAuthorizationRequest.php b/app/Http/Requests/OpenBanking/StartAuthorizationRequest.php new file mode 100644 index 00000000..9aede424 --- /dev/null +++ b/app/Http/Requests/OpenBanking/StartAuthorizationRequest.php @@ -0,0 +1,26 @@ +user())->active('open-banking'); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'aspsp_name' => ['required', 'string'], + 'country' => ['required', 'string', 'size:2'], + 'logo' => ['nullable', 'string', 'url'], + ]; + } +} diff --git a/app/Jobs/SyncAllBankingConnectionsJob.php b/app/Jobs/SyncAllBankingConnectionsJob.php new file mode 100644 index 00000000..51fa043a --- /dev/null +++ b/app/Jobs/SyncAllBankingConnectionsJob.php @@ -0,0 +1,29 @@ +where('status', BankingConnectionStatus::Active) + ->where(function ($query) { + $query->whereNull('valid_until') + ->orWhere('valid_until', '>', now()); + }) + ->each(function (BankingConnection $connection) { + SyncBankingConnectionJob::dispatch($connection); + }); + } +} diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php new file mode 100644 index 00000000..9ca38c9a --- /dev/null +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -0,0 +1,97 @@ +bankingConnection->id; + } + + public function handle(TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void + { + $connection = $this->bankingConnection; + + if ($connection->isExpired()) { + $connection->update(['status' => BankingConnectionStatus::Expired]); + Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]); + + return; + } + + if (! $connection->isActive()) { + return; + } + + $isFirstSync = ! $connection->last_synced_at; + $dateFrom = $isFirstSync + ? now()->subYear()->toDateString() + : $connection->last_synced_at->toDateString(); + $dateTo = now()->toDateString(); + $strategy = $isFirstSync ? 'longest' : null; + + try { + foreach ($connection->accounts as $account) { + if ($account->isLinked()) { + $lastTransaction = $account->transactions() + ->latest('transaction_date') + ->first(); + + $linkedDateFrom = $lastTransaction + ? $lastTransaction->transaction_date->toDateString() + : $dateFrom; + + $transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); + $balanceSync->sync($account); + } else { + $transactionSync->sync($account, $dateFrom, $dateTo, $strategy); + $balanceSync->sync($account); + + if ($isFirstSync) { + $balanceSync->calculateHistoricalBalances($account); + } + } + } + + $connection->update([ + 'last_synced_at' => now(), + 'error_message' => null, + ]); + } catch (\Throwable $e) { + Log::error('Banking sync failed', [ + 'connection_id' => $connection->id, + 'error' => $e->getMessage(), + ]); + + $connection->update([ + 'status' => BankingConnectionStatus::Error, + 'error_message' => $e->getMessage(), + ]); + + throw $e; + } + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index 4b340b29..3a47898c 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -23,6 +23,9 @@ class Account extends Model 'currency_code', 'type', 'encrypted', + 'banking_connection_id', + 'external_account_id', + 'linked_at', ]; protected function casts(): array @@ -30,6 +33,7 @@ class Account extends Model return [ 'type' => AccountType::class, 'encrypted' => 'boolean', + 'linked_at' => 'datetime', ]; } @@ -52,4 +56,19 @@ class Account extends Model { return $this->hasMany(AccountBalance::class); } + + public function bankingConnection(): BelongsTo + { + return $this->belongsTo(BankingConnection::class); + } + + public function isConnected(): bool + { + return $this->banking_connection_id !== null; + } + + public function isLinked(): bool + { + return $this->linked_at !== null; + } } diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php new file mode 100644 index 00000000..570976dc --- /dev/null +++ b/app/Models/BankingConnection.php @@ -0,0 +1,68 @@ + */ + use HasFactory, HasUuids, SoftDeletes; + + protected $fillable = [ + 'user_id', + 'provider', + 'authorization_id', + 'session_id', + 'aspsp_name', + 'aspsp_country', + 'aspsp_logo', + 'status', + 'valid_until', + 'last_synced_at', + 'error_message', + 'pending_accounts_data', + ]; + + protected function casts(): array + { + return [ + 'status' => BankingConnectionStatus::class, + 'valid_until' => 'datetime', + 'last_synced_at' => 'datetime', + 'pending_accounts_data' => 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function accounts(): HasMany + { + return $this->hasMany(Account::class); + } + + public function isActive(): bool + { + return $this->status === BankingConnectionStatus::Active; + } + + public function hasPendingAccounts(): bool + { + return ! empty($this->pending_accounts_data); + } + + public function isExpired(): bool + { + return $this->status === BankingConnectionStatus::Expired + || ($this->valid_until && $this->valid_until->isPast()); + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 12b761b5..8c6bd639 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -38,6 +38,8 @@ class Transaction extends Model 'notes', 'notes_iv', 'source', + 'external_transaction_id', + 'raw_data', ]; protected function casts(): array @@ -46,6 +48,7 @@ class Transaction extends Model 'transaction_date' => 'date:Y-m-d', 'amount' => 'integer', 'source' => TransactionSource::class, + 'raw_data' => 'array', ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 2f2ed416..4e715ba9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -118,6 +118,11 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma return $this->hasMany(Budget::class); } + public function bankingConnections(): HasMany + { + return $this->hasMany(BankingConnection::class); + } + public function hasReceivedEmail(DripEmailType $type): bool { return $this->mailLogs()->where('email_type', $type)->exists(); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a3f7f2fc..c3f1ca75 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Contracts\BankingProviderInterface; use App\Events\TransactionCreated; use App\Events\TransactionDeleted; use App\Events\TransactionUpdated; @@ -9,6 +10,7 @@ use App\Http\Responses\RegisterResponse; use App\Listeners\AssignTransactionToBudget; use App\Listeners\UnassignTransactionFromBudget; use App\Models\User; +use App\Services\Banking\EnableBankingProvider; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; @@ -24,6 +26,13 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); + + $this->app->bind(BankingProviderInterface::class, function ($app) { + return new EnableBankingProvider( + config('services.enablebanking.app_id'), + base_path(config('services.enablebanking.private_key_path')), + ); + }); } /** @@ -41,5 +50,7 @@ class AppServiceProvider extends ServiceProvider Feature::define('budgets', fn (User $user) => true); Feature::define('plaintext-transactions', fn (User $user) => false); + Feature::define('open-banking', fn (User $user) => false); + Feature::define('account-mapping', fn (User $user) => false); } } diff --git a/app/Services/Banking/BalanceSyncService.php b/app/Services/Banking/BalanceSyncService.php new file mode 100644 index 00000000..47d22488 --- /dev/null +++ b/app/Services/Banking/BalanceSyncService.php @@ -0,0 +1,123 @@ +external_account_id) { + return; + } + + $result = $this->provider->getBalances($account->external_account_id); + $balances = $result['balances'] ?? []; + + if (empty($balances)) { + return; + } + + $balance = $this->selectPreferredBalance($balances); + + if (! $balance) { + return; + } + + $amount = (int) round(floatval($balance['balance_amount']['amount']) * 100); + $date = $balance['reference_date'] ?? now()->toDateString(); + + $account->balances()->updateOrCreate( + ['balance_date' => $date], + ['balance' => $amount], + ); + + Log::info('Synced balance', [ + 'account_id' => $account->id, + 'balance' => $amount, + 'date' => $date, + 'type' => $balance['balance_type'], + ]); + } + + /** + * Calculate historical daily balances by working backwards from the latest known balance. + * Uses transaction amounts to derive end-of-day balances for dates without direct balance data. + */ + public function calculateHistoricalBalances(Account $account): void + { + $referenceBalance = $account->balances() + ->orderByDesc('balance_date') + ->first(); + + if (! $referenceBalance) { + return; + } + + $existingDates = $account->balances() + ->pluck('balance_date') + ->map(fn ($date) => $date->toDateString()) + ->flip() + ->all(); + + $dailyTotals = $account->transactions() + ->where('transaction_date', '<=', $referenceBalance->balance_date) + ->selectRaw('transaction_date, SUM(amount) as daily_total') + ->groupBy('transaction_date') + ->orderByDesc('transaction_date') + ->pluck('daily_total', 'transaction_date'); + + if ($dailyTotals->isEmpty()) { + return; + } + + $runningBalance = $referenceBalance->balance; + $referenceDate = $referenceBalance->balance_date->toDateString(); + + foreach ($dailyTotals as $date => $sum) { + if ($date < $referenceDate && ! isset($existingDates[$date])) { + $account->balances()->create([ + 'balance_date' => $date, + 'balance' => $runningBalance, + ]); + } + + $runningBalance -= (int) $sum; + } + + Log::info('Calculated historical balances', [ + 'account_id' => $account->id, + 'reference_date' => $referenceDate, + 'reference_balance' => $referenceBalance->balance, + ]); + } + + /** + * Select the most useful balance from the list based on preferred types. + */ + private function selectPreferredBalance(array $balances): ?array + { + foreach (self::PREFERRED_BALANCE_TYPES as $type) { + foreach ($balances as $balance) { + if (($balance['balance_type'] ?? null) === $type) { + return $balance; + } + } + } + + return $balances[0] ?? null; + } +} diff --git a/app/Services/Banking/EnableBankingProvider.php b/app/Services/Banking/EnableBankingProvider.php new file mode 100644 index 00000000..10eba8b2 --- /dev/null +++ b/app/Services/Banking/EnableBankingProvider.php @@ -0,0 +1,157 @@ +client()->get('/aspsps', [ + 'country' => $countryCode, + 'psu_type' => 'personal', + ]); + + $response->throw(); + + return collect($response->json('aspsps', [])) + ->map(fn (array $aspsp) => [ + 'name' => $aspsp['name'], + 'country' => $aspsp['country'], + 'logo' => $aspsp['logo'] ?? null, + 'maximum_consent_validity' => $aspsp['maximum_consent_validity'] ?? null, + ]) + ->all(); + } + + public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl): array + { + $response = $this->client()->post('/auth', [ + 'access' => [ + 'valid_until' => now()->addDays(90)->toIso8601String(), + 'balances' => true, + 'transactions' => true, + ], + 'aspsp' => [ + 'name' => $aspspName, + 'country' => $countryCode, + ], + 'state' => csrf_token(), + 'redirect_url' => $redirectUrl, + 'psu_type' => 'personal', + ]); + + $response->throw(); + + $data = $response->json(); + + return [ + 'url' => $data['url'], + 'authorization_id' => $data['authorization_id'], + ]; + } + + public function createSession(string $code): array + { + $response = $this->client()->post('/sessions', [ + 'code' => $code, + ]); + + $response->throw(); + + return $response->json(); + } + + public function getTransactions(string $accountId, string $dateFrom, string $dateTo, ?string $continuationKey = null, ?string $strategy = null): array + { + $query = [ + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + ]; + + if ($continuationKey) { + $query['continuation_key'] = $continuationKey; + } + + if ($strategy) { + $query['strategy'] = $strategy; + } + + $response = $this->client()->get("/accounts/{$accountId}/transactions", $query); + + $response->throw(); + + $data = $response->json(); + + return [ + 'transactions' => $data['transactions'] ?? [], + 'continuation_key' => $data['continuation_key'] ?? null, + ]; + } + + public function getBalances(string $accountId): array + { + $response = $this->client()->get("/accounts/{$accountId}/balances"); + + $response->throw(); + + return $response->json(); + } + + public function getSession(string $sessionId): array + { + $response = $this->client()->get("/sessions/{$sessionId}"); + + $response->throw(); + + return $response->json(); + } + + public function revokeSession(string $sessionId): void + { + $response = $this->client()->delete("/sessions/{$sessionId}"); + + $response->throw(); + } + + private function client(): PendingRequest + { + return Http::baseUrl(self::BASE_URL) + ->withToken($this->generateJwt()) + ->acceptJson() + ->throw(function ($response, $exception) { + Log::error('EnableBanking API error', [ + 'status' => $response->status(), + 'body' => $response->json(), + ]); + }); + } + + private function generateJwt(): string + { + $now = time(); + + $payload = [ + 'iss' => 'enablebanking.com', + 'aud' => 'api.enablebanking.com', + 'iat' => $now, + 'exp' => $now + 3600, + ]; + + $privateKey = file_get_contents($this->privateKeyPath); + + return JWT::encode($payload, $privateKey, 'RS256', $this->appId); + } +} diff --git a/app/Services/Banking/TransactionSyncService.php b/app/Services/Banking/TransactionSyncService.php new file mode 100644 index 00000000..44e8bfff --- /dev/null +++ b/app/Services/Banking/TransactionSyncService.php @@ -0,0 +1,185 @@ +external_account_id) { + return 0; + } + + $created = 0; + $continuationKey = null; + $dailyBalances = []; + + do { + $result = $this->provider->getTransactions( + $account->external_account_id, + $dateFrom, + $dateTo, + $continuationKey, + $strategy, + ); + + foreach ($result['transactions'] as $transaction) { + if ($this->importTransaction($account, $transaction)) { + $created++; + } + + if ($saveDailyBalances) { + $this->trackDailyBalance($transaction, $dailyBalances); + } + } + + $continuationKey = $result['continuation_key']; + } while ($continuationKey); + + if ($saveDailyBalances) { + $this->saveDailyBalances($account, $dailyBalances); + } + + Log::info('Synced transactions', [ + 'account_id' => $account->id, + 'new_transactions' => $created, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + ]); + + return $created; + } + + /** + * Import a single transaction, skipping duplicates. + */ + private function importTransaction(Account $account, array $data): bool + { + $externalId = $data['transaction_id'] ?? $data['entry_reference'] ?? null; + + if ($externalId) { + $exists = $account->transactions() + ->where('external_transaction_id', $externalId) + ->exists(); + + if ($exists) { + return false; + } + } + + $amount = $this->parseAmount($data); + $description = $this->parseDescription($data); + $transactionDate = $this->parseDate($data); + $currency = $data['transaction_amount']['currency'] ?? $account->currency_code; + + $account->transactions()->create([ + 'user_id' => $account->user_id, + 'description' => $description, + 'description_iv' => null, + 'transaction_date' => $transactionDate, + 'amount' => $amount, + 'currency_code' => $currency, + 'notes' => null, + 'notes_iv' => null, + 'source' => TransactionSource::EnableBanking, + 'external_transaction_id' => $externalId, + 'raw_data' => $data, + ]); + + return true; + } + + /** + * Parse amount from EnableBanking transaction data. + * Returns amount in cents (bigint). Debits are negative. + */ + private function parseAmount(array $data): int + { + $rawAmount = $data['transaction_amount']['amount'] ?? '0'; + $cents = (int) round(floatval($rawAmount) * 100); + + $indicator = $data['credit_debit_indicator'] ?? null; + + if ($indicator === 'DBIT') { + return -abs($cents); + } + + return abs($cents); + } + + /** + * Parse description from EnableBanking transaction data. + */ + private function parseDescription(array $data): string + { + $remittanceInfo = $data['remittance_information'] ?? []; + + if (! empty($remittanceInfo)) { + return implode(' ', $remittanceInfo); + } + + return $data['creditor']['name'] + ?? $data['debtor']['name'] + ?? 'Bank transaction'; + } + + /** + * Parse transaction date, preferring booking_date. + */ + private function parseDate(array $data): string + { + return $data['booking_date'] + ?? $data['transaction_date'] + ?? $data['value_date'] + ?? now()->toDateString(); + } + + /** + * Track the balance after transaction for each day. + * Overwrites so only the last transaction's balance per day is kept. + * + * @param array $dailyBalances + */ + private function trackDailyBalance(array $transaction, array &$dailyBalances): void + { + $balanceAfter = $transaction['balance_after_transaction'] ?? null; + + if (! $balanceAfter || ! isset($balanceAfter['amount'])) { + return; + } + + $date = $this->parseDate($transaction); + $amount = (int) round(floatval($balanceAfter['amount']) * 100); + + $dailyBalances[$date] = $amount; + } + + /** + * Save tracked daily balances to the account. + * + * @param array $dailyBalances + */ + private function saveDailyBalances(Account $account, array $dailyBalances): void + { + foreach ($dailyBalances as $date => $balance) { + $account->balances()->updateOrCreate( + ['balance_date' => $date], + ['balance' => $balance], + ); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index f55756cb..c9147cc9 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ \App\Http\Middleware\EnsureOnboardingComplete::class, 'block-demo' => \App\Http\Middleware\BlockDemoAccountActions::class, 'budgets' => EnsureBudgetsFeature::class, + 'open-banking' => EnsureOpenBankingFeature::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index 10e4b0d7..3d80962f 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "firebase/php-jwt": "^7.0", "inertiajs/inertia-laravel": "^2.0", "laravel/cashier": "^16.1", "laravel/fortify": "^1.30", diff --git a/composer.lock b/composer.lock index ad00662f..46ef34ce 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d01684feb019023ee08971e154374c89", + "content-hash": "98b80c7fbbd588b5674d2a769d135250", "packages": [ { "name": "bacon/bacon-qr-code", @@ -613,6 +613,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + }, + "time": "2025-12-16T22:17:28+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -12399,5 +12462,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/services.php b/config/services.php index 6a90eb83..cd06196e 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,10 @@ return [ ], ], + 'enablebanking' => [ + 'app_id' => env('ENABLEBANKING_APP_ID'), + 'private_key_path' => env('ENABLEBANKING_PRIVATE_KEY_PATH'), + 'redirect_url' => env('ENABLEBANKING_REDIRECT_URL'), + ], + ]; diff --git a/database/factories/AccountFactory.php b/database/factories/AccountFactory.php index 3a479caa..1bee139c 100644 --- a/database/factories/AccountFactory.php +++ b/database/factories/AccountFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; use App\Enums\AccountType; use App\Models\Bank; +use App\Models\BankingConnection; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -29,4 +30,21 @@ class AccountFactory extends Factory 'type' => fake()->randomElement(AccountType::cases()), ]; } + + public function connected(): static + { + return $this->state(fn (array $attributes) => [ + 'banking_connection_id' => BankingConnection::factory(), + 'external_account_id' => fake()->uuid(), + ]); + } + + public function linked(): static + { + return $this->state(fn (array $attributes) => [ + 'banking_connection_id' => BankingConnection::factory(), + 'external_account_id' => fake()->uuid(), + 'linked_at' => now(), + ]); + } } diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php new file mode 100644 index 00000000..1acfa510 --- /dev/null +++ b/database/factories/BankingConnectionFactory.php @@ -0,0 +1,82 @@ + + */ +class BankingConnectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'provider' => 'enablebanking', + 'authorization_id' => fake()->uuid(), + 'session_id' => fake()->uuid(), + 'aspsp_name' => fake()->company(), + 'aspsp_country' => fake()->randomElement(['ES', 'DE', 'FR', 'IT', 'NL']), + 'status' => BankingConnectionStatus::Active, + 'valid_until' => now()->addDays(90), + 'last_synced_at' => now(), + 'error_message' => null, + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => BankingConnectionStatus::Pending, + 'session_id' => null, + 'last_synced_at' => null, + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => BankingConnectionStatus::Expired, + 'valid_until' => now()->subDay(), + ]); + } + + public function revoked(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => BankingConnectionStatus::Revoked, + ]); + } + + public function awaitingMapping(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => BankingConnectionStatus::AwaitingMapping, + 'last_synced_at' => null, + 'pending_accounts_data' => [ + [ + 'uid' => fake()->uuid(), + 'currency' => 'EUR', + 'name' => 'Test Account', + 'account_id' => ['iban' => 'ES1234567890123456789012'], + ], + ], + ]); + } + + public function error(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => BankingConnectionStatus::Error, + 'error_message' => 'Connection failed: bank returned an error', + ]); + } +} diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index e3683359..cfe40c32 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -42,6 +42,16 @@ class TransactionFactory extends Factory ]); } + public function enableBanking(): static + { + return $this->state(fn (array $attributes) => [ + 'source' => TransactionSource::EnableBanking, + 'external_transaction_id' => fake()->uuid(), + 'description_iv' => null, + 'notes_iv' => null, + ]); + } + public function plaintext(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_02_09_144915_create_banking_connections_table.php b/database/migrations/2026_02_09_144915_create_banking_connections_table.php new file mode 100644 index 00000000..fe869650 --- /dev/null +++ b/database/migrations/2026_02_09_144915_create_banking_connections_table.php @@ -0,0 +1,38 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('authorization_id')->nullable(); + $table->string('session_id')->nullable()->unique(); + $table->string('aspsp_name'); + $table->string('aspsp_country', 2); + $table->string('status'); + $table->dateTime('valid_until')->nullable(); + $table->dateTime('last_synced_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('banking_connections'); + } +}; diff --git a/database/migrations/2026_02_09_144959_add_banking_connection_fields_to_accounts_table.php b/database/migrations/2026_02_09_144959_add_banking_connection_fields_to_accounts_table.php new file mode 100644 index 00000000..03834e09 --- /dev/null +++ b/database/migrations/2026_02_09_144959_add_banking_connection_fields_to_accounts_table.php @@ -0,0 +1,32 @@ +foreignUuid('banking_connection_id')->nullable()->after('type')->constrained()->nullOnDelete(); + $table->string('external_account_id')->nullable()->after('banking_connection_id'); + + $table->index('banking_connection_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + $table->dropForeign(['banking_connection_id']); + $table->dropColumn(['banking_connection_id', 'external_account_id']); + }); + } +}; diff --git a/database/migrations/2026_02_09_144959_add_external_transaction_id_to_transactions_table.php b/database/migrations/2026_02_09_144959_add_external_transaction_id_to_transactions_table.php new file mode 100644 index 00000000..42c6355f --- /dev/null +++ b/database/migrations/2026_02_09_144959_add_external_transaction_id_to_transactions_table.php @@ -0,0 +1,31 @@ +string('external_transaction_id')->nullable()->after('source'); + + $table->index(['account_id', 'external_transaction_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropIndex(['account_id', 'external_transaction_id']); + $table->dropColumn('external_transaction_id'); + }); + } +}; diff --git a/database/migrations/2026_02_10_085759_add_aspsp_logo_to_banking_connections_table.php b/database/migrations/2026_02_10_085759_add_aspsp_logo_to_banking_connections_table.php new file mode 100644 index 00000000..e4378c39 --- /dev/null +++ b/database/migrations/2026_02_10_085759_add_aspsp_logo_to_banking_connections_table.php @@ -0,0 +1,28 @@ +string('aspsp_logo')->nullable()->after('aspsp_country'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('banking_connections', function (Blueprint $table) { + $table->dropColumn('aspsp_logo'); + }); + } +}; diff --git a/database/migrations/2026_02_10_095639_add_raw_data_to_transactions_table.php b/database/migrations/2026_02_10_095639_add_raw_data_to_transactions_table.php new file mode 100644 index 00000000..8da8593f --- /dev/null +++ b/database/migrations/2026_02_10_095639_add_raw_data_to_transactions_table.php @@ -0,0 +1,28 @@ +json('raw_data')->nullable()->after('external_transaction_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropColumn('raw_data'); + }); + } +}; diff --git a/database/migrations/2026_02_11_113115_add_pending_accounts_data_to_banking_connections_table.php b/database/migrations/2026_02_11_113115_add_pending_accounts_data_to_banking_connections_table.php new file mode 100644 index 00000000..d82d8109 --- /dev/null +++ b/database/migrations/2026_02_11_113115_add_pending_accounts_data_to_banking_connections_table.php @@ -0,0 +1,25 @@ +json('pending_accounts_data')->nullable()->after('error_message'); + }); + } + + public function down(): void + { + Schema::table('banking_connections', function (Blueprint $table) { + $table->dropColumn('pending_accounts_data'); + }); + } +}; diff --git a/database/migrations/2026_02_11_113119_add_linked_at_to_accounts_table.php b/database/migrations/2026_02_11_113119_add_linked_at_to_accounts_table.php new file mode 100644 index 00000000..4fab5bb1 --- /dev/null +++ b/database/migrations/2026_02_11_113119_add_linked_at_to_accounts_table.php @@ -0,0 +1,25 @@ +timestamp('linked_at')->nullable()->after('external_account_id'); + }); + } + + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + $table->dropColumn('linked_at'); + }); + } +}; diff --git a/resources/js/components/accounts/account-list-card.tsx b/resources/js/components/accounts/account-list-card.tsx index 548cc3f8..7b450818 100644 --- a/resources/js/components/accounts/account-list-card.tsx +++ b/resources/js/components/accounts/account-list-card.tsx @@ -1,5 +1,6 @@ import { show } from '@/actions/App/Http/Controllers/AccountController'; import { AccountName } from '@/components/accounts/account-name'; +import { BankLogo } from '@/components/bank-logo'; import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Card, CardContent } from '@/components/ui/card'; @@ -63,21 +64,12 @@ export function AccountListCard({ className="-my-1 -ml-1.5 flex items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted" >

- {account.bank?.logo ? ( - {account.bank.name} - ) : ( -
- - {account.bank?.name?.charAt( - 0, - ) || '?'} - -
- )} + {selectedBank ? (
- {selectedBank.logo ? ( - {selectedBank.name} - ) : ( -
- )} + {selectedBank.name}
) : ( @@ -154,15 +152,12 @@ export function BankCombobox({ onSelect={() => handleSelect(bank)} >
- {bank.logo ? ( - {bank.name} - ) : ( -
- )} + {bank.name}
void }) { + const { features } = usePage().props; + const openBankingEnabled = features['open-banking']; + const [open, setOpen] = useState(false); + const [mode, setMode] = useState( + openBankingEnabled ? 'choice' : 'manual', + ); const [isSubmitting, setIsSubmitting] = useState(false); + const [connectDialogOpen, setConnectDialogOpen] = useState(false); const formDataRef = useRef({ displayName: '', bankId: null, @@ -29,6 +42,13 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) { formDataRef.current = data; }, []); + function handleOpenChange(newOpen: boolean) { + setOpen(newOpen); + if (!newOpen) { + setMode(openBankingEnabled ? 'choice' : 'manual'); + } + } + async function createBankAndGetId(): Promise { const customBank = formDataRef.current.customBank; if (!customBank) return null; @@ -113,7 +133,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) { }, { onSuccess: () => { - setOpen(false); + handleOpenChange(false); onSuccess?.(); }, onFinish: () => { @@ -133,41 +153,105 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) { } return ( - - - - - - - {__('Create Account')} - - {__( - 'Add a new bank account to track your transactions.', - )} - - -
- + <> + + + {__('Create Account')} + + + + {__('Create Account')} + + {mode === 'choice' + ? __('Choose how you want to add your account.') + : __( + 'Add a new bank account to track your transactions.', + )} + + -
- - -
- -
-
+ {mode === 'choice' && ( +
+ + +
+ )} + + {mode === 'manual' && ( +
+ + +
+ + +
+ + )} +
+
+ + + ); } diff --git a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx b/resources/js/components/accounts/import-balances/import-balance-step-account.tsx index 83b7c306..030a7565 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-step-account.tsx @@ -1,11 +1,11 @@ import { AccountName } from '@/components/accounts/account-name'; +import { BankLogo } from '@/components/bank-logo'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { type Account } from '@/types/account'; import type { UUID } from '@/types/uuid'; import { __ } from '@/utils/i18n'; -import { Building2 } from 'lucide-react'; interface ImportBalanceStepAccountProps { accounts?: Account[]; @@ -48,17 +48,12 @@ export function ImportBalanceStepAccount({ id={`account-${account.id}`} /> - {account.bank.logo ? ( - {account.bank.name} - ) : ( -
- -
- )} +
- + + {__('Create Rule')} + diff --git a/resources/js/components/bank-logo.tsx b/resources/js/components/bank-logo.tsx new file mode 100644 index 00000000..2f34acaa --- /dev/null +++ b/resources/js/components/bank-logo.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/lib/utils'; +import { Building2 } from 'lucide-react'; + +interface BankLogoProps { + src?: string | null; + name?: string; + className?: string; + fallback?: 'letter' | 'icon' | 'empty' | 'none'; +} + +export function BankLogo({ + src, + name, + className, + fallback = 'none', +}: BankLogoProps) { + if (src) { + return ( + {name + ); + } + + if (fallback === 'none') { + return null; + } + + if (fallback === 'letter') { + return ( +
+ + {name?.charAt(0) || '?'} + +
+ ); + } + + if (fallback === 'icon') { + return ( +
+ +
+ ); + } + + return
; +} diff --git a/resources/js/components/categories/create-category-dialog.tsx b/resources/js/components/categories/create-category-dialog.tsx index 8a39ebf7..7c47feda 100644 --- a/resources/js/components/categories/create-category-dialog.tsx +++ b/resources/js/components/categories/create-category-dialog.tsx @@ -2,6 +2,7 @@ import { store } from '@/actions/App/Http/Controllers/Settings/CategoryControlle import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { CreateButton } from '@/components/ui/create-button'; import { Dialog, DialogContent, @@ -42,7 +43,7 @@ export function CreateCategoryDialog({ return ( - + {__('Create Category')} diff --git a/resources/js/components/dashboard/account-balance-card.tsx b/resources/js/components/dashboard/account-balance-card.tsx index 7a65fa82..7a220608 100644 --- a/resources/js/components/dashboard/account-balance-card.tsx +++ b/resources/js/components/dashboard/account-balance-card.tsx @@ -1,6 +1,7 @@ import { show } from '@/actions/App/Http/Controllers/AccountController'; import { AccountName } from '@/components/accounts/account-name'; import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog'; +import { BankLogo } from '@/components/bank-logo'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { AccountWithMetrics } from '@/hooks/use-dashboard-data'; @@ -48,13 +49,11 @@ export function AccountBalanceCard({ href={show.url(account.id)} className="-my-1 -ml-1.5 flex items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted" > - {account.bank.logo && ( - {account.bank.name} - )} + void }) { return ( - + {__('Create Label')} diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx new file mode 100644 index 00000000..1885b3c7 --- /dev/null +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -0,0 +1,324 @@ +import { BankLogo } from '@/components/bank-logo'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +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 { useCallback, useEffect, 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; + +interface ConnectAccountDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type Step = 'country' | 'bank' | 'confirm'; + +export function ConnectAccountDialog({ + open, + onOpenChange, +}: ConnectAccountDialogProps) { + 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 resetState = useCallback(() => { + setStep('country'); + setCountry(''); + setInstitutions([]); + setFilteredInstitutions([]); + setSearchQuery(''); + setSelectedBank(null); + setIsLoading(false); + setIsSubmitting(false); + setError(null); + }, []); + + useEffect(() => { + if (!open) { + resetState(); + } + }, [open, resetState]); + + useEffect(() => { + if (searchQuery) { + setFilteredInstitutions( + institutions.filter((i) => + i.name.toLowerCase().includes(searchQuery.toLowerCase()), + ), + ); + } else { + setFilteredInstitutions(institutions); + } + }, [searchQuery, institutions]); + + 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': decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ), + }, + }, + ); + + if (!response.ok) { + throw new Error('Failed to fetch banks'); + } + + const data = await response.json(); + setInstitutions(data); + setFilteredInstitutions(data); + 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 response = await fetch('/open-banking/authorize', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-XSRF-TOKEN': decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ), + }, + body: JSON.stringify({ + aspsp_name: selectedBank.name, + country: country, + logo: selectedBank.logo, + }), + }); + + if (!response.ok) { + throw new Error('Failed to start authorization'); + } + + const data = await response.json(); + window.location.href = data.redirect_url; + } catch { + setError(__('Failed to connect to your bank. Please try again.')); + setIsSubmitting(false); + } + } + + return ( + + + + {__('Connect Bank Account')} + + {step === 'country' && + __( + 'Select the country where your bank is located.', + )} + {step === 'bank' && __('Select your bank.')} + {step === 'confirm' && + __( + 'You will be redirected to your bank to authorize access.', + )} + + + + {error &&

{error}

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

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

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

+ {selectedBank.name} +

+

+ {__( + 'You will be redirected to authorize access to your account data.', + )} +

+
+
+
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/resources/js/components/open-banking/connection-status-badge.tsx b/resources/js/components/open-banking/connection-status-badge.tsx new file mode 100644 index 00000000..a06ffe74 --- /dev/null +++ b/resources/js/components/open-banking/connection-status-badge.tsx @@ -0,0 +1,40 @@ +import { Badge } from '@/components/ui/badge'; +import { Spinner } from '@/components/ui/spinner'; +import type { BankingConnection } from '@/types/banking'; +import { __ } from '@/utils/i18n'; + +const statusConfig: Record< + BankingConnection['status'], + { + label: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline'; + } +> = { + active: { label: 'Active', variant: 'default' }, + awaiting_mapping: { label: 'Setup Required', variant: 'secondary' }, + pending: { label: 'Pending', variant: 'secondary' }, + expired: { label: 'Expired', variant: 'outline' }, + revoked: { label: 'Revoked', variant: 'outline' }, + error: { label: 'Error', variant: 'destructive' }, +}; + +export function ConnectionStatusBadge({ + status, + lastSyncedAt, +}: { + status: BankingConnection['status']; + lastSyncedAt?: string | null; +}) { + if (status === 'active' && !lastSyncedAt) { + return ( + + + {__('Syncing')} + + ); + } + + const config = statusConfig[status]; + + return {__(config.label)}; +} diff --git a/resources/js/components/open-banking/disconnect-dialog.tsx b/resources/js/components/open-banking/disconnect-dialog.tsx new file mode 100644 index 00000000..456841d0 --- /dev/null +++ b/resources/js/components/open-banking/disconnect-dialog.tsx @@ -0,0 +1,170 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { BankingConnection } from '@/types/banking'; +import { __ } from '@/utils/i18n'; +import { router } from '@inertiajs/react'; +import { useState } from 'react'; + +interface DisconnectDialogProps { + connection: BankingConnection; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DisconnectDialog({ + connection, + open, + onOpenChange, +}: DisconnectDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [deleteAccounts, setDeleteAccounts] = useState(null); + const [confirmation, setConfirmation] = useState(''); + + function handleDisconnect() { + setIsSubmitting(true); + + router.delete(`/settings/connections/${connection.id}`, { + data: { + delete_accounts: deleteAccounts ?? false, + confirmation: deleteAccounts ? confirmation : null, + }, + onFinish: () => { + setIsSubmitting(false); + onOpenChange(false); + }, + }); + } + + function handleOpenChange(value: boolean) { + if (!value) { + setDeleteAccounts(null); + setConfirmation(''); + } + onOpenChange(value); + } + + const isConfirmed = deleteAccounts + ? confirmation.toLowerCase() === 'delete all' + : deleteAccounts !== null; + + return ( + + + + {__('Disconnect Bank')} + + {__( + 'This will revoke access to your bank account data from :bank.', + { bank: connection.aspsp_name }, + )} + + + + {connection.accounts_count > 0 && ( +
+

+ {__( + 'This connection has :count associated account(s). What would you like to do with them?', + { + count: String(connection.accounts_count), + }, + )} +

+ +
+ + + +
+ + {deleteAccounts === true && ( +
+ + + setConfirmation(e.target.value) + } + placeholder="delete all" + autoComplete="off" + /> +
+ )} +
+ )} + + + + + +
+
+ ); +} diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index aa67b102..7214aa46 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -33,6 +33,7 @@ import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; +import { type SharedData } from '@/types'; import { filterTransactionalAccounts, type Account, @@ -41,7 +42,6 @@ import { import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; -import { type SharedData } from '@/types'; import { type DecryptedTransaction } from '@/types/transaction'; import { formatDate } from '@/utils/date'; import { __ } from '@/utils/i18n'; diff --git a/resources/js/components/transactions/import-step-account.tsx b/resources/js/components/transactions/import-step-account.tsx index c1dc15b9..13095916 100644 --- a/resources/js/components/transactions/import-step-account.tsx +++ b/resources/js/components/transactions/import-step-account.tsx @@ -1,11 +1,11 @@ import { AccountName } from '@/components/accounts/account-name'; +import { BankLogo } from '@/components/bank-logo'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { filterTransactionalAccounts, type Account } from '@/types/account'; import type { UUID } from '@/types/uuid'; import { __ } from '@/utils/i18n'; -import { Building2 } from 'lucide-react'; import { useEffect } from 'react'; interface ImportStepAccountProps { @@ -59,17 +59,12 @@ export function ImportStepAccount({ id={`account-${account.id}`} /> - {account.bank.logo ? ( - {account.bank.name} - ) : ( -
- -
- )} +
- {transaction.bank?.logo && ( - {transaction.bank.name} - )} + ) { + return ( + + ); +} + +export { CreateButton }; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 084b437c..729d711c 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -23,6 +23,7 @@ import { type PropsWithChildren } from 'react'; const getNavItems = ( subscriptionsEnabled: boolean, isDemoAccount: boolean, + openBankingEnabled: boolean, ): (NavItem | NavSectionHeader | NavDivider)[] => [ { type: 'nav-item', @@ -30,6 +31,16 @@ const getNavItems = ( href: accountsIndex(), icon: null, }, + ...(openBankingEnabled + ? [ + { + type: 'nav-item' as const, + title: 'Connections', + href: '/settings/connections', + icon: null, + }, + ] + : []), { type: 'nav-item', title: 'Automation rules', @@ -93,8 +104,10 @@ const getNavItems = ( ]; export default function SettingsLayout({ children }: PropsWithChildren) { - const { subscriptionsEnabled, auth } = usePage().props; + const { subscriptionsEnabled, auth, features } = + usePage().props; const isDemoAccount = auth?.isDemoAccount ?? false; + const openBankingEnabled = features['open-banking'] ?? false; // When server-side rendering, we only render the layout on the client... if (typeof window === 'undefined') { @@ -102,7 +115,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) { } const currentPath = window.location.pathname; - const sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount); + const sidebarNavItems = getNavItems( + subscriptionsEnabled, + isDemoAccount, + openBankingEnabled, + ); return (
diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index 5a596236..00c40360 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -6,6 +6,7 @@ import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog import { EditAccountDialog } from '@/components/accounts/edit-account-dialog'; import { ImportBalancesDrawer } from '@/components/accounts/import-balances-drawer'; import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog'; +import { BankLogo } from '@/components/bank-logo'; import HeadingSmall from '@/components/heading-small'; import { TransactionList } from '@/components/transactions/transaction-list'; import { Button } from '@/components/ui/button'; @@ -76,19 +77,12 @@ export default function AccountShow({
- {account.bank?.logo ? ( - {account.bank.name} - ) : ( -
- - {account.bank?.name?.charAt(0) || '?'} - -
- )} + ( + bankAccounts.map((ba) => ({ + bank_account_uid: ba.uid, + action: 'create', + existing_account_id: null, + })), + ); + const [processing, setProcessing] = useState(false); + + function updateMapping(uid: string, updates: Partial) { + setMappings((prev) => + prev.map((m) => + m.bank_account_uid === uid ? { ...m, ...updates } : m, + ), + ); + } + + function getCompatibleAccounts(currency: string) { + return existingAccounts.filter((a) => a.currency_code === currency); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setProcessing(true); + router.post( + `/open-banking/connections/${connection.id}/map-accounts`, + { mappings }, + { + onFinish: () => setProcessing(false), + }, + ); + } + + return ( +
+ + +
+
+

+ {__('Map Bank Accounts')} +

+

+ {__( + 'Choose how to handle each account from :bank. You can create new accounts, link to existing ones, or skip.', + { bank: connection.aspsp_name }, + )} +

+
+ +
+ {bankAccounts.map((bankAccount) => { + const mapping = mappings.find( + (m) => m.bank_account_uid === bankAccount.uid, + ); + const compatibleAccounts = getCompatibleAccounts( + bankAccount.currency, + ); + const displayName = + bankAccount.name || + bankAccount.account_id?.iban || + __('Bank Account'); + + return ( + + + + {displayName} + + + {bankAccount.currency} + {bankAccount.account_id?.iban && + bankAccount.name && + ` \u00b7 ${bankAccount.account_id.iban}`} + + + + + updateMapping(bankAccount.uid, { + action: value, + existing_account_id: + value === 'link' + ? (mapping?.existing_account_id ?? + null) + : null, + }) + } + > +
+ + +
+ + {compatibleAccounts.length > 0 && ( +
+
+ + +
+ + {mapping?.action === 'link' && ( +
+ +
+ )} +
+ )} + +
+ + +
+
+
+
+ ); + })} + +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx index 44ce50d7..45da4f34 100644 --- a/resources/js/pages/settings/accounts.tsx +++ b/resources/js/pages/settings/accounts.tsx @@ -14,7 +14,7 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; -import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; +import { ArrowUpDown, Link2, MoreHorizontal } from 'lucide-react'; import { useState } from 'react'; import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController'; @@ -22,6 +22,7 @@ import { AccountName } from '@/components/accounts/account-name'; import { CreateAccountDialog } from '@/components/accounts/create-account-dialog'; import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog'; import { EditAccountDialog } from '@/components/accounts/edit-account-dialog'; +import { BankLogo } from '@/components/bank-logo'; import HeadingSmall from '@/components/heading-small'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -213,7 +214,7 @@ export default function Accounts({ accounts }: AccountsPageProps) { }, cell: ({ row }) => { return ( -
+
- {bank.logo ? ( - {bank.name} - ) : ( -
- )} + {bank.name}
); @@ -247,10 +245,20 @@ export default function Accounts({ accounts }: AccountsPageProps) { accessorKey: 'type', header: () => __('Type'), cell: ({ row }) => { + const isConnected = !!row.original.banking_connection_id; + return ( - - {formatAccountType(row.getValue('type'))} - +
+ + {formatAccountType(row.getValue('type'))} + + {isConnected && ( + + )} +
); }, }, diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx new file mode 100644 index 00000000..b69f017f --- /dev/null +++ b/resources/js/pages/settings/connections.tsx @@ -0,0 +1,244 @@ +import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog'; +import { ConnectionStatusBadge } from '@/components/open-banking/connection-status-badge'; +import { DisconnectDialog } from '@/components/open-banking/disconnect-dialog'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { CreateButton } from '@/components/ui/create-button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Spinner } from '@/components/ui/spinner'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import type { SharedData } from '@/types'; +import type { BankingConnection } from '@/types/banking'; +import { __ } from '@/utils/i18n'; +import { Head, router, usePage, usePoll } from '@inertiajs/react'; +import { ArrowRight, MoreHorizontal, RefreshCw, Unplug } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface Props { + connections: BankingConnection[]; +} + +export default function ConnectionsPage({ connections }: Props) { + const { auth } = usePage().props; + const isDemoAccount = auth?.isDemoAccount ?? false; + const [connectDialogOpen, setConnectDialogOpen] = useState(false); + const [disconnectConnection, setDisconnectConnection] = + useState(null); + + const hasSyncing = connections.some( + (c) => c.status === 'active' && !c.last_synced_at, + ); + + const { start, stop } = usePoll(5000, {}, { autoStart: false }); + + useEffect(() => { + if (hasSyncing) { + start(); + } else { + stop(); + } + }, [hasSyncing, start, stop]); + + function handleSync(connection: BankingConnection) { + router.post(`/settings/connections/${connection.id}/sync`); + } + + function formatDate(dateString: string | null): string { + if (!dateString) return __('Never'); + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + return ( + + + + +
+
+
+

+ {__('Bank Connections')} +

+

+ {__( + 'Manage your connected bank accounts for automatic transaction syncing.', + )} +

+
+ setConnectDialogOpen(true)} + disabled={isDemoAccount} + > + {__('Connect Bank')} + +
+ + {connections.length === 0 ? ( + + +

+ {__( + 'No bank connections yet. Connect a bank to automatically sync your transactions.', + )} +

+
+
+ ) : ( +
+ {connections.map((connection) => ( + + +
+ + {connection.aspsp_name} + + + {connection.aspsp_country}{' '} + ·{' '} + {connection.accounts_count}{' '} + {connection.accounts_count === 1 + ? __('account') + : __('accounts')} + +
+
+ + + + + + + {connection.status === + 'awaiting_mapping' && ( + + router.visit( + `/open-banking/connections/${connection.id}/map-accounts`, + ) + } + > + + {__('Map Accounts')} + + )} + {connection.status === + 'active' && ( + + handleSync( + connection, + ) + } + > + + {__('Sync Now')} + + )} + + setDisconnectConnection( + connection, + ) + } + className="text-destructive" + > + + {__('Disconnect')} + + + +
+
+ +
+ {connection.status === + 'awaiting_mapping' ? ( + + {__( + 'Accounts need to be mapped before syncing can begin.', + )} + + ) : connection.status === + 'active' && + !connection.last_synced_at ? ( + + + {__( + 'Syncing transactions and balances…', + )} + + ) : ( + + {__('Last synced')}:{' '} + {formatDate( + connection.last_synced_at, + )} + + )} + {connection.valid_until && ( + + {__('Expires')}:{' '} + {formatDate( + connection.valid_until, + )} + + )} +
+ {connection.error_message && ( +

+ {connection.error_message} +

+ )} +
+
+ ))} +
+ )} +
+ + + + {disconnectConnection && ( + { + if (!open) setDisconnectConnection(null); + }} + /> + )} +
+
+ ); +} diff --git a/resources/js/types/account.ts b/resources/js/types/account.ts index 2fa47c93..0a41fedf 100644 --- a/resources/js/types/account.ts +++ b/resources/js/types/account.ts @@ -43,6 +43,9 @@ export interface Account { bank: Bank; type: AccountType; currency_code: CurrencyCode; + banking_connection_id: UUID | null; + external_account_id: string | null; + linked_at: string | null; } export interface AccountBalance { diff --git a/resources/js/types/banking.ts b/resources/js/types/banking.ts new file mode 100644 index 00000000..059662c5 --- /dev/null +++ b/resources/js/types/banking.ts @@ -0,0 +1,38 @@ +import { UUID } from './uuid'; + +export interface BankingConnection { + id: UUID; + provider: string; + aspsp_name: string; + aspsp_country: string; + status: + | 'pending' + | 'awaiting_mapping' + | 'active' + | 'expired' + | 'revoked' + | 'error'; + valid_until: string | null; + last_synced_at: string | null; + error_message: string | null; + accounts_count: number; + has_pending_accounts?: boolean; + created_at: string; + updated_at: string; +} + +export interface PendingBankAccount { + uid: string; + currency: string; + name?: string; + account_id?: { + iban?: string; + }; +} + +export interface EnableBankingInstitution { + name: string; + country: string; + logo: string | null; + maximum_consent_validity: number | null; +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index e5154f19..7f7c012d 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -42,6 +42,8 @@ export interface Features { cashflow: boolean; budgets: boolean; 'plaintext-transactions': boolean; + 'open-banking': boolean; + 'account-mapping': boolean; } export interface SharedData { diff --git a/routes/console.php b/routes/console.php index 8d03fa99..303850ec 100644 --- a/routes/console.php +++ b/routes/console.php @@ -5,3 +5,4 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('demo:reset')->twiceDaily(); Schedule::command('horizon:snapshot')->everyFiveMinutes(); Schedule::command('budgets:generate-periods')->daily(); +Schedule::command('banking:sync')->everySixHours(); diff --git a/routes/settings.php b/routes/settings.php index d28e1cec..86166666 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -1,5 +1,6 @@ group(function () { Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show']) ->name('two-factor.show'); + + // Open Banking connections (feature-flagged) + Route::middleware('open-banking')->group(function () { + Route::get('settings/connections', [ConnectionController::class, 'index'])->name('settings.connections.index'); + Route::post('settings/connections/{connection}/sync', [ConnectionController::class, 'sync'])->name('settings.connections.sync'); + Route::delete('settings/connections/{connection}', [ConnectionController::class, 'destroy'])->name('settings.connections.destroy'); + }); }); diff --git a/routes/web.php b/routes/web.php index ea55a33d..1bb581b7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,9 @@ use App\Http\Controllers\BudgetController; use App\Http\Controllers\CashflowController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\OnboardingController; +use App\Http\Controllers\OpenBanking\AccountMappingController; +use App\Http\Controllers\OpenBanking\AuthorizationController; +use App\Http\Controllers\OpenBanking\InstitutionController; use App\Http\Controllers\RobotsController; use App\Http\Controllers\SitemapController; use App\Http\Controllers\SubscriptionController; @@ -61,6 +64,14 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy'); }); +Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', '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'); + Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts'); + Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store'); +}); + Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'budgets'])->group(function () { Route::get('budgets', [BudgetController::class, 'index'])->name('budgets.index'); Route::post('budgets', [BudgetController::class, 'store'])->name('budgets.store'); diff --git a/tests/Feature/OpenBanking/AccountMappingTest.php b/tests/Feature/OpenBanking/AccountMappingTest.php new file mode 100644 index 00000000..f6d47b40 --- /dev/null +++ b/tests/Feature/OpenBanking/AccountMappingTest.php @@ -0,0 +1,312 @@ + 'test-app-id', + 'services.enablebanking.private_key_path' => '/tmp/fake-key.pem', + 'services.enablebanking.redirect_url' => 'https://example.com/callback', + ]); +}); + +test('show returns mapping page with correct props', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $user->id, + ]); + + $response = $this->actingAs($user) + ->get(route('open-banking.map-accounts', $connection)); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('open-banking/map-accounts') + ->has('connection') + ->has('bankAccounts') + ->has('existingAccounts') + ); +}); + +test('show redirects if no pending accounts', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'pending_accounts_data' => null, + ]); + + $response = $this->actingAs($user) + ->get(route('open-banking.map-accounts', $connection)); + + $response->assertRedirect(route('settings.connections.index')); +}); + +test('show returns 403 for other user\'s connection', function () { + $user = User::factory()->onboarded()->create(); + $otherUser = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $otherUser->id, + ]); + + $response = $this->actingAs($user) + ->get(route('open-banking.map-accounts', $connection)); + + $response->assertForbidden(); +}); + +test('store with action create creates new accounts', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Test Bank', + 'pending_accounts_data' => [ + [ + 'uid' => 'ext-1', + 'currency' => 'EUR', + 'name' => 'Test Checking', + 'account_id' => ['iban' => 'ES1234567890'], + ], + ], + ]); + + $response = $this->actingAs($user) + ->post(route('open-banking.map-accounts.store', $connection), [ + 'mappings' => [ + [ + 'bank_account_uid' => 'ext-1', + 'action' => 'create', + 'existing_account_id' => null, + ], + ], + ]); + + $response->assertRedirect(route('settings.connections.index')); + + $this->assertDatabaseHas('accounts', [ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-1', + 'name' => 'Test Checking', + 'currency_code' => 'EUR', + ]); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->pending_accounts_data)->toBeNull(); + + Queue::assertPushed(SyncBankingConnectionJob::class); +}); + +test('store with action link links existing account', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $bank = Bank::factory()->create(); + $existingAccount = Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'currency_code' => 'EUR', + 'banking_connection_id' => null, + ]); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Test Bank', + 'pending_accounts_data' => [ + [ + 'uid' => 'ext-1', + 'currency' => 'EUR', + 'name' => 'Bank Account', + 'account_id' => ['iban' => 'ES1234567890'], + ], + ], + ]); + + $response = $this->actingAs($user) + ->post(route('open-banking.map-accounts.store', $connection), [ + 'mappings' => [ + [ + 'bank_account_uid' => 'ext-1', + 'action' => 'link', + 'existing_account_id' => $existingAccount->id, + ], + ], + ]); + + $response->assertRedirect(route('settings.connections.index')); + + $existingAccount->refresh(); + expect($existingAccount->banking_connection_id)->toBe($connection->id); + expect($existingAccount->external_account_id)->toBe('ext-1'); + expect($existingAccount->linked_at)->not->toBeNull(); + + Queue::assertPushed(SyncBankingConnectionJob::class); +}); + +test('store with action skip does nothing', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $user->id, + 'pending_accounts_data' => [ + [ + 'uid' => 'ext-1', + 'currency' => 'EUR', + 'name' => 'Skipped Account', + 'account_id' => [], + ], + ], + ]); + + $response = $this->actingAs($user) + ->post(route('open-banking.map-accounts.store', $connection), [ + 'mappings' => [ + [ + 'bank_account_uid' => 'ext-1', + 'action' => 'skip', + 'existing_account_id' => null, + ], + ], + ]); + + $response->assertRedirect(route('settings.connections.index')); + + $this->assertDatabaseMissing('accounts', [ + 'banking_connection_id' => $connection->id, + ]); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); +}); + +test('store with mixed actions works correctly', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $bank = Bank::factory()->create(); + $existingAccount = Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'currency_code' => 'EUR', + 'banking_connection_id' => null, + ]); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Test Bank', + 'pending_accounts_data' => [ + [ + 'uid' => 'ext-1', + 'currency' => 'EUR', + 'name' => 'Account to Create', + 'account_id' => [], + ], + [ + 'uid' => 'ext-2', + 'currency' => 'EUR', + 'name' => 'Account to Link', + 'account_id' => [], + ], + [ + 'uid' => 'ext-3', + 'currency' => 'EUR', + 'name' => 'Account to Skip', + 'account_id' => [], + ], + ], + ]); + + $response = $this->actingAs($user) + ->post(route('open-banking.map-accounts.store', $connection), [ + 'mappings' => [ + [ + 'bank_account_uid' => 'ext-1', + 'action' => 'create', + 'existing_account_id' => null, + ], + [ + 'bank_account_uid' => 'ext-2', + 'action' => 'link', + 'existing_account_id' => $existingAccount->id, + ], + [ + 'bank_account_uid' => 'ext-3', + 'action' => 'skip', + 'existing_account_id' => null, + ], + ], + ]); + + $response->assertRedirect(route('settings.connections.index')); + + // Created account exists + $this->assertDatabaseHas('accounts', [ + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-1', + ]); + + // Linked account is updated + $existingAccount->refresh(); + expect($existingAccount->external_account_id)->toBe('ext-2'); + expect($existingAccount->linked_at)->not->toBeNull(); + + // Skipped account was not created + $this->assertDatabaseMissing('accounts', [ + 'external_account_id' => 'ext-3', + 'banking_connection_id' => $connection->id, + ]); +}); + +test('validation fails when linking without existing_account_id', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $user->id, + 'pending_accounts_data' => [ + [ + 'uid' => 'ext-1', + 'currency' => 'EUR', + 'name' => 'Test', + 'account_id' => [], + ], + ], + ]); + + $response = $this->actingAs($user) + ->post(route('open-banking.map-accounts.store', $connection), [ + 'mappings' => [ + [ + 'bank_account_uid' => 'ext-1', + 'action' => 'link', + 'existing_account_id' => null, + ], + ], + ]); + + $response->assertSessionHasErrors('mappings.0.existing_account_id'); +}); diff --git a/tests/Feature/OpenBanking/AuthorizationControllerTest.php b/tests/Feature/OpenBanking/AuthorizationControllerTest.php new file mode 100644 index 00000000..db7c53ac --- /dev/null +++ b/tests/Feature/OpenBanking/AuthorizationControllerTest.php @@ -0,0 +1,181 @@ + 'test-app-id', + 'services.enablebanking.private_key_path' => '/tmp/fake-key.pem', + 'services.enablebanking.redirect_url' => 'https://example.com/callback', + ]); +}); + +test('users can start bank authorization', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('startAuthorization') + ->once() + ->andReturn([ + 'url' => 'https://bank.example.com/authorize', + 'authorization_id' => 'auth-123', + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->postJson('/open-banking/authorize', [ + 'aspsp_name' => 'Test Bank', + 'country' => 'ES', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['redirect_url', 'connection_id']); + + $this->assertDatabaseHas('banking_connections', [ + 'user_id' => $user->id, + 'provider' => 'enablebanking', + 'aspsp_name' => 'Test Bank', + 'aspsp_country' => 'ES', + 'status' => BankingConnectionStatus::Pending->value, + ]); +}); + +test('authorization requires aspsp_name and country', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $response = $this->actingAs($user)->postJson('/open-banking/authorize', []); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['aspsp_name', 'country']); +}); + +test('callback with error redirects with error message', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $response = $this->actingAs($user) + ->get('/open-banking/callback?error=access_denied&error_description=User+denied+access'); + + $response->assertRedirect(route('settings.connections.index')); + $response->assertSessionHas('error'); +}); + +test('callback without code redirects with error', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $response = $this->actingAs($user)->get('/open-banking/callback'); + + $response->assertRedirect(route('settings.connections.index')); + $response->assertSessionHas('error'); +}); + +test('callback with valid code creates accounts directly when account-mapping is disabled', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Test Bank', + 'aspsp_country' => 'ES', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('createSession') + ->with('test-code') + ->once() + ->andReturn([ + 'session_id' => 'session-123', + 'accounts' => [ + [ + 'uid' => 'ext-account-1', + 'currency' => 'EUR', + 'name' => 'My Checking Account', + 'account_id' => ['iban' => 'ES1234567890123456789012'], + ], + ], + 'aspsp' => ['name' => 'Test Bank', 'country' => 'ES'], + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->get('/open-banking/callback?code=test-code'); + + $response->assertRedirect(route('settings.connections.index')); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->session_id)->toBe('session-123'); + + $this->assertDatabaseHas('accounts', [ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-account-1', + 'encrypted' => false, + ]); + + Queue::assertPushed(SyncBankingConnectionJob::class); +}); + +test('callback with valid code stores pending accounts and redirects to mapping when account-mapping is enabled', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + Feature::for($user)->activate('account-mapping'); + + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Test Bank', + 'aspsp_country' => 'ES', + ]); + + $accounts = [ + [ + 'uid' => 'ext-account-1', + 'currency' => 'EUR', + 'name' => 'My Checking Account', + 'account_id' => ['iban' => 'ES1234567890123456789012'], + ], + ]; + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('createSession') + ->with('test-code') + ->once() + ->andReturn([ + 'session_id' => 'session-123', + 'accounts' => $accounts, + 'aspsp' => ['name' => 'Test Bank', 'country' => 'ES'], + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->get('/open-banking/callback?code=test-code'); + + $response->assertRedirect(route('open-banking.map-accounts', $connection)); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->session_id)->toBe('session-123'); + expect($connection->pending_accounts_data)->toEqual($accounts); + + $this->assertDatabaseMissing('accounts', [ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + ]); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/OpenBanking/BalanceSyncServiceTest.php b/tests/Feature/OpenBanking/BalanceSyncServiceTest.php new file mode 100644 index 00000000..e5f77fcd --- /dev/null +++ b/tests/Feature/OpenBanking/BalanceSyncServiceTest.php @@ -0,0 +1,183 @@ +onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + // Reference balance: end of Feb 10, balance = 100000 (€1,000.00) + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => '2026-02-10', + 'balance' => 100000, + ]); + + // Transactions: Feb 10 had -5000 (debit), Feb 8 had +20000 (credit), Feb 5 had -10000 (debit) + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-10', + 'amount' => -5000, + ]); + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-08', + 'amount' => 20000, + ]); + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-05', + 'amount' => -10000, + ]); + + $service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class)); + $service->calculateHistoricalBalances($account); + + // End of Feb 10: 100000 (reference) + // End of Feb 8: 100000 - (-5000) = 105000 (before Feb 10 transactions) + // End of Feb 5: 105000 - 20000 = 85000 (before Feb 8 transactions) + expect($account->balances()->count())->toBe(3); + + $feb8 = $account->balances()->where('balance_date', '2026-02-08')->first(); + expect($feb8->balance)->toBe(105000); + + $feb5 = $account->balances()->where('balance_date', '2026-02-05')->first(); + expect($feb5->balance)->toBe(85000); +}); + +test('calculateHistoricalBalances handles multiple transactions per day', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => '2026-02-10', + 'balance' => 100000, + ]); + + // Two transactions on Feb 8: -3000 and -7000 = total -10000 + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-08', + 'amount' => -3000, + ]); + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-08', + 'amount' => -7000, + ]); + + $service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class)); + $service->calculateHistoricalBalances($account); + + // End of Feb 8: 100000 (no transactions between Feb 8 and Feb 10 on the reference date) + // Wait - there are no transactions on Feb 10, so running_balance stays 100000 + // End of Feb 8: 100000 + expect($account->balances()->count())->toBe(2); + + $feb8 = $account->balances()->where('balance_date', '2026-02-08')->first(); + expect($feb8->balance)->toBe(100000); +}); + +test('calculateHistoricalBalances skips dates with existing balances', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => '2026-02-10', + 'balance' => 100000, + ]); + + // Existing balance from balance_after_transaction (more accurate) + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => '2026-02-05', + 'balance' => 77777, + ]); + + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-08', + 'amount' => 20000, + ]); + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-05', + 'amount' => -10000, + ]); + + $service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class)); + $service->calculateHistoricalBalances($account); + + // Feb 8 should be calculated, Feb 5 should NOT be overwritten + expect($account->balances()->count())->toBe(3); + + $feb5 = $account->balances()->where('balance_date', '2026-02-05')->first(); + expect($feb5->balance)->toBe(77777); // Preserved original value +}); + +test('calculateHistoricalBalances does nothing without reference balance', function () { + $user = User::factory()->onboarded()->create(); + $account = Account::factory()->create([ + 'user_id' => $user->id, + ]); + + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-08', + 'amount' => -5000, + ]); + + $service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class)); + $service->calculateHistoricalBalances($account); + + expect($account->balances()->count())->toBe(0); +}); + +test('calculateHistoricalBalances does nothing without transactions', function () { + $user = User::factory()->onboarded()->create(); + $account = Account::factory()->create([ + 'user_id' => $user->id, + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => '2026-02-10', + 'balance' => 100000, + ]); + + $service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class)); + $service->calculateHistoricalBalances($account); + + expect($account->balances()->count())->toBe(1); +}); diff --git a/tests/Feature/OpenBanking/ConnectionControllerTest.php b/tests/Feature/OpenBanking/ConnectionControllerTest.php new file mode 100644 index 00000000..22b5ac99 --- /dev/null +++ b/tests/Feature/OpenBanking/ConnectionControllerTest.php @@ -0,0 +1,192 @@ + 'test-app-id', + 'services.enablebanking.private_key_path' => '/tmp/fake-key.pem', + 'services.enablebanking.redirect_url' => 'https://example.com/callback', + ]); +}); + +test('users can view their connections page', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + BankingConnection::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get('/settings/connections'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('settings/connections') + ->has('connections', 1) + ); +}); + +test('connections page only shows own connections', function () { + $user = User::factory()->onboarded()->create(); + $otherUser = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + BankingConnection::factory()->create(['user_id' => $user->id]); + BankingConnection::factory()->create(['user_id' => $otherUser->id]); + + $response = $this->actingAs($user)->get('/settings/connections'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->has('connections', 1) + ); +}); + +test('users can disconnect a banking connection and keep accounts as manual', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + $transaction = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + ]); + $balance = AccountBalance::factory()->create([ + 'account_id' => $account->id, + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('revokeSession')->once(); + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [ + 'delete_accounts' => false, + 'confirmation' => null, + ]); + + $response->assertRedirect(route('settings.connections.index')); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Revoked); + expect($connection->trashed())->toBeTrue(); + + $account->refresh(); + expect($account->banking_connection_id)->toBeNull(); + expect($account->external_account_id)->toBeNull(); + expect($account->trashed())->toBeFalse(); + + expect(Transaction::find($transaction->id))->not->toBeNull(); + expect(AccountBalance::find($balance->id))->not->toBeNull(); +}); + +test('users can disconnect a banking connection and delete accounts', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + $transaction = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + ]); + $balance = AccountBalance::factory()->create([ + 'account_id' => $account->id, + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('revokeSession')->once(); + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [ + 'delete_accounts' => true, + 'confirmation' => 'delete all', + ]); + + $response->assertRedirect(route('settings.connections.index')); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Revoked); + expect($connection->trashed())->toBeTrue(); + + expect(Account::withTrashed()->find($account->id)->trashed())->toBeTrue(); + expect(Transaction::withTrashed()->find($transaction->id)->trashed())->toBeTrue(); + expect(AccountBalance::find($balance->id))->toBeNull(); +}); + +test('deleting accounts requires confirmation text', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [ + 'delete_accounts' => true, + 'confirmation' => 'wrong text', + ]); + + $response->assertSessionHasErrors('confirmation'); + expect($connection->fresh()->trashed())->toBeFalse(); +}); + +test('users cannot disconnect another users connection', function () { + $user = User::factory()->onboarded()->create(); + $otherUser = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->create(['user_id' => $otherUser->id]); + + $response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [ + 'delete_accounts' => false, + ]); + + $response->assertForbidden(); +}); + +test('users can trigger manual sync on active connection', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'status' => BankingConnectionStatus::Active, + ]); + + $response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync"); + + $response->assertRedirect(); + $response->assertSessionHas('success'); +}); + +test('users cannot sync expired connection', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->expired()->create([ + 'user_id' => $user->id, + ]); + + $response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync"); + + $response->assertRedirect(); + $response->assertSessionHas('error'); +}); diff --git a/tests/Feature/OpenBanking/InstitutionControllerTest.php b/tests/Feature/OpenBanking/InstitutionControllerTest.php new file mode 100644 index 00000000..2a0c8936 --- /dev/null +++ b/tests/Feature/OpenBanking/InstitutionControllerTest.php @@ -0,0 +1,51 @@ +onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('getInstitutions') + ->with('ES') + ->once() + ->andReturn([ + ['name' => 'Test Bank', 'country' => 'ES', 'logo' => 'https://example.com/logo.png', 'maximum_consent_validity' => 90], + ['name' => 'Another Bank', 'country' => 'ES', 'logo' => null, 'maximum_consent_validity' => 180], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->getJson('/open-banking/institutions?country=ES'); + + $response->assertOk(); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'Test Bank']); +}); + +test('institutions endpoint requires country parameter', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $response = $this->actingAs($user)->getJson('/open-banking/institutions'); + + $response->assertUnprocessable(); +}); + +test('institutions endpoint requires valid country code length', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $response = $this->actingAs($user)->getJson('/open-banking/institutions?country=SPAIN'); + + $response->assertUnprocessable(); +}); + +test('guests cannot access institutions endpoint', function () { + $response = $this->getJson('/open-banking/institutions?country=ES'); + + $response->assertUnauthorized(); +}); diff --git a/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php b/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php new file mode 100644 index 00000000..39744f26 --- /dev/null +++ b/tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php @@ -0,0 +1,82 @@ +onboarded()->create(); + + Feature::for($user)->deactivate('open-banking'); + + $response = $this->actingAs($user)->get('/open-banking/institutions?country=ES'); + + $response->assertNotFound(); +}); + +test('users without open-banking feature get 404 on authorize', function () { + $user = User::factory()->onboarded()->create(); + + Feature::for($user)->deactivate('open-banking'); + + $response = $this->actingAs($user)->post('/open-banking/authorize', [ + 'aspsp_name' => 'Test Bank', + 'country' => 'ES', + ]); + + $response->assertNotFound(); +}); + +test('users without open-banking feature get 404 on callback', function () { + $user = User::factory()->onboarded()->create(); + + Feature::for($user)->deactivate('open-banking'); + + $response = $this->actingAs($user)->get('/open-banking/callback?code=test'); + + $response->assertNotFound(); +}); + +test('users without open-banking feature get 404 on connections index', function () { + $user = User::factory()->onboarded()->create(); + + Feature::for($user)->deactivate('open-banking'); + + $response = $this->actingAs($user)->get('/settings/connections'); + + $response->assertNotFound(); +}); + +test('open-banking feature flag is shared with frontend when enabled', function () { + $user = User::factory()->onboarded()->create(); + + Feature::for($user)->activate('open-banking'); + + $response = $this->actingAs($user)->get('/dashboard'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('features.open-banking', true) + ); +}); + +test('open-banking feature flag is shared with frontend when disabled', function () { + $user = User::factory()->onboarded()->create(); + + Feature::for($user)->deactivate('open-banking'); + + $response = $this->actingAs($user)->get('/dashboard'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('features.open-banking', false) + ); +}); + +test('guests see open-banking feature as false', function () { + $response = $this->get('/'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('features.open-banking', false) + ); +}); diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php new file mode 100644 index 00000000..683dc84d --- /dev/null +++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php @@ -0,0 +1,120 @@ +onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => null, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once(); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + $balanceSync->shouldReceive('calculateHistoricalBalances')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); +}); + +test('subsequent syncs do not calculate historical balances', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once(); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + $balanceSync->shouldNotReceive('calculateHistoricalBalances'); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); +}); + +test('linked accounts sync from last transaction date and skip historical balances', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => null, + ]); + $account = Account::factory()->linked()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'transaction_date' => '2025-12-15', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync') + ->once() + ->withArgs(function ($acct, $dateFrom, $dateTo, $strategy) { + return $dateFrom === '2025-12-15'; + }); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + $balanceSync->shouldNotReceive('calculateHistoricalBalances'); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); +}); + +test('mixed linked and new accounts in same connection', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => null, + ]); + + $newAccount = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-new', + ]); + + $linkedAccount = Account::factory()->linked()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-linked', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->twice(); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->twice(); + $balanceSync->shouldReceive('calculateHistoricalBalances') + ->once() + ->with(Mockery::on(fn ($acct) => $acct->id === $newAccount->id)); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); +}); diff --git a/tests/Feature/OpenBanking/TransactionSyncServiceTest.php b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php new file mode 100644 index 00000000..c4885af1 --- /dev/null +++ b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php @@ -0,0 +1,295 @@ +onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('getTransactions') + ->with('ext-123', '2025-01-01', '2025-01-31', null, null) + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-15', + 'remittance_information' => ['Grocery Store Purchase'], + ], + [ + 'transaction_id' => 'txn-002', + 'transaction_amount' => ['amount' => '1000.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'CRDT', + 'booking_date' => '2025-01-20', + 'remittance_information' => ['Salary Payment'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider); + $created = $service->sync($account, '2025-01-01', '2025-01-31'); + + expect($created)->toBe(2); + expect($account->transactions()->count())->toBe(2); + + $debit = $account->transactions()->where('external_transaction_id', 'txn-001')->first(); + expect($debit->amount)->toBe(-5000); + expect($debit->description)->toBe('Grocery Store Purchase'); + expect($debit->source)->toBe(TransactionSource::EnableBanking); + expect($debit->description_iv)->toBeNull(); + expect($debit->raw_data)->toEqual([ + 'booking_date' => '2025-01-15', + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'remittance_information' => ['Grocery Store Purchase'], + ]); + + $credit = $account->transactions()->where('external_transaction_id', 'txn-002')->first(); + expect($credit->amount)->toBe(100000); + expect($credit->description)->toBe('Salary Payment'); +}); + +test('sync deduplicates transactions by external_transaction_id', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + Transaction::factory()->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'external_transaction_id' => 'txn-001', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('getTransactions') + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-15', + 'remittance_information' => ['Duplicate Transaction'], + ], + [ + 'transaction_id' => 'txn-003', + 'transaction_amount' => ['amount' => '25.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-16', + 'remittance_information' => ['New Transaction'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider); + $created = $service->sync($account, '2025-01-01', '2025-01-31'); + + expect($created)->toBe(1); + expect($account->transactions()->count())->toBe(2); +}); + +test('sync handles pagination with continuation key', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + + $mockProvider->shouldReceive('getTransactions') + ->with('ext-123', '2025-01-01', '2025-01-31', null, null) + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '10.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-01', + 'remittance_information' => ['Page 1'], + ], + ], + 'continuation_key' => 'page2', + ]); + + $mockProvider->shouldReceive('getTransactions') + ->with('ext-123', '2025-01-01', '2025-01-31', 'page2', null) + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-002', + 'transaction_amount' => ['amount' => '20.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'CRDT', + 'booking_date' => '2025-01-02', + 'remittance_information' => ['Page 2'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider); + $created = $service->sync($account, '2025-01-01', '2025-01-31'); + + expect($created)->toBe(2); + expect($account->transactions()->count())->toBe(2); +}); + +test('sync uses creditor name as fallback description', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('getTransactions') + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '99.99', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-15', + 'remittance_information' => [], + 'creditor' => ['name' => 'Amazon EU'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider); + $service->sync($account, '2025-01-01', '2025-01-31'); + + $transaction = $account->transactions()->first(); + expect($transaction->description)->toBe('Amazon EU'); +}); + +test('sync creates daily balances from balance_after_transaction', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('getTransactions') + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-15', + 'remittance_information' => ['Morning purchase'], + 'balance_after_transaction' => ['amount' => '950.00', 'currency' => 'EUR'], + ], + [ + 'transaction_id' => 'txn-002', + 'transaction_amount' => ['amount' => '30.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-15', + 'remittance_information' => ['Evening purchase'], + 'balance_after_transaction' => ['amount' => '920.00', 'currency' => 'EUR'], + ], + [ + 'transaction_id' => 'txn-003', + 'transaction_amount' => ['amount' => '1000.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'CRDT', + 'booking_date' => '2025-01-20', + 'remittance_information' => ['Salary'], + 'balance_after_transaction' => ['amount' => '1920.00', 'currency' => 'EUR'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider); + $service->sync($account, '2025-01-01', '2025-01-31'); + + expect($account->balances()->count())->toBe(2); + + $jan15 = $account->balances()->where('balance_date', '2025-01-15')->first(); + expect($jan15->balance)->toBe(92000); // Last transaction of the day + + $jan20 = $account->balances()->where('balance_date', '2025-01-20')->first(); + expect($jan20->balance)->toBe(192000); +}); + +test('sync skips daily balance when balance_after_transaction is missing', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('getTransactions') + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2025-01-15', + 'remittance_information' => ['Purchase'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider); + $service->sync($account, '2025-01-01', '2025-01-31'); + + expect($account->balances()->count())->toBe(0); +}); + +test('sync skips accounts without external_account_id', function () { + $user = User::factory()->onboarded()->create(); + $account = Account::factory()->create([ + 'user_id' => $user->id, + 'external_account_id' => null, + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldNotReceive('getTransactions'); + + $service = new TransactionSyncService($mockProvider); + $created = $service->sync($account, '2025-01-01', '2025-01-31'); + + expect($created)->toBe(0); +});