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:
parent
c7f3f1a978
commit
40d4b3cfe7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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];
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue