whisper-money/tests/Feature/OpenBanking/AccountMappingTest.php

489 lines
15 KiB
PHP

<?php
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Account;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config([
'services.enablebanking.app_id' => 'test-app-id',
'services.enablebanking.private_key_path' => '/tmp/fake-key.pem',
'services.enablebanking.redirect_url' => 'https://example.com/callback',
]);
});
test('show returns mapping page with correct props', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
]);
$response = $this->actingAs($user)
->get(route('open-banking.map-accounts', $connection));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('open-banking/map-accounts')
->has('connection')
->has('bankAccounts')
->has('existingAccounts')
);
});
test('show redirects if no pending accounts', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'pending_accounts_data' => null,
]);
$response = $this->actingAs($user)
->get(route('open-banking.map-accounts', $connection));
$response->assertRedirect(route('settings.connections.index'));
});
test('show auto-creates pending accounts and updates currency during onboarding', function () {
Queue::fake();
$user = User::factory()->notOnboarded()->create(['currency_code' => 'USD']);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Euro Checking',
'account_id' => ['iban' => 'ES1234567890'],
],
],
]);
$this->actingAs($user)
->get(route('open-banking.map-accounts', $connection))
->assertRedirect(route('onboarding', ['step' => 'create-account']));
expect($user->refresh()->currency_code)->toBe('EUR');
$this->assertDatabaseHas('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-1',
'currency_code' => 'EUR',
]);
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('show returns 403 for other user\'s connection', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $otherUser->id,
]);
$response = $this->actingAs($user)
->get(route('open-banking.map-accounts', $connection));
$response->assertForbidden();
});
test('store with action create creates new accounts', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Test Checking',
'account_id' => ['iban' => 'ES1234567890'],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'create',
'existing_account_id' => null,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
$this->assertDatabaseHas('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-1',
'name' => 'Test Checking',
'currency_code' => 'EUR',
'iban' => 'ES1234567890',
]);
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->pending_accounts_data)->toBeNull();
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('store creates investment accounts for crypto provider connections', function (string $provider, string $providerName, string $uid) {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'provider' => $provider,
'aspsp_name' => $providerName,
'pending_accounts_data' => [
[
'uid' => $uid,
'currency' => 'EUR',
'name' => 'Crypto Portfolio',
'account_id' => [],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => $uid,
'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' => $uid,
'type' => 'investment',
]);
Queue::assertPushed(SyncBankingConnectionJob::class);
})->with([
'bitpanda' => ['bitpanda', 'Bitpanda', 'bitpanda-portfolio'],
'coinbase' => ['coinbase', 'Coinbase', 'coinbase-portfolio'],
]);
test('store updates user currency from first account created from mapping', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Euro Checking',
'account_id' => ['iban' => 'ES1234567890'],
],
],
]);
$this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'create',
'existing_account_id' => null,
],
],
])
->assertRedirect(route('settings.connections.index'));
expect($user->refresh()->currency_code)->toBe('EUR');
});
test('store does not update user currency from later mapped accounts', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$existingConnection = BankingConnection::factory()->create(['user_id' => $user->id]);
Account::factory()->create([
'user_id' => $user->id,
'currency_code' => 'EUR',
'banking_connection_id' => $existingConnection->id,
'external_account_id' => 'existing-ext',
]);
$user->update(['currency_code' => 'USD']);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Second Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-2',
'currency' => 'GBP',
'name' => 'Pound Checking',
'account_id' => ['iban' => 'GB1234567890'],
],
],
]);
$this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-2',
'action' => 'create',
'existing_account_id' => null,
],
],
])
->assertRedirect(route('settings.connections.index'));
expect($user->refresh()->currency_code)->toBe('USD');
});
test('store updates user currency when linking first account', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
$bank = Bank::factory()->create();
$existingAccount = Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'currency_code' => 'EUR',
'banking_connection_id' => null,
]);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Bank Account',
'account_id' => ['iban' => 'ES1234567890'],
],
],
]);
$this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'link',
'existing_account_id' => $existingAccount->id,
],
],
])
->assertRedirect(route('settings.connections.index'));
expect($user->refresh()->currency_code)->toBe('EUR');
});
test('store with action link links existing account', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create();
$existingAccount = Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'currency_code' => 'EUR',
'banking_connection_id' => null,
]);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Bank Account',
'account_id' => ['iban' => 'ES1234567890'],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'link',
'existing_account_id' => $existingAccount->id,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
$existingAccount->refresh();
expect($existingAccount->banking_connection_id)->toBe($connection->id);
expect($existingAccount->external_account_id)->toBe('ext-1');
expect($existingAccount->linked_at)->not->toBeNull();
expect($existingAccount->iban)->toBe('ES1234567890');
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('store with action skip does nothing', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Skipped Account',
'account_id' => [],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'skip',
'existing_account_id' => null,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
$this->assertDatabaseMissing('accounts', [
'banking_connection_id' => $connection->id,
]);
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
});
test('store with mixed actions works correctly', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create();
$existingAccount = Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'currency_code' => 'EUR',
'banking_connection_id' => null,
]);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Account to Create',
'account_id' => [],
],
[
'uid' => 'ext-2',
'currency' => 'EUR',
'name' => 'Account to Link',
'account_id' => [],
],
[
'uid' => 'ext-3',
'currency' => 'EUR',
'name' => 'Account to Skip',
'account_id' => [],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'create',
'existing_account_id' => null,
],
[
'bank_account_uid' => 'ext-2',
'action' => 'link',
'existing_account_id' => $existingAccount->id,
],
[
'bank_account_uid' => 'ext-3',
'action' => 'skip',
'existing_account_id' => null,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
// Created account exists
$this->assertDatabaseHas('accounts', [
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-1',
]);
// Linked account is updated
$existingAccount->refresh();
expect($existingAccount->external_account_id)->toBe('ext-2');
expect($existingAccount->linked_at)->not->toBeNull();
// Skipped account was not created
$this->assertDatabaseMissing('accounts', [
'external_account_id' => 'ext-3',
'banking_connection_id' => $connection->id,
]);
});
test('validation fails when linking without existing_account_id', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Test',
'account_id' => [],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'link',
'existing_account_id' => null,
],
],
]);
$response->assertSessionHasErrors('mappings.0.existing_account_id');
});