From 473ac03088b3ad6e09c32344e0b4ca5f1db489ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 16 Apr 2026 08:28:44 +0100 Subject: [PATCH] fix(open-banking): skip silent sync emails (#295) ## Summary - add a per-connection cutoff timestamp so silent first/full sync imports are excluded from later daily bank sync emails - keep the existing one-email-per-day user cap while still reporting transactions created after the silent sync cutoff - add regression coverage for silent first sync, full sync, and post-cutoff reporting behavior ## Testing - php artisan test --compact tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php - vendor/bin/pint --dirty --format agent --- ...endDailyBankTransactionsSyncedEmailJob.php | 7 +- app/Jobs/SyncBankingConnectionJob.php | 15 +++- app/Models/BankingConnection.php | 3 + ...cutoff_at_to_banking_connections_table.php | 28 ++++++ .../SyncBankingConnectionJobTest.php | 85 +++++++++++++++++++ 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_04_16_063515_add_bank_transactions_email_cutoff_at_to_banking_connections_table.php diff --git a/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php b/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php index dd18a965..e34e8a42 100644 --- a/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php +++ b/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php @@ -50,7 +50,12 @@ class SendDailyBankTransactionsSyncedEmailJob implements ShouldBeUnique, ShouldQ ->where('user_id', $this->user->id) ->where('source', TransactionSource::EnableBanking) ->when($lastSentMailLog?->sent_at, fn ($query, $lastSentAt) => $query->where('created_at', '>', $lastSentAt)) - ->whereHas('account.bankingConnection') + ->whereHas('account.bankingConnection', function ($query) { + $query->where(function ($query) { + $query->whereNull('bank_transactions_email_cutoff_at') + ->orWhereColumn('bank_transactions_email_cutoff_at', '<', 'transactions.created_at'); + }); + }) ->with('account.bank') ->get(); diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index c77406c0..35533ee1 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -53,6 +53,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue { $connection = $this->bankingConnection; $startTime = microtime(true); + $syncedAt = now(); if ($connection->isEnableBanking() && $connection->isExpired()) { $connection->update(['status' => BankingConnectionStatus::Expired]); @@ -85,17 +86,23 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue if (! $isFirstSync) { SendDailyBankTransactionsSyncedEmailJob::dispatch( $connection->user, - now()->toDateString(), + $syncedAt->toDateString(), ); } } - $connection->update([ + $connectionUpdates = [ 'status' => BankingConnectionStatus::Active, - 'last_synced_at' => now(), + 'last_synced_at' => $syncedAt, 'error_message' => null, 'consecutive_sync_failures' => 0, - ]); + ]; + + if ($connection->isEnableBanking() && $isFirstSync) { + $connectionUpdates['bank_transactions_email_cutoff_at'] = $syncedAt; + } + + $connection->update($connectionUpdates); $this->logSyncAttempt($connection, BankingSyncLogStatus::Success, $startTime, metadata: $metadata ?: null); } catch (\Throwable $e) { diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index 43f6bf14..61800626 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property BankingConnectionStatus $status * @property Carbon|null $valid_until * @property Carbon|null $last_synced_at + * @property Carbon|null $bank_transactions_email_cutoff_at * @property int $consecutive_sync_failures * @property array|null $pending_accounts_data */ @@ -37,6 +38,7 @@ class BankingConnection extends Model 'status', 'valid_until', 'last_synced_at', + 'bank_transactions_email_cutoff_at', 'error_message', 'consecutive_sync_failures', 'pending_accounts_data', @@ -58,6 +60,7 @@ class BankingConnection extends Model 'status' => BankingConnectionStatus::class, 'valid_until' => 'datetime', 'last_synced_at' => 'datetime', + 'bank_transactions_email_cutoff_at' => 'datetime', 'pending_accounts_data' => 'array', 'api_token' => 'encrypted', 'api_secret' => 'encrypted', diff --git a/database/migrations/2026_04_16_063515_add_bank_transactions_email_cutoff_at_to_banking_connections_table.php b/database/migrations/2026_04_16_063515_add_bank_transactions_email_cutoff_at_to_banking_connections_table.php new file mode 100644 index 00000000..1f5751b9 --- /dev/null +++ b/database/migrations/2026_04_16_063515_add_bank_transactions_email_cutoff_at_to_banking_connections_table.php @@ -0,0 +1,28 @@ +dateTime('bank_transactions_email_cutoff_at')->nullable()->after('last_synced_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('banking_connections', function (Blueprint $table) { + $table->dropColumn('bank_transactions_email_cutoff_at'); + }); + } +}; diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php index a20c195d..06f92d3d 100644 --- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php +++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php @@ -194,6 +194,10 @@ test('does not send email on first sync', function () { $job->handle($transactionSync, $balanceSync); Queue::assertNotPushed(SendDailyBankTransactionsSyncedEmailJob::class); + + $connection->refresh(); + + expect($connection->bank_transactions_email_cutoff_at)->not->toBeNull(); }); test('schedules daily bank sync email check when subsequent sync imports zero new transactions', function () { @@ -387,6 +391,83 @@ test('daily bank sync email job sends unreported transactions next day even when }); }); +test('daily bank sync email job skips transactions imported during silent first sync', function () { + Mail::fake(); + + test()->travelTo(Carbon::parse('2026-04-15 09:00:00')); + + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Silent Bank']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDays(2), + 'bank_transactions_email_cutoff_at' => now()->subHour(), + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'bank_id' => $bank->id, + ]); + + Transaction::factory()->count(3)->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'created_at' => now()->subHours(2), + 'updated_at' => now()->subHours(2), + ]); + + test()->travelTo(Carbon::parse('2026-04-16 08:00:00')); + + $job = new SendDailyBankTransactionsSyncedEmailJob($user, now()->toDateString()); + $job->handle(); + + Mail::assertNothingQueued(); +}); + +test('daily bank sync email job only reports transactions created after silent first sync cutoff', function () { + Mail::fake(); + + test()->travelTo(Carbon::parse('2026-04-15 09:00:00')); + + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Mixed Bank']); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDays(2), + 'bank_transactions_email_cutoff_at' => now()->subHour(), + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'bank_id' => $bank->id, + ]); + + Transaction::factory()->count(2)->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'created_at' => now()->subHours(2), + 'updated_at' => now()->subHours(2), + ]); + + Transaction::factory()->count(4)->enableBanking()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'created_at' => now()->subMinutes(15), + 'updated_at' => now()->subMinutes(15), + ]); + + test()->travelTo(Carbon::parse('2026-04-16 08:00:00')); + + $job = new SendDailyBankTransactionsSyncedEmailJob($user, now()->toDateString()); + $job->handle(); + + Mail::assertQueued(BankTransactionsSyncedEmail::class, function ($mail) use ($user) { + return $mail->totalTransactions === 4 + && $mail->transactionsPerBank === ['Mixed Bank' => 4] + && $mail->hasTo($user->email); + }); +}); + test('daily bank sync email job skips when no pending transactions', function () { Mail::fake(); @@ -606,6 +687,10 @@ test('fullSync flag forces first-sync behavior on already-synced connection', fu $job = new SyncBankingConnectionJob($connection, fullSync: true); $job->handle($transactionSync, $balanceSync); + + $connection->refresh(); + + expect($connection->bank_transactions_email_cutoff_at)->not->toBeNull(); }); test('bitpanda sync calls balance sync service and updates last_synced_at', function () {