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:
parent
64ec047769
commit
473ac03088
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue