From c42a48a952f2d1a763a36d98085ff84cd6114e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 1 Apr 2026 11:09:22 +0100 Subject: [PATCH] chore: Remove account-mapping feature flag (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Removes the `account-mapping` Pennant feature flag entirely, making the account mapping flow (pending accounts data + map-accounts page) the default and only code path - Removes the old direct-creation branches from all banking controllers (Authorization, Bitpanda, Binance, IndexaCapital) - Extracts a shared `CreatesAccountsFromPending` trait for the onboarding auto-create logic - Updates all controller tests to reflect the always-on mapping behavior ## Changes ### Backend - **AppServiceProvider** — removed `account-mapping` flag definition - **AuthorizationController** — removed flag check + `createAccountsFromSession()` dead code; always stores `pending_accounts_data` and redirects to mapping - **BitpandaController / BinanceController / IndexaCapitalController** — removed flag checks, old inline account creation, and unused imports - **HandleInertiaRequests / ActivateDevelopmentFeatures / ResolvesFeatures** — removed `account-mapping` from flag arrays - **New `CreatesAccountsFromPending` trait** — shared auto-create logic for onboarding path (to be consumed next) ### Frontend - Removed `'account-mapping'` from the TypeScript `Features` interface ### Tests - Merged flag-specific test variants into single always-on tests - Removed redundant tests that tested the old disabled-flag code path - Updated assertions to check `pending_accounts_data` instead of direct account creation --- .../Commands/Concerns/ResolvesFeatures.php | 2 +- .../OpenBanking/AccountMappingController.php | 58 +------- .../OpenBanking/AuthorizationController.php | 133 ++---------------- .../OpenBanking/BinanceController.php | 40 ++---- .../OpenBanking/BitpandaController.php | 40 ++---- .../Concerns/CreatesAccountsFromPending.php | 65 +++++++++ .../OpenBanking/IndexaCapitalController.php | 42 ++---- .../ActivateDevelopmentFeatures.php | 1 - app/Http/Middleware/HandleInertiaRequests.php | 2 +- app/Jobs/SyncBankingConnectionJob.php | 2 + app/Providers/AppServiceProvider.php | 1 - resources/js/types/index.d.ts | 1 - .../OpenBanking/AccountMappingTest.php | 45 ++++++ .../AuthorizationControllerTest.php | 66 +-------- .../OpenBanking/BinanceControllerTest.php | 107 +++++++------- .../OpenBanking/BitpandaControllerTest.php | 127 ++++++++--------- .../IndexaCapitalControllerTest.php | 107 +++++++------- .../SyncBankingConnectionJobTest.php | 18 ++- .../OpenBanking/SyncRetryAndLoggingTest.php | 6 +- .../SyncBankingConnectionsCommandTest.php | 30 ++++ 20 files changed, 389 insertions(+), 504 deletions(-) create mode 100644 app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php diff --git a/app/Console/Commands/Concerns/ResolvesFeatures.php b/app/Console/Commands/Concerns/ResolvesFeatures.php index ab94c744..c6c5b592 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 ['open-banking', 'account-mapping', 'real-estate']; + return ['open-banking', 'real-estate']; } } diff --git a/app/Http/Controllers/OpenBanking/AccountMappingController.php b/app/Http/Controllers/OpenBanking/AccountMappingController.php index 0907e800..312e8cb3 100644 --- a/app/Http/Controllers/OpenBanking/AccountMappingController.php +++ b/app/Http/Controllers/OpenBanking/AccountMappingController.php @@ -5,17 +5,19 @@ namespace App\Http\Controllers\OpenBanking; use App\Enums\AccountType; use App\Enums\BankingConnectionStatus; use App\Http\Controllers\Controller; +use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Requests\OpenBanking\MapAccountsRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; use App\Models\BankingConnection; -use App\Models\User; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; class AccountMappingController extends Controller { + use CreatesAccountsFromPending; + public function show(BankingConnection $connection): Response|RedirectResponse { if ($connection->user_id !== auth()->id()) { @@ -33,7 +35,7 @@ class AccountMappingController extends Controller // During onboarding, skip the mapping UI — auto-create all accounts directly if (! $user->isOnboarded()) { - $this->autoCreateAccounts($user, $connection); + $this->createAccountsFromPending($user, $connection); SyncBankingConnectionJob::dispatch($connection); return redirect()->route('onboarding', ['step' => 'create-account']) @@ -74,7 +76,7 @@ class AccountMappingController extends Controller $pendingAccounts = collect($connection->pending_accounts_data) ->keyBy('uid'); - $accountType = ($connection->isIndexaCapital() || $connection->isBinance()) + $accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda()) ? AccountType::Investment : AccountType::Checking; @@ -102,6 +104,7 @@ class AccountMappingController extends Controller 'type' => $accountType->value, 'banking_connection_id' => $connection->id, 'external_account_id' => $uid, + 'iban' => $accountData['account_id']['iban'] ?? null, ]); } elseif ($action === 'link') { $existingAccount = $user->accounts()->find($mapping['existing_account_id']); @@ -110,6 +113,7 @@ class AccountMappingController extends Controller $existingAccount->update([ 'banking_connection_id' => $connection->id, 'external_account_id' => $uid, + 'iban' => $accountData['account_id']['iban'] ?? $existingAccount->iban, 'bank_id' => $bank->id, 'linked_at' => now(), ]); @@ -130,52 +134,4 @@ class AccountMappingController extends Controller return redirect()->route($successRedirect, $redirectParams) ->with('success', 'Bank account connected successfully.'); } - - /** - * Auto-create all pending accounts without user interaction. - */ - private function autoCreateAccounts(User $user, BankingConnection $connection): void - { - $bank = Bank::firstOrCreate( - ['name' => $connection->aspsp_name, 'user_id' => null], - ['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo], - ); - - if (! $bank->logo && $connection->aspsp_logo) { - $bank->update(['logo' => $connection->aspsp_logo]); - } - - $accountType = ($connection->isIndexaCapital() || $connection->isBinance()) - ? AccountType::Investment - : AccountType::Checking; - - foreach ($connection->pending_accounts_data ?? [] as $accountData) { - $uid = $accountData['uid'] ?? null; - - if (! $uid) { - continue; - } - - $currency = $accountData['currency'] ?? 'EUR'; - $name = $accountData['name'] - ?? $accountData['account_id']['iban'] - ?? $connection->aspsp_name.' Account'; - - $user->accounts()->create([ - 'name' => $name, - 'name_iv' => null, - 'encrypted' => false, - 'bank_id' => $bank->id, - 'currency_code' => $currency, - 'type' => $accountType->value, - 'banking_connection_id' => $connection->id, - 'external_account_id' => $uid, - ]); - } - - $connection->update([ - 'status' => BankingConnectionStatus::Active, - 'pending_accounts_data' => null, - ]); - } } diff --git a/app/Http/Controllers/OpenBanking/AuthorizationController.php b/app/Http/Controllers/OpenBanking/AuthorizationController.php index b3e3e00e..585343b4 100644 --- a/app/Http/Controllers/OpenBanking/AuthorizationController.php +++ b/app/Http/Controllers/OpenBanking/AuthorizationController.php @@ -3,21 +3,21 @@ namespace App\Http\Controllers\OpenBanking; use App\Contracts\BankingProviderInterface; -use App\Enums\AccountType; use App\Enums\BankingConnectionStatus; use App\Http\Controllers\Controller; +use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Requests\OpenBanking\StartAuthorizationRequest; use App\Jobs\SyncBankingConnectionJob; -use App\Models\Bank; use App\Models\BankingConnection; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; -use Laravel\Pennant\Feature; class AuthorizationController extends Controller { + use CreatesAccountsFromPending; + /** * Start the bank authorization flow. */ @@ -159,40 +159,22 @@ class AuthorizationController extends Controller ->with('success', __('Bank account reconnected successfully.')); } - 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'], - ]); - - if (! $user->isOnboarded()) { - $this->createAccountsFromPending($user, $connection); - SyncBankingConnectionJob::dispatch($connection); - - return redirect()->route('onboarding', ['step' => 'create-account']) - ->with('success', 'Bank account connected successfully.'); - } - - return redirect()->route('open-banking.map-accounts', $connection); - } - $connection->update([ 'session_id' => $sessionData['session_id'], - 'status' => BankingConnectionStatus::Active, + 'status' => BankingConnectionStatus::AwaitingMapping, 'valid_until' => $sessionData['access']['valid_until'] ?? null, + 'pending_accounts_data' => $sessionData['accounts'], ]); - $this->createAccountsFromSession($user, $connection, $sessionData); + if (! $user->isOnboarded()) { + $this->createAccountsFromPending($user, $connection); + SyncBankingConnectionJob::dispatch($connection); - SyncBankingConnectionJob::dispatch($connection); + return redirect()->route('onboarding', ['step' => 'create-account']) + ->with('success', 'Bank account connected successfully.'); + } - $successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding'; - $redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account']; - - return redirect()->route($successRedirect, $redirectParams) - ->with('success', 'Bank account connected successfully.'); + return redirect()->route('open-banking.map-accounts', $connection); } /** @@ -249,95 +231,4 @@ class AuthorizationController extends Controller ]); } } - - /** - * Auto-create accounts from pending_accounts_data without user interaction. - */ - private function createAccountsFromPending($user, BankingConnection $connection): void - { - $bank = Bank::firstOrCreate( - ['name' => $connection->aspsp_name, 'user_id' => null], - ['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo], - ); - - if (! $bank->logo && $connection->aspsp_logo) { - $bank->update(['logo' => $connection->aspsp_logo]); - } - - foreach ($connection->pending_accounts_data ?? [] as $accountData) { - $uid = $accountData['uid'] ?? null; - - if (! $uid) { - continue; - } - - $currency = $accountData['currency'] ?? 'EUR'; - $name = $accountData['name'] - ?? $accountData['account_id']['iban'] - ?? $connection->aspsp_name.' Account'; - - $user->accounts()->create([ - 'name' => $name, - 'name_iv' => null, - 'encrypted' => false, - 'bank_id' => $bank->id, - 'currency_code' => $currency, - 'type' => AccountType::Checking->value, - 'banking_connection_id' => $connection->id, - 'external_account_id' => $uid, - 'iban' => $accountData['account_id']['iban'] ?? null, - ]); - } - } - - /** - * 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, - 'iban' => $accountData['account_id']['iban'] ?? null, - ]); - } - } } diff --git a/app/Http/Controllers/OpenBanking/BinanceController.php b/app/Http/Controllers/OpenBanking/BinanceController.php index e1c29161..ea073385 100644 --- a/app/Http/Controllers/OpenBanking/BinanceController.php +++ b/app/Http/Controllers/OpenBanking/BinanceController.php @@ -2,19 +2,20 @@ namespace App\Http\Controllers\OpenBanking; -use App\Enums\AccountType; use App\Enums\BankingConnectionStatus; use App\Http\Controllers\Controller; +use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Requests\OpenBanking\ConnectBinanceRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; use App\Services\Banking\BinanceClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; -use Laravel\Pennant\Feature; class BinanceController extends Controller { + use CreatesAccountsFromPending; + /** * Validate Binance API credentials and create a connection. */ @@ -58,38 +59,23 @@ class BinanceController extends Controller ], ]; - if (Feature::for($user)->active('account-mapping')) { - $connection->update([ - 'status' => BankingConnectionStatus::AwaitingMapping, - 'pending_accounts_data' => $pendingAccounts, - ]); + $connection->update([ + 'status' => BankingConnectionStatus::AwaitingMapping, + 'pending_accounts_data' => $pendingAccounts, + ]); + + if (! $user->isOnboarded()) { + $this->createAccountsFromPending($user, $connection); + SyncBankingConnectionJob::dispatch($connection); return response()->json([ - 'redirect_url' => route('open-banking.map-accounts', $connection), + 'redirect_url' => route('onboarding', ['step' => 'create-account']), 'connection_id' => $connection->id, ]); } - $connection->update(['status' => BankingConnectionStatus::Active]); - - $user->accounts()->create([ - 'name' => 'Crypto Portfolio', - 'name_iv' => null, - 'encrypted' => false, - 'bank_id' => $bank->id, - 'currency_code' => $user->currency_code, - 'type' => AccountType::Investment->value, - 'banking_connection_id' => $connection->id, - 'external_account_id' => 'binance-portfolio', - ]); - - SyncBankingConnectionJob::dispatch($connection); - - $successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding'; - $redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account']; - return response()->json([ - 'redirect_url' => route($successRedirect, $redirectParams), + 'redirect_url' => route('open-banking.map-accounts', $connection), 'connection_id' => $connection->id, ]); } diff --git a/app/Http/Controllers/OpenBanking/BitpandaController.php b/app/Http/Controllers/OpenBanking/BitpandaController.php index 037d8ad8..a54522e9 100644 --- a/app/Http/Controllers/OpenBanking/BitpandaController.php +++ b/app/Http/Controllers/OpenBanking/BitpandaController.php @@ -2,19 +2,20 @@ namespace App\Http\Controllers\OpenBanking; -use App\Enums\AccountType; use App\Enums\BankingConnectionStatus; use App\Http\Controllers\Controller; +use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Requests\OpenBanking\ConnectBitpandaRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; use App\Services\Banking\BitpandaClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; -use Laravel\Pennant\Feature; class BitpandaController extends Controller { + use CreatesAccountsFromPending; + /** * Validate Bitpanda API key and create a connection. */ @@ -57,38 +58,23 @@ class BitpandaController extends Controller ], ]; - if (Feature::for($user)->active('account-mapping')) { - $connection->update([ - 'status' => BankingConnectionStatus::AwaitingMapping, - 'pending_accounts_data' => $pendingAccounts, - ]); + $connection->update([ + 'status' => BankingConnectionStatus::AwaitingMapping, + 'pending_accounts_data' => $pendingAccounts, + ]); + + if (! $user->isOnboarded()) { + $this->createAccountsFromPending($user, $connection); + SyncBankingConnectionJob::dispatch($connection); return response()->json([ - 'redirect_url' => route('open-banking.map-accounts', $connection), + 'redirect_url' => route('onboarding', ['step' => 'create-account']), 'connection_id' => $connection->id, ]); } - $connection->update(['status' => BankingConnectionStatus::Active]); - - $user->accounts()->create([ - 'name' => 'Crypto Portfolio', - 'name_iv' => null, - 'encrypted' => false, - 'bank_id' => $bank->id, - 'currency_code' => $user->currency_code, - 'type' => AccountType::Investment->value, - 'banking_connection_id' => $connection->id, - 'external_account_id' => 'bitpanda-portfolio', - ]); - - SyncBankingConnectionJob::dispatch($connection); - - $successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding'; - $redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account']; - return response()->json([ - 'redirect_url' => route($successRedirect, $redirectParams), + 'redirect_url' => route('open-banking.map-accounts', $connection), 'connection_id' => $connection->id, ]); } diff --git a/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php b/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php new file mode 100644 index 00000000..3d4d340b --- /dev/null +++ b/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php @@ -0,0 +1,65 @@ + $connection->aspsp_name, 'user_id' => null], + ['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo], + ); + + if (! $bank->logo && $connection->aspsp_logo) { + $bank->update(['logo' => $connection->aspsp_logo]); + } + + $accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda()) + ? AccountType::Investment + : AccountType::Checking; + + foreach ($connection->pending_accounts_data ?? [] as $accountData) { + $uid = $accountData['uid'] ?? null; + + if (! $uid) { + continue; + } + + $currency = $accountData['currency'] ?? 'EUR'; + $name = $accountData['name'] + ?? $accountData['account_id']['iban'] + ?? $connection->aspsp_name.' Account'; + + $user->accounts()->create([ + 'name' => $name, + 'name_iv' => null, + 'encrypted' => false, + 'bank_id' => $bank->id, + 'currency_code' => $currency, + 'type' => $accountType->value, + 'banking_connection_id' => $connection->id, + 'external_account_id' => $uid, + 'iban' => $accountData['account_id']['iban'] ?? null, + ]); + } + + $connection->update([ + 'status' => BankingConnectionStatus::Active, + 'pending_accounts_data' => null, + ]); + } +} diff --git a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php index 356f8aa1..94fae7e5 100644 --- a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php +++ b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php @@ -2,19 +2,20 @@ namespace App\Http\Controllers\OpenBanking; -use App\Enums\AccountType; use App\Enums\BankingConnectionStatus; use App\Http\Controllers\Controller; +use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Requests\OpenBanking\ConnectIndexaCapitalRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; use App\Services\Banking\IndexaCapitalClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; -use Laravel\Pennant\Feature; class IndexaCapitalController extends Controller { + use CreatesAccountsFromPending; + /** * Validate the Indexa Capital API token and create a connection. */ @@ -51,40 +52,23 @@ class IndexaCapitalController extends Controller $pendingAccounts = $this->buildPendingAccounts($userData); - if (Feature::for($user)->active('account-mapping')) { - $connection->update([ - 'status' => BankingConnectionStatus::AwaitingMapping, - 'pending_accounts_data' => $pendingAccounts, - ]); + $connection->update([ + 'status' => BankingConnectionStatus::AwaitingMapping, + 'pending_accounts_data' => $pendingAccounts, + ]); + + if (! $user->isOnboarded()) { + $this->createAccountsFromPending($user, $connection); + SyncBankingConnectionJob::dispatch($connection); return response()->json([ - 'redirect_url' => route('open-banking.map-accounts', $connection), + 'redirect_url' => route('onboarding', ['step' => 'create-account']), 'connection_id' => $connection->id, ]); } - $connection->update(['status' => BankingConnectionStatus::Active]); - - foreach ($pendingAccounts as $accountData) { - $user->accounts()->create([ - 'name' => $accountData['name'], - 'name_iv' => null, - 'encrypted' => false, - 'bank_id' => $bank->id, - 'currency_code' => $accountData['currency'], - 'type' => AccountType::Investment->value, - 'banking_connection_id' => $connection->id, - 'external_account_id' => $accountData['uid'], - ]); - } - - SyncBankingConnectionJob::dispatch($connection); - - $successRedirect = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding'; - $redirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account']; - return response()->json([ - 'redirect_url' => route($successRedirect, $redirectParams), + 'redirect_url' => route('open-banking.map-accounts', $connection), 'connection_id' => $connection->id, ]); } diff --git a/app/Http/Middleware/ActivateDevelopmentFeatures.php b/app/Http/Middleware/ActivateDevelopmentFeatures.php index 97690cf7..45c361c8 100644 --- a/app/Http/Middleware/ActivateDevelopmentFeatures.php +++ b/app/Http/Middleware/ActivateDevelopmentFeatures.php @@ -19,7 +19,6 @@ class ActivateDevelopmentFeatures if (app()->isLocal() && $request->user()) { Feature::for($request->user())->activate([ 'open-banking', - 'account-mapping', 'real-estate', ]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0e0b572b..a6a3c532 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -127,7 +127,7 @@ class HandleInertiaRequests extends Middleware */ protected function resolveFeatureFlags(?object $user): array { - $flags = ['open-banking', 'account-mapping', 'real-estate']; + $flags = ['open-banking', 'real-estate']; if (! $user) { return ['cashflow' => true, ...array_fill_keys($flags, false)]; diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index 9695adb6..329da040 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -136,6 +136,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } $this->fail($e); + + throw $e; } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3488e137..bbd085a5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -51,7 +51,6 @@ class AppServiceProvider extends ServiceProvider }); Feature::define('open-banking', fn (User $user) => false); - Feature::define('account-mapping', fn (User $user) => false); Feature::define('real-estate', fn (User $user) => false); } } diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 176ada8b..554e25a0 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -42,7 +42,6 @@ export interface NavDivider { export interface Features { cashflow: boolean; 'open-banking': boolean; - 'account-mapping': boolean; 'real-estate': boolean; } diff --git a/tests/Feature/OpenBanking/AccountMappingTest.php b/tests/Feature/OpenBanking/AccountMappingTest.php index f6d47b40..c00d0d26 100644 --- a/tests/Feature/OpenBanking/AccountMappingTest.php +++ b/tests/Feature/OpenBanking/AccountMappingTest.php @@ -105,6 +105,7 @@ test('store with action create creates new accounts', function () { 'external_account_id' => 'ext-1', 'name' => 'Test Checking', 'currency_code' => 'EUR', + 'iban' => 'ES1234567890', ]); $connection->refresh(); @@ -114,6 +115,49 @@ test('store with action create creates new accounts', function () { Queue::assertPushed(SyncBankingConnectionJob::class); }); +test('store creates investment accounts for bitpanda connections', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->awaitingMapping()->create([ + 'user_id' => $user->id, + 'provider' => 'bitpanda', + 'aspsp_name' => 'Bitpanda', + 'pending_accounts_data' => [ + [ + 'uid' => 'bitpanda-portfolio', + 'currency' => 'EUR', + 'name' => 'Crypto Portfolio', + 'account_id' => [], + ], + ], + ]); + + $response = $this->actingAs($user) + ->post(route('open-banking.map-accounts.store', $connection), [ + 'mappings' => [ + [ + 'bank_account_uid' => 'bitpanda-portfolio', + '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' => 'bitpanda-portfolio', + 'type' => 'investment', + ]); + + Queue::assertPushed(SyncBankingConnectionJob::class); +}); + test('store with action link links existing account', function () { Queue::fake(); @@ -158,6 +202,7 @@ test('store with action link links existing account', function () { expect($existingAccount->banking_connection_id)->toBe($connection->id); expect($existingAccount->external_account_id)->toBe('ext-1'); expect($existingAccount->linked_at)->not->toBeNull(); + expect($existingAccount->iban)->toBe('ES1234567890'); Queue::assertPushed(SyncBankingConnectionJob::class); }); diff --git a/tests/Feature/OpenBanking/AuthorizationControllerTest.php b/tests/Feature/OpenBanking/AuthorizationControllerTest.php index 84029f1c..22d805a2 100644 --- a/tests/Feature/OpenBanking/AuthorizationControllerTest.php +++ b/tests/Feature/OpenBanking/AuthorizationControllerTest.php @@ -136,7 +136,7 @@ test('callback without code redirects with error', function () { $response->assertSessionHas('error'); }); -test('callback with valid code creates accounts directly when account-mapping is disabled', function () { +test('callback with valid code stores pending accounts and redirects to mapping', function () { Queue::fake(); $user = User::factory()->onboarded()->create(); @@ -148,57 +148,6 @@ test('callback with valid code creates accounts directly when account-mapping is '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', @@ -408,12 +357,11 @@ test('callback with existing accounts updates session without creating new accou Queue::assertPushed(SyncBankingConnectionJob::class); }); -test('callback with existing accounts skips account-mapping even when feature flag is enabled', function () { +test('callback with existing accounts skips mapping on reconnect', 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, @@ -618,7 +566,7 @@ test('reconnect callback uses positional fallback for accounts without stored ib expect($accountB->refresh()->iban)->toBe('ES0000000000000000000002'); }); -test('callback stores iban when creating accounts for the first time', function () { +test('callback stores iban in pending accounts data', function () { Queue::fake(); $user = User::factory()->onboarded()->create(); @@ -651,9 +599,7 @@ test('callback stores iban when creating accounts for the first time', function $this->actingAs($user)->get('/open-banking/callback?code=test-code'); - $this->assertDatabaseHas('accounts', [ - 'user_id' => $user->id, - 'external_account_id' => 'ext-account-1', - 'iban' => 'ES9999999999999999999999', - ]); + $connection->refresh(); + expect($connection->pending_accounts_data)->toHaveCount(1); + expect($connection->pending_accounts_data[0]['account_id']['iban'])->toBe('ES9999999999999999999999'); }); diff --git a/tests/Feature/OpenBanking/BinanceControllerTest.php b/tests/Feature/OpenBanking/BinanceControllerTest.php index d5e75ac9..166325c0 100644 --- a/tests/Feature/OpenBanking/BinanceControllerTest.php +++ b/tests/Feature/OpenBanking/BinanceControllerTest.php @@ -1,6 +1,5 @@ assertOk(); $response->assertJsonStructure(['redirect_url', 'connection_id']); - $this->assertDatabaseHas('banking_connections', [ + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'binance')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->pending_accounts_data)->toHaveCount(1); + expect($connection->pending_accounts_data[0]['uid'])->toBe('binance-portfolio'); + expect($connection->pending_accounts_data[0]['name'])->toBe('Crypto Portfolio'); + + $this->assertDatabaseMissing('accounts', [ 'user_id' => $user->id, - 'provider' => 'binance', - 'aspsp_name' => 'Binance', - 'aspsp_country' => 'ES', - 'status' => BankingConnectionStatus::Active->value, + 'banking_connection_id' => $connection->id, ]); - $this->assertDatabaseHas('accounts', [ - 'user_id' => $user->id, - 'external_account_id' => 'binance-portfolio', - 'type' => AccountType::Investment->value, - 'currency_code' => 'EUR', - 'name' => 'Crypto Portfolio', - ]); - - Queue::assertPushed(SyncBankingConnectionJob::class); + Queue::assertNothingPushed(); }); test('invalid binance credentials return 422', function () { @@ -74,44 +69,6 @@ test('invalid binance credentials return 422', function () { ]); }); -test('binance connection with account-mapping flag returns mapping redirect', function () { - Queue::fake(); - - $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); - Feature::for($user)->activate('open-banking'); - Feature::for($user)->activate('account-mapping'); - - Http::fake([ - 'api.binance.com/api/v3/account*' => Http::response([ - 'balances' => [ - ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'], - ], - ]), - ]); - - $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [ - 'api_key' => 'valid-test-api-key-12345', - 'api_secret' => 'valid-test-api-secret-12345', - 'country' => 'ES', - ]); - - $response->assertOk(); - - $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'binance')->first(); - - expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); - expect($connection->pending_accounts_data)->toHaveCount(1); - expect($connection->pending_accounts_data[0]['uid'])->toBe('binance-portfolio'); - expect($connection->pending_accounts_data[0]['name'])->toBe('Crypto Portfolio'); - - $this->assertDatabaseMissing('accounts', [ - 'user_id' => $user->id, - 'banking_connection_id' => $connection->id, - ]); - - Queue::assertNothingPushed(); -}); - test('binance requires open-banking feature flag', function () { $user = User::factory()->onboarded()->create(); @@ -141,7 +98,7 @@ test('binance api_key and api_secret are required and must be at least 10 charac ->assertJsonValidationErrors(['api_key', 'api_secret']); }); -test('binance creates single crypto portfolio account with user currency', function () { +test('binance stores pending accounts with user currency', function () { Queue::fake(); $user = User::factory()->onboarded()->create(['currency_code' => 'USD']); @@ -163,13 +120,47 @@ test('binance creates single crypto portfolio account with user currency', funct $response->assertOk(); - expect($user->accounts()->count())->toBe(1); + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'binance')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->pending_accounts_data)->toHaveCount(1); + expect($connection->pending_accounts_data[0]['currency'])->toBe('USD'); +}); + +test('binance auto-creates accounts during onboarding', function () { + Queue::fake(); + + $user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']); + Feature::for($user)->activate('open-banking'); + + Http::fake([ + 'api.binance.com/api/v3/account*' => Http::response([ + 'balances' => [ + ['asset' => 'BTC', 'free' => '0.5', 'locked' => '0.0'], + ], + ]), + ]); + + $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [ + 'api_key' => 'valid-test-api-key-12345', + 'api_secret' => 'valid-test-api-secret-12345', + 'country' => 'ES', + ]); + + $response->assertOk(); + $response->assertJsonPath('redirect_url', route('onboarding', ['step' => 'create-account'])); + + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'binance')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->pending_accounts_data)->toBeNull(); $this->assertDatabaseHas('accounts', [ 'user_id' => $user->id, - 'name' => 'Crypto Portfolio', - 'currency_code' => 'USD', - 'type' => AccountType::Investment->value, + 'banking_connection_id' => $connection->id, 'external_account_id' => 'binance-portfolio', + 'type' => 'investment', ]); + + Queue::assertPushed(SyncBankingConnectionJob::class); }); diff --git a/tests/Feature/OpenBanking/BitpandaControllerTest.php b/tests/Feature/OpenBanking/BitpandaControllerTest.php index ec1e8a41..e2c960f5 100644 --- a/tests/Feature/OpenBanking/BitpandaControllerTest.php +++ b/tests/Feature/OpenBanking/BitpandaControllerTest.php @@ -1,6 +1,5 @@ assertOk(); $response->assertJsonStructure(['redirect_url', 'connection_id']); - $this->assertDatabaseHas('banking_connections', [ + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'bitpanda')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->pending_accounts_data)->toHaveCount(1); + expect($connection->pending_accounts_data[0]['uid'])->toBe('bitpanda-portfolio'); + expect($connection->pending_accounts_data[0]['name'])->toBe('Crypto Portfolio'); + + $this->assertDatabaseMissing('accounts', [ 'user_id' => $user->id, - 'provider' => 'bitpanda', - 'aspsp_name' => 'Bitpanda', - 'aspsp_country' => 'ES', - 'status' => BankingConnectionStatus::Active->value, + 'banking_connection_id' => $connection->id, ]); - $this->assertDatabaseHas('accounts', [ - 'user_id' => $user->id, - 'external_account_id' => 'bitpanda-portfolio', - 'type' => AccountType::Investment->value, - 'currency_code' => 'EUR', - 'name' => 'Crypto Portfolio', - ]); - - Queue::assertPushed(SyncBankingConnectionJob::class); + Queue::assertNothingPushed(); }); test('invalid bitpanda credentials return 422', function () { @@ -83,54 +78,6 @@ test('invalid bitpanda credentials return 422', function () { ]); }); -test('bitpanda connection with account-mapping flag returns mapping redirect', function () { - Queue::fake(); - - $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); - Feature::for($user)->activate('open-banking'); - Feature::for($user)->activate('account-mapping'); - - Http::fake([ - 'api.bitpanda.com/v1/wallets' => Http::response([ - 'data' => [ - [ - 'type' => 'wallet', - 'attributes' => [ - 'cryptocoin_id' => '1', - 'cryptocoin_symbol' => 'BTC', - 'balance' => '1.00000000', - 'is_default' => true, - 'name' => 'BTC wallet', - 'deleted' => false, - ], - 'id' => 'wallet-uuid-1', - ], - ], - ]), - ]); - - $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [ - 'api_key' => 'valid-test-api-key-12345', - 'country' => 'ES', - ]); - - $response->assertOk(); - - $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'bitpanda')->first(); - - expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); - expect($connection->pending_accounts_data)->toHaveCount(1); - expect($connection->pending_accounts_data[0]['uid'])->toBe('bitpanda-portfolio'); - expect($connection->pending_accounts_data[0]['name'])->toBe('Crypto Portfolio'); - - $this->assertDatabaseMissing('accounts', [ - 'user_id' => $user->id, - 'banking_connection_id' => $connection->id, - ]); - - Queue::assertNothingPushed(); -}); - test('bitpanda requires open-banking feature flag', function () { $user = User::factory()->onboarded()->create(); @@ -158,7 +105,7 @@ test('bitpanda api_key is required and must be at least 10 characters', function ->assertJsonValidationErrors(['api_key']); }); -test('bitpanda creates single crypto portfolio account with user currency', function () { +test('bitpanda stores pending accounts with user currency', function () { Queue::fake(); $user = User::factory()->onboarded()->create(['currency_code' => 'USD']); @@ -190,13 +137,57 @@ test('bitpanda creates single crypto portfolio account with user currency', func $response->assertOk(); - expect($user->accounts()->count())->toBe(1); + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'bitpanda')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->pending_accounts_data)->toHaveCount(1); + expect($connection->pending_accounts_data[0]['currency'])->toBe('USD'); +}); + +test('bitpanda auto-creates accounts during onboarding', function () { + Queue::fake(); + + $user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']); + Feature::for($user)->activate('open-banking'); + + Http::fake([ + 'api.bitpanda.com/v1/wallets' => Http::response([ + 'data' => [ + [ + 'type' => 'wallet', + 'attributes' => [ + 'cryptocoin_id' => '1', + 'cryptocoin_symbol' => 'BTC', + 'balance' => '0.50000000', + 'is_default' => true, + 'name' => 'BTC wallet', + 'deleted' => false, + ], + 'id' => 'wallet-uuid-1', + ], + ], + ]), + ]); + + $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [ + 'api_key' => 'valid-test-api-key-12345', + 'country' => 'ES', + ]); + + $response->assertOk(); + $response->assertJsonPath('redirect_url', route('onboarding', ['step' => 'create-account'])); + + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'bitpanda')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->pending_accounts_data)->toBeNull(); $this->assertDatabaseHas('accounts', [ 'user_id' => $user->id, - 'name' => 'Crypto Portfolio', - 'currency_code' => 'USD', - 'type' => AccountType::Investment->value, + 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', + 'type' => 'investment', ]); + + Queue::assertPushed(SyncBankingConnectionJob::class); }); diff --git a/tests/Feature/OpenBanking/IndexaCapitalControllerTest.php b/tests/Feature/OpenBanking/IndexaCapitalControllerTest.php index 1e826c1e..2b9e777c 100644 --- a/tests/Feature/OpenBanking/IndexaCapitalControllerTest.php +++ b/tests/Feature/OpenBanking/IndexaCapitalControllerTest.php @@ -1,6 +1,5 @@ assertOk(); $response->assertJsonStructure(['redirect_url', 'connection_id']); - $this->assertDatabaseHas('banking_connections', [ + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'indexacapital')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->pending_accounts_data)->toHaveCount(1); + expect($connection->pending_accounts_data[0]['uid'])->toBe('IC-001'); + + $this->assertDatabaseMissing('accounts', [ 'user_id' => $user->id, - 'provider' => 'indexacapital', - 'aspsp_name' => 'Indexa Capital', - 'aspsp_country' => 'ES', - 'status' => BankingConnectionStatus::Active->value, + 'banking_connection_id' => $connection->id, ]); - $this->assertDatabaseHas('accounts', [ - 'user_id' => $user->id, - 'external_account_id' => 'IC-001', - 'type' => AccountType::Investment->value, - ]); - - Queue::assertPushed(SyncBankingConnectionJob::class); + Queue::assertNothingPushed(); }); test('invalid token returns 422', function () { @@ -77,43 +73,6 @@ test('invalid token returns 422', function () { ]); }); -test('connection with account-mapping flag returns mapping redirect', function () { - Queue::fake(); - - $user = User::factory()->onboarded()->create(); - Feature::for($user)->activate('open-banking'); - Feature::for($user)->activate('account-mapping'); - - Http::fake([ - 'api.indexacapital.com/users/me' => Http::response([ - 'accounts' => [ - ['account_number' => 'IC-001', 'status' => 'active', 'type' => 'mutual'], - ['account_number' => 'IC-002', 'status' => 'active', 'type' => 'pension'], - ], - ]), - ]); - - $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [ - 'api_token' => 'valid-test-token-12345', - ]); - - $response->assertOk(); - - $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'indexacapital')->first(); - - expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); - expect($connection->pending_accounts_data)->toHaveCount(2); - expect($connection->pending_accounts_data[0]['uid'])->toBe('IC-001'); - expect($connection->pending_accounts_data[1]['uid'])->toBe('IC-002'); - - $this->assertDatabaseMissing('accounts', [ - 'user_id' => $user->id, - 'banking_connection_id' => $connection->id, - ]); - - Queue::assertNothingPushed(); -}); - test('requires open-banking feature flag', function () { $user = User::factory()->onboarded()->create(); @@ -139,7 +98,7 @@ test('api_token is required and must be at least 10 characters', function () { ->assertJsonValidationErrors(['api_token']); }); -test('creates multiple accounts for multiple indexa portfolios', function () { +test('stores multiple pending accounts for multiple indexa portfolios', function () { Queue::fake(); $user = User::factory()->onboarded()->create(); @@ -160,16 +119,56 @@ test('creates multiple accounts for multiple indexa portfolios', function () { $response->assertOk(); - expect($user->accounts()->where('type', AccountType::Investment->value)->count())->toBe(2); + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'indexacapital')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + expect($connection->pending_accounts_data)->toHaveCount(2); + expect($connection->pending_accounts_data[0]['uid'])->toBe('IC-001'); + expect($connection->pending_accounts_data[0]['name'])->toBe('Investment Portfolio (IC-001)'); + expect($connection->pending_accounts_data[1]['uid'])->toBe('IC-002'); + expect($connection->pending_accounts_data[1]['name'])->toBe('Pension Plan (IC-002)'); +}); + +test('indexa capital auto-creates accounts during onboarding', function () { + Queue::fake(); + + $user = User::factory()->notOnboarded()->create(); + Feature::for($user)->activate('open-banking'); + + Http::fake([ + 'api.indexacapital.com/users/me' => Http::response([ + 'accounts' => [ + ['account_number' => 'IC-001', 'status' => 'active', 'type' => 'mutual'], + ['account_number' => 'IC-002', 'status' => 'active', 'type' => 'pension'], + ], + ]), + ]); + + $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [ + 'api_token' => 'valid-test-token-12345', + ]); + + $response->assertOk(); + $response->assertJsonPath('redirect_url', route('onboarding', ['step' => 'create-account'])); + + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'indexacapital')->first(); + + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->pending_accounts_data)->toBeNull(); $this->assertDatabaseHas('accounts', [ 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, 'external_account_id' => 'IC-001', - 'name' => 'Investment Portfolio (IC-001)', + 'type' => 'investment', ]); + $this->assertDatabaseHas('accounts', [ 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, 'external_account_id' => 'IC-002', - 'name' => 'Pension Plan (IC-002)', + 'type' => 'investment', ]); + + Queue::assertPushed(SyncBankingConnectionJob::class); }); diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php index 7eaef6f4..faee6ba2 100644 --- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php +++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php @@ -656,7 +656,11 @@ test('sends auth failed email immediately for indexa capital 401 error', functio $mockQueueJob->shouldReceive('fail')->once(); $job->job = $mockQueueJob; - $job->handle($transactionSync, $balanceSync); + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected for auth failures after manually failing the job. + } $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Error); @@ -696,7 +700,11 @@ test('auth error sends email and fails job on any attempt', function () { $job->job->shouldReceive('hasFailed')->andReturn(false); $job->job->shouldReceive('fail')->once(); - $job->handle($transactionSync, $balanceSync); + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected for auth failures after manually failing the job. + } $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Error); @@ -810,7 +818,11 @@ test('sends auth failed email immediately for binance 403 error', function () { $job->job->shouldReceive('hasFailed')->andReturn(false); $job->job->shouldReceive('fail')->once(); - $job->handle($transactionSync, $balanceSync); + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected for auth failures after manually failing the job. + } Mail::assertQueued(BankingConnectionAuthFailedEmail::class, function ($mail) use ($user, $connection) { return $mail->hasTo($user->email) diff --git a/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php index ac2be600..327a2ef6 100644 --- a/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php +++ b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php @@ -226,7 +226,11 @@ test('auth error sets consecutive failures beyond the cap', function () { $job->job->shouldReceive('hasFailed')->andReturn(false); $job->job->shouldReceive('fail')->once(); - $job->handle($transactionSync, $balanceSync); + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected for auth failures after manually failing the job. + } $connection->refresh(); expect($connection->consecutive_sync_failures)->toBe(SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1); diff --git a/tests/Feature/SyncBankingConnectionsCommandTest.php b/tests/Feature/SyncBankingConnectionsCommandTest.php index fd422a85..33e2c8b4 100644 --- a/tests/Feature/SyncBankingConnectionsCommandTest.php +++ b/tests/Feature/SyncBankingConnectionsCommandTest.php @@ -4,6 +4,8 @@ use App\Jobs\SyncAllBankingConnectionsJob; use App\Jobs\SyncBankingConnectionJob; use App\Models\BankingConnection; use App\Models\User; +use Illuminate\Http\Client\RequestException; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use function Pest\Laravel\artisan; @@ -140,3 +142,31 @@ test('banking:sync does not set fullSync by default', function () { return $job->fullSync === false; }); }); + +test('banking:sync --sync fails for auth errors instead of reporting success', function () { + $user = User::factory()->create(['email' => 'test@example.com']); + $connection = BankingConnection::factory()->indexaCapital()->for($user)->create([ + 'last_synced_at' => now()->subDay(), + ]); + + $user->accounts()->create([ + 'name' => 'Indexa Capital Account', + 'name_iv' => null, + 'encrypted' => false, + 'bank_id' => null, + 'currency_code' => 'EUR', + 'type' => 'investment', + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'IC-001', + ]); + + Http::fake([ + 'api.indexacapital.com/*' => Http::response(['error' => 'Unauthorized'], 401), + ]); + + expect(fn () => artisan('banking:sync', [ + '--user' => 'test@example.com', + '--connection' => $connection->id, + '--sync' => true, + ]))->toThrow(RequestException::class); +});