From 0d4c68361afc114615323418e2cfcf380e5ac13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 25 May 2026 20:55:24 +0200 Subject: [PATCH] fix(accounts): sync currency from first account (#430) ## Summary - sync user currency from the first account, manual or connected - reuse one service across manual creation and open banking mapping/auto-create flows - keep later accounts from overwriting user currency - add browser signup + onboarding account creation coverage ## Prod check - users with any account: 210 - first account currency mismatches user currency: 64 - USD user + EUR first account: 36 - first account manual users: 189 - manual-first mismatches: 61 ## Tests - vendor/bin/pint --dirty --format agent - php artisan test --compact tests/Feature/OpenBanking/AccountMappingTest.php tests/Feature/OpenBanking/AuthorizationControllerTest.php tests/Feature/OpenBanking/IndexaCapitalControllerTest.php tests/Feature/OpenBanking/BinanceControllerTest.php tests/Feature/OpenBanking/BitpandaControllerTest.php tests/Feature/OpenBanking/CoinbaseControllerTest.php tests/Feature/Settings/AccountTest.php - php artisan test --compact tests/Browser/OnboardingFlowTest.php --filter='syncs user currency from first onboarding account after signup' --- .../OpenBanking/AccountMappingController.php | 13 +- .../OpenBanking/AuthorizationController.php | 5 +- .../OpenBanking/BinanceController.php | 5 +- .../OpenBanking/BitpandaController.php | 5 +- .../OpenBanking/CoinbaseController.php | 5 +- .../Concerns/CreatesAccountsFromPending.php | 7 +- .../OpenBanking/IndexaCapitalController.php | 5 +- .../Settings/AccountController.php | 8 +- app/Services/AccountUserCurrencyService.php | 35 +++++ tests/Browser/OnboardingFlowTest.php | 64 ++++++++ .../OpenBanking/AccountMappingTest.php | 147 ++++++++++++++++++ tests/Feature/Settings/AccountTest.php | 15 ++ 12 files changed, 293 insertions(+), 21 deletions(-) create mode 100644 app/Services/AccountUserCurrencyService.php diff --git a/app/Http/Controllers/OpenBanking/AccountMappingController.php b/app/Http/Controllers/OpenBanking/AccountMappingController.php index d7aff07b..1901336d 100644 --- a/app/Http/Controllers/OpenBanking/AccountMappingController.php +++ b/app/Http/Controllers/OpenBanking/AccountMappingController.php @@ -10,6 +10,7 @@ use App\Http\Requests\OpenBanking\MapAccountsRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; use App\Models\BankingConnection; +use App\Services\AccountUserCurrencyService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -18,7 +19,7 @@ class AccountMappingController extends Controller { use CreatesAccountsFromPending; - public function show(BankingConnection $connection): Response|RedirectResponse + public function show(BankingConnection $connection, AccountUserCurrencyService $accountUserCurrencyService): Response|RedirectResponse { if ($connection->user_id !== auth()->id()) { abort(403); @@ -35,7 +36,7 @@ class AccountMappingController extends Controller // During onboarding, skip the mapping UI — auto-create all accounts directly if (! $user->isOnboarded()) { - $this->createAccountsFromPending($user, $connection); + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); SyncBankingConnectionJob::dispatch($connection); return redirect()->route('onboarding', ['step' => 'create-account']) @@ -55,7 +56,7 @@ class AccountMappingController extends Controller ]); } - public function store(MapAccountsRequest $request, BankingConnection $connection): RedirectResponse + public function store(MapAccountsRequest $request, BankingConnection $connection, AccountUserCurrencyService $accountUserCurrencyService): RedirectResponse { if ($connection->user_id !== auth()->id()) { abort(403); @@ -95,7 +96,7 @@ class AccountMappingController extends Controller ?? $accountData['account_id']['iban'] ?? $connection->aspsp_name.' Account'; - $user->accounts()->create([ + $account = $user->accounts()->create([ 'name' => $name, 'name_iv' => null, 'encrypted' => false, @@ -106,6 +107,8 @@ class AccountMappingController extends Controller 'external_account_id' => $uid, 'iban' => $accountData['account_id']['iban'] ?? null, ]); + + $accountUserCurrencyService->syncFromFirstAccount($account); } elseif ($action === 'link') { $existingAccount = $user->accounts()->find($mapping['existing_account_id']); @@ -117,6 +120,8 @@ class AccountMappingController extends Controller 'bank_id' => $bank->id, 'linked_at' => now(), ]); + + $accountUserCurrencyService->syncFromFirstAccount($existingAccount); } } } diff --git a/app/Http/Controllers/OpenBanking/AuthorizationController.php b/app/Http/Controllers/OpenBanking/AuthorizationController.php index 9cdbcf81..ffdcd613 100644 --- a/app/Http/Controllers/OpenBanking/AuthorizationController.php +++ b/app/Http/Controllers/OpenBanking/AuthorizationController.php @@ -11,6 +11,7 @@ use App\Http\Requests\OpenBanking\StartAuthorizationRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\BankingConnection; use App\Models\User; +use App\Services\AccountUserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -138,7 +139,7 @@ class AuthorizationController extends Controller /** * Handle the callback from bank authorization. */ - public function callback(Request $request, BankingProviderInterface $provider): RedirectResponse + public function callback(Request $request, BankingProviderInterface $provider, AccountUserCurrencyService $accountUserCurrencyService): RedirectResponse { $user = auth()->user(); $errorRedirectRoute = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding'; @@ -224,7 +225,7 @@ class AuthorizationController extends Controller ]); if (! $user->isOnboarded()) { - $this->createAccountsFromPending($user, $connection); + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); SyncBankingConnectionJob::dispatch($connection); return redirect()->route('onboarding', ['step' => 'create-account']) diff --git a/app/Http/Controllers/OpenBanking/BinanceController.php b/app/Http/Controllers/OpenBanking/BinanceController.php index 7982f07f..f3c8a911 100644 --- a/app/Http/Controllers/OpenBanking/BinanceController.php +++ b/app/Http/Controllers/OpenBanking/BinanceController.php @@ -9,6 +9,7 @@ use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate; use App\Http\Requests\OpenBanking\ConnectBinanceRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; +use App\Services\AccountUserCurrencyService; use App\Services\Banking\BinanceClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; @@ -21,7 +22,7 @@ class BinanceController extends Controller /** * Validate Binance API credentials and create a connection. */ - public function store(ConnectBinanceRequest $request): JsonResponse + public function store(ConnectBinanceRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse { $validated = $request->validated(); $user = auth()->user(); @@ -71,7 +72,7 @@ class BinanceController extends Controller ]); if (! $user->isOnboarded()) { - $this->createAccountsFromPending($user, $connection); + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); SyncBankingConnectionJob::dispatch($connection); return response()->json([ diff --git a/app/Http/Controllers/OpenBanking/BitpandaController.php b/app/Http/Controllers/OpenBanking/BitpandaController.php index 6e8a6d18..53654627 100644 --- a/app/Http/Controllers/OpenBanking/BitpandaController.php +++ b/app/Http/Controllers/OpenBanking/BitpandaController.php @@ -9,6 +9,7 @@ use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate; use App\Http\Requests\OpenBanking\ConnectBitpandaRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; +use App\Services\AccountUserCurrencyService; use App\Services\Banking\BitpandaClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; @@ -21,7 +22,7 @@ class BitpandaController extends Controller /** * Validate Bitpanda API key and create a connection. */ - public function store(ConnectBitpandaRequest $request): JsonResponse + public function store(ConnectBitpandaRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse { $validated = $request->validated(); $user = auth()->user(); @@ -70,7 +71,7 @@ class BitpandaController extends Controller ]); if (! $user->isOnboarded()) { - $this->createAccountsFromPending($user, $connection); + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); SyncBankingConnectionJob::dispatch($connection); return response()->json([ diff --git a/app/Http/Controllers/OpenBanking/CoinbaseController.php b/app/Http/Controllers/OpenBanking/CoinbaseController.php index 0945fb78..98ea72a4 100644 --- a/app/Http/Controllers/OpenBanking/CoinbaseController.php +++ b/app/Http/Controllers/OpenBanking/CoinbaseController.php @@ -9,6 +9,7 @@ use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate; use App\Http\Requests\OpenBanking\ConnectCoinbaseRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; +use App\Services\AccountUserCurrencyService; use App\Services\Banking\CoinbaseClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; @@ -21,7 +22,7 @@ class CoinbaseController extends Controller /** * Validate Coinbase CDP API credentials and create a connection. */ - public function store(ConnectCoinbaseRequest $request): JsonResponse + public function store(ConnectCoinbaseRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse { $validated = $request->validated(); $user = auth()->user(); @@ -71,7 +72,7 @@ class CoinbaseController extends Controller ]); if (! $user->isOnboarded()) { - $this->createAccountsFromPending($user, $connection); + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); SyncBankingConnectionJob::dispatch($connection); return response()->json([ diff --git a/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php b/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php index 03778233..027d1f7c 100644 --- a/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php +++ b/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php @@ -7,6 +7,7 @@ use App\Enums\BankingConnectionStatus; use App\Models\Bank; use App\Models\BankingConnection; use App\Models\User; +use App\Services\AccountUserCurrencyService; trait CreatesAccountsFromPending { @@ -17,7 +18,7 @@ trait CreatesAccountsFromPending * Indexa Capital, Binance, Bitpanda, and Coinbase; Checking for everything else) and clears the * pending data once accounts have been created. */ - private function createAccountsFromPending(User $user, BankingConnection $connection): void + private function createAccountsFromPending(User $user, BankingConnection $connection, AccountUserCurrencyService $accountUserCurrencyService): void { $bank = Bank::firstOrCreate( ['name' => $connection->aspsp_name, 'user_id' => null], @@ -44,7 +45,7 @@ trait CreatesAccountsFromPending ?? $accountData['account_id']['iban'] ?? $connection->aspsp_name.' Account'; - $user->accounts()->create([ + $account = $user->accounts()->create([ 'name' => $name, 'name_iv' => null, 'encrypted' => false, @@ -55,6 +56,8 @@ trait CreatesAccountsFromPending 'external_account_id' => $uid, 'iban' => $accountData['account_id']['iban'] ?? null, ]); + + $accountUserCurrencyService->syncFromFirstAccount($account); } $connection->update([ diff --git a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php index bc1d55b4..6968603b 100644 --- a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php +++ b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php @@ -9,6 +9,7 @@ use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate; use App\Http\Requests\OpenBanking\ConnectIndexaCapitalRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\Bank; +use App\Services\AccountUserCurrencyService; use App\Services\Banking\IndexaCapitalClient; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; @@ -21,7 +22,7 @@ class IndexaCapitalController extends Controller /** * Validate the Indexa Capital API token and create a connection. */ - public function store(ConnectIndexaCapitalRequest $request): JsonResponse + public function store(ConnectIndexaCapitalRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse { $validated = $request->validated(); $user = auth()->user(); @@ -64,7 +65,7 @@ class IndexaCapitalController extends Controller ]); if (! $user->isOnboarded()) { - $this->createAccountsFromPending($user, $connection); + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); SyncBankingConnectionJob::dispatch($connection); return response()->json([ diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index 85c25ce1..591f5eb0 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -10,6 +10,7 @@ use App\Jobs\GenerateHistoricalLoanBalancesJob; use App\Jobs\GenerateHistoricalRealEstateBalancesJob; use App\Models\Account; use App\Models\User; +use App\Services\AccountUserCurrencyService; use App\Services\LoanBalanceGeneratorService; use App\Services\RealEstateBalanceGeneratorService; use Carbon\Carbon; @@ -46,7 +47,7 @@ class AccountController extends Controller /** * Store a newly created account. */ - public function store(StoreAccountRequest $request, RealEstateBalanceGeneratorService $balanceGenerator, LoanBalanceGeneratorService $loanBalanceGenerator): RedirectResponse|JsonResponse + public function store(StoreAccountRequest $request, RealEstateBalanceGeneratorService $balanceGenerator, LoanBalanceGeneratorService $loanBalanceGenerator, AccountUserCurrencyService $accountUserCurrencyService): RedirectResponse|JsonResponse { /** @var User $user */ $user = Auth::user(); @@ -168,10 +169,7 @@ class AccountController extends Controller } } - // Set user's currency_code from first account - if ($user->accounts()->count() === 1) { - $user->update(['currency_code' => $account->currency_code]); - } + $accountUserCurrencyService->syncFromFirstAccount($account); if ($request->wantsJson()) { return response()->json($account, 201); diff --git a/app/Services/AccountUserCurrencyService.php b/app/Services/AccountUserCurrencyService.php new file mode 100644 index 00000000..d2ba435c --- /dev/null +++ b/app/Services/AccountUserCurrencyService.php @@ -0,0 +1,35 @@ +user; + + if (! $user instanceof User) { + return; + } + + if ($user->accounts()->count() !== 1) { + return; + } + + $this->sync($user, $account); + } + + private function sync(User $user, Account $account): void + { + $currencyCode = strtoupper($account->currency_code); + + if ($user->currency_code === $currencyCode) { + return; + } + + $user->forceFill(['currency_code' => $currencyCode])->save(); + } +} diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 29679923..9a40acee 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -28,6 +28,70 @@ it('redirects new registration to email verification', function () { ]); }); +it('syncs user currency from first onboarding account after signup', function () { + Bank::factory()->create(['name' => 'Signup Test Bank']); + + $page = visit('/register?force=1'); + + $page->assertSee('Create an account') + ->fill('name', 'Currency Signup User') + ->fill('email', 'currency-signup@example.com') + ->fill('password', 'password123456') + ->fill('password_confirmation', 'password123456') + ->click('@register-user-button') + ->wait(3) + ->assertPathIs('/email/verify') + ->assertNoJavascriptErrors(); + + $user = User::where('email', 'currency-signup@example.com')->firstOrFail(); + + expect($user->currency_code)->toBe('USD'); + + $user->forceFill(['email_verified_at' => now()])->save(); + + $this->actingAs($user->refresh()); + + $page = visit('/onboarding'); + + $page->assertPathIs('/onboarding') + ->assertSee('Welcome to') + ->click("Let's Get Started") + ->wait(1) + ->assertSee('Account Types') + ->click('Create Your First Account') + ->wait(1) + ->assertSee('Create an Account') + ->click('Manual') + ->wait(1) + ->click('Continue') + ->wait(1) + ->fill('#display_name', 'Euro Checking Account') + ->click('Select bank...') + ->wait(1) + ->fill('[placeholder="Search bank..."]', 'Signup') + ->wait(1) + ->click('Signup Test Bank') + ->wait(1) + ->click('Select account type') + ->wait(1) + ->click('[role="option"]:has-text("Checking")') + ->wait(1) + ->click('Select currency') + ->wait(1) + ->click('[role="option"]:has-text("EUR")') + ->wait(1) + ->click('Create Account') + ->wait(5) + ->assertNoJavascriptErrors(); + + $user->refresh(); + $account = $user->accounts()->first(); + + expect($user->currency_code)->toBe('EUR'); + expect($account)->not->toBeNull(); + expect($account->currency_code)->toBe('EUR'); +}); + it('redirects onboarded user away from onboarding page to dashboard', function () { $user = User::factory()->onboarded()->create(); diff --git a/tests/Feature/OpenBanking/AccountMappingTest.php b/tests/Feature/OpenBanking/AccountMappingTest.php index 3edfbced..48558b7c 100644 --- a/tests/Feature/OpenBanking/AccountMappingTest.php +++ b/tests/Feature/OpenBanking/AccountMappingTest.php @@ -47,6 +47,38 @@ test('show redirects if no pending accounts', function () { $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(); @@ -150,6 +182,121 @@ test('store creates investment accounts for crypto provider connections', functi '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(); diff --git a/tests/Feature/Settings/AccountTest.php b/tests/Feature/Settings/AccountTest.php index 69acced2..d5ee43d9 100644 --- a/tests/Feature/Settings/AccountTest.php +++ b/tests/Feature/Settings/AccountTest.php @@ -56,6 +56,21 @@ it('can create a new account with plaintext name', function () { ]); }); +it('updates user currency from first manually created account', function () { + actingAs($this->user); + + $response = $this->post(route('accounts.store'), [ + 'name' => 'My Euro Account', + 'bank_id' => $this->bank->id, + 'currency_code' => 'EUR', + 'type' => AccountType::Checking->value, + ]); + + $response->assertRedirect(); + + expect($this->user->refresh()->currency_code)->toBe('EUR'); +}); + it('can create a new account without a bank', function () { actingAs($this->user);