From 40d4b3cfe7ad2f5404f38827b67ed534a20e42fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 12 Feb 2026 14:05:02 +0100 Subject: [PATCH] Email notification for bank-synced transactions (#113) ## Summary - Send a queued email notification to users after subsequent bank syncs when new transactions are found - Email includes a per-bank breakdown of imported transaction counts with a link to the transactions page - Skips notification on first sync and when zero new transactions are created ## Test plan - [x] Sends email when new transactions are synced on subsequent sync - [x] Does not send email on first sync - [x] Does not send email when zero new transactions - [x] Aggregates multiple accounts under same bank - [x] Lists different banks separately in email - [x] Existing sync job tests still pass --- app/Jobs/SyncBankingConnectionJob.php | 25 ++- app/Mail/BankTransactionsSyncedEmail.php | 70 +++++++ .../mail/bank-transactions-synced.blade.php | 17 ++ .../SyncBankingConnectionJobTest.php | 176 +++++++++++++++++- 4 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 app/Mail/BankTransactionsSyncedEmail.php create mode 100644 resources/views/mail/bank-transactions-synced.blade.php diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index 9ca38c9a..bf850473 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Enums\BankingConnectionStatus; +use App\Mail\BankTransactionsSyncedEmail; use App\Models\BankingConnection; use App\Services\Banking\BalanceSyncService; use App\Services\Banking\TransactionSyncService; @@ -13,6 +14,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue { @@ -54,6 +56,10 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue $strategy = $isFirstSync ? 'longest' : null; try { + $transactionsPerBank = []; + + $connection->load('accounts.bank'); + foreach ($connection->accounts as $account) { if ($account->isLinked()) { $lastTransaction = $account->transactions() @@ -64,22 +70,37 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue ? $lastTransaction->transaction_date->toDateString() : $dateFrom; - $transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); + $created = $transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); $balanceSync->sync($account); } else { - $transactionSync->sync($account, $dateFrom, $dateTo, $strategy); + $created = $transactionSync->sync($account, $dateFrom, $dateTo, $strategy); $balanceSync->sync($account); if ($isFirstSync) { $balanceSync->calculateHistoricalBalances($account); } } + + if ($created > 0) { + $bankName = $account->bank?->name ?? __('Unknown Bank'); + $transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created; + } } $connection->update([ 'last_synced_at' => now(), 'error_message' => null, ]); + + $totalTransactions = array_sum($transactionsPerBank); + + if (! $isFirstSync && $totalTransactions > 0) { + Mail::to($connection->user)->send(new BankTransactionsSyncedEmail( + $connection->user, + $totalTransactions, + $transactionsPerBank, + )); + } } catch (\Throwable $e) { Log::error('Banking sync failed', [ 'connection_id' => $connection->id, diff --git a/app/Mail/BankTransactionsSyncedEmail.php b/app/Mail/BankTransactionsSyncedEmail.php new file mode 100644 index 00000000..43bd1b04 --- /dev/null +++ b/app/Mail/BankTransactionsSyncedEmail.php @@ -0,0 +1,70 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + /** + * @param array $transactionsPerBank + */ + public function __construct( + public User $user, + public int $totalTransactions, + public array $transactionsPerBank, + ) { + $this->onQueue('emails'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: __(':count new transactions synced on Whisper Money', ['count' => $this->totalTransactions]), + )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.bank-transactions-synced', + with: [ + 'userName' => $this->user->name, + 'transactionsPerBank' => $this->transactionsPerBank, + ], + ); + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/resources/views/mail/bank-transactions-synced.blade.php b/resources/views/mail/bank-transactions-synced.blade.php new file mode 100644 index 00000000..06229601 --- /dev/null +++ b/resources/views/mail/bank-transactions-synced.blade.php @@ -0,0 +1,17 @@ + +# {{ __('New transactions synced, :name!', ['name' => $userName]) }} + +{{ __('We just synced new transactions from your connected banks:') }} + +@foreach ($transactionsPerBank as $bankName => $count) +- **{{ $bankName }}** - {{ trans_choice(':count new transaction|:count new transactions', $count, ['count' => $count]) }} +@endforeach + + +{{ __('View Transactions') }} + + +Best,
+VĂ­ctor F,
+Founder of Whisper Money +
diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php index 683dc84d..7c3cefd6 100644 --- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php +++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php @@ -1,12 +1,15 @@ onboarded()->create(); @@ -21,7 +24,7 @@ test('first sync calculates historical balances', function () { ]); $transactionSync = Mockery::mock(TransactionSyncService::class); - $transactionSync->shouldReceive('sync')->once(); + $transactionSync->shouldReceive('sync')->once()->andReturn(0); $balanceSync = Mockery::mock(BalanceSyncService::class); $balanceSync->shouldReceive('sync')->once(); @@ -44,7 +47,7 @@ test('subsequent syncs do not calculate historical balances', function () { ]); $transactionSync = Mockery::mock(TransactionSyncService::class); - $transactionSync->shouldReceive('sync')->once(); + $transactionSync->shouldReceive('sync')->once()->andReturn(0); $balanceSync = Mockery::mock(BalanceSyncService::class); $balanceSync->shouldReceive('sync')->once(); @@ -77,7 +80,8 @@ test('linked accounts sync from last transaction date and skip historical balanc ->once() ->withArgs(function ($acct, $dateFrom, $dateTo, $strategy) { return $dateFrom === '2025-12-15'; - }); + }) + ->andReturn(0); $balanceSync = Mockery::mock(BalanceSyncService::class); $balanceSync->shouldReceive('sync')->once(); @@ -107,7 +111,7 @@ test('mixed linked and new accounts in same connection', function () { ]); $transactionSync = Mockery::mock(TransactionSyncService::class); - $transactionSync->shouldReceive('sync')->twice(); + $transactionSync->shouldReceive('sync')->twice()->andReturn(0); $balanceSync = Mockery::mock(BalanceSyncService::class); $balanceSync->shouldReceive('sync')->twice(); @@ -118,3 +122,167 @@ test('mixed linked and new accounts in same connection', function () { $job = new SyncBankingConnectionJob($connection); $job->handle($transactionSync, $balanceSync); }); + +test('sends email when new transactions are synced on subsequent sync', function () { + Mail::fake(); + + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Test Bank']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + 'bank_id' => $bank->id, + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once()->andReturn(5); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + Mail::assertQueued(BankTransactionsSyncedEmail::class, function ($mail) use ($user) { + return $mail->totalTransactions === 5 + && $mail->transactionsPerBank === ['Test Bank' => 5] + && $mail->hasTo($user->email); + }); +}); + +test('does not send email on first sync', function () { + Mail::fake(); + + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Test Bank']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => null, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + 'bank_id' => $bank->id, + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once()->andReturn(10); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + $balanceSync->shouldReceive('calculateHistoricalBalances')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + Mail::assertNotQueued(BankTransactionsSyncedEmail::class); +}); + +test('does not send email when zero new transactions', function () { + Mail::fake(); + + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once()->andReturn(0); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + Mail::assertNotQueued(BankTransactionsSyncedEmail::class); +}); + +test('aggregates multiple accounts under same bank', function () { + Mail::fake(); + + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Shared Bank']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-1', + 'bank_id' => $bank->id, + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-2', + 'bank_id' => $bank->id, + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->twice()->andReturn(3); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->twice(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + Mail::assertQueued(BankTransactionsSyncedEmail::class, function ($mail) { + return $mail->totalTransactions === 6 + && $mail->transactionsPerBank === ['Shared Bank' => 6]; + }); +}); + +test('lists different banks separately in email', function () { + Mail::fake(); + + $user = User::factory()->onboarded()->create(); + $bankA = Bank::factory()->create(['name' => 'Bank A']); + $bankB = Bank::factory()->create(['name' => 'Bank B']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-a', + 'bank_id' => $bankA->id, + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-b', + 'bank_id' => $bankB->id, + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->twice()->andReturn(4); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->twice(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + Mail::assertQueued(BankTransactionsSyncedEmail::class, function ($mail) { + return $mail->totalTransactions === 8 + && $mail->transactionsPerBank === ['Bank A' => 4, 'Bank B' => 4]; + }); +});