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'
This commit is contained in:
Víctor Falcón 2026-05-25 20:55:24 +02:00 committed by GitHub
parent 4f42de74a1
commit 0d4c68361a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 293 additions and 21 deletions

View File

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

View File

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

View File

@ -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([

View File

@ -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([

View File

@ -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([

View File

@ -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([

View File

@ -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([

View File

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

View File

@ -0,0 +1,35 @@
<?php
namespace App\Services;
use App\Models\Account;
use App\Models\User;
class AccountUserCurrencyService
{
public function syncFromFirstAccount(Account $account): void
{
$user = $account->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();
}
}

View File

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

View File

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

View File

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