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); +});