chore: Remove account-mapping feature flag (#252)

## 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
This commit is contained in:
Víctor Falcón 2026-04-01 11:09:22 +01:00 committed by GitHub
parent ca189a565b
commit c42a48a952
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 389 additions and 504 deletions

View File

@ -49,6 +49,6 @@ trait ResolvesFeatures
private function getStringBasedFeatures(): array
{
return ['open-banking', 'account-mapping', 'real-estate'];
return ['open-banking', 'real-estate'];
}
}

View File

@ -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,
]);
}
}

View File

@ -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,
]);
}
}
}

View File

@ -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,
]);
}

View File

@ -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,
]);
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\OpenBanking\Concerns;
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
trait CreatesAccountsFromPending
{
/**
* Auto-create all pending accounts from a banking connection without user interaction.
*
* This resolves the correct account type based on the provider (Investment for
* Indexa Capital, Binance, and Bitpanda; Checking for everything else) and clears the
* pending data once accounts have been created.
*/
private function createAccountsFromPending(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() || $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,
]);
}
}

View File

@ -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,
]);
}

View File

@ -19,7 +19,6 @@ class ActivateDevelopmentFeatures
if (app()->isLocal() && $request->user()) {
Feature::for($request->user())->activate([
'open-banking',
'account-mapping',
'real-estate',
]);
}

View File

@ -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)];

View File

@ -136,6 +136,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
}
$this->fail($e);
throw $e;
}
/**

View File

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

View File

@ -42,7 +42,6 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
'open-banking': boolean;
'account-mapping': boolean;
'real-estate': boolean;
}

View File

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

View File

@ -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');
});

View File

@ -1,6 +1,5 @@
<?php
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
@ -32,23 +31,19 @@ test('users can connect a binance account with valid credentials', function () {
$response->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);
});

View File

@ -1,6 +1,5 @@
<?php
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
@ -42,23 +41,19 @@ test('users can connect a bitpanda account with valid credentials', function ()
$response->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);
});

View File

@ -1,6 +1,5 @@
<?php
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
@ -39,21 +38,18 @@ test('users can connect an indexa capital account with valid token', function ()
$response->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);
});

View File

@ -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)

View File

@ -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);

View File

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