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
This commit is contained in:
Víctor Falcón 2026-02-12 14:05:02 +01:00 committed by GitHub
parent c7f3f1a978
commit 40d4b3cfe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 282 additions and 6 deletions

View File

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

View File

@ -0,0 +1,70 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class BankTransactionsSyncedEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
/**
* @param array<string, int> $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<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,17 @@
<x-mail::message>
# {{ __('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
<x-mail::button :url="route('transactions.index')">
{{ __('View Transactions') }}
</x-mail::button>
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -1,12 +1,15 @@
<?php
use App\Jobs\SyncBankingConnectionJob;
use App\Mail\BankTransactionsSyncedEmail;
use App\Models\Account;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
use Illuminate\Support\Facades\Mail;
test('first sync calculates historical balances', function () {
$user = User::factory()->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];
});
});