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
This commit is contained in:
Víctor Falcón 2026-04-16 08:28:44 +01:00 committed by GitHub
parent 64ec047769
commit 473ac03088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 133 additions and 5 deletions

View File

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

View File

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

View File

@ -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<int, mixed>|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',

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('banking_connections', function (Blueprint $table) {
$table->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');
});
}
};

View File

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