fix(open-banking): respect local email hours (#306)

## Summary
- avoid sending bank transaction synced emails during 23:00-08:00 in the
user's local timezone by re-releasing the job until the next local 08:00
- deduplicate the daily bank transaction email by the user's local date
instead of the UTC date
- add feature coverage for quiet hours, first allowed local send time,
and local-day deduplication

## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
- vendor/bin/pint --dirty --format agent

## Notes
- existing 6-hour sync slots still leave at least one valid send slot
for every timezone
This commit is contained in:
Víctor Falcón 2026-04-19 16:44:40 +01:00 committed by GitHub
parent 827acb8c15
commit fbffdd3f3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 205 additions and 2 deletions

View File

@ -8,6 +8,7 @@ use App\Mail\BankTransactionsSyncedEmail;
use App\Models\Transaction;
use App\Models\User;
use App\Models\UserMailLog;
use DateTimeZone;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -36,13 +37,22 @@ class SendDailyBankTransactionsSyncedEmailJob implements ShouldBeUnique, ShouldQ
public function handle(): void
{
$localReportDate = $this->localReportDate();
$lastSentMailLog = UserMailLog::query()
->where('user_id', $this->user->id)
->where('email_type', DripEmailType::BankTransactionsSynced)
->latest('sent_at')
->first();
if ($lastSentMailLog?->email_identifier === $this->reportDate) {
if ($lastSentMailLog?->email_identifier === $localReportDate) {
return;
}
$quietHoursDelay = $this->quietHoursDelayInSeconds();
if ($quietHoursDelay !== null) {
$this->release($quietHoursDelay);
return;
}
@ -78,7 +88,7 @@ class SendDailyBankTransactionsSyncedEmailJob implements ShouldBeUnique, ShouldQ
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::BankTransactionsSynced,
'email_identifier' => $this->reportDate,
'email_identifier' => $localReportDate,
'sent_at' => now(),
]);
}
@ -87,4 +97,53 @@ class SendDailyBankTransactionsSyncedEmailJob implements ShouldBeUnique, ShouldQ
{
return $this->user->id.':'.$this->reportDate;
}
private function localReportDate(): string
{
return now($this->reportingTimezone())->toDateString();
}
private function quietHoursDelayInSeconds(): ?int
{
$timezone = $this->userTimezone();
if ($timezone === null) {
return null;
}
$localNow = now($timezone);
$localHour = (int) $localNow->format('G');
if ($localHour >= 8 && $localHour < 23) {
return null;
}
$nextAllowedAt = $localHour >= 23
? $localNow->copy()->addDay()->startOfDay()->addHours(8)
: $localNow->copy()->startOfDay()->addHours(8);
return $nextAllowedAt->getTimestamp() - $localNow->getTimestamp();
}
private function reportingTimezone(): string
{
return $this->userTimezone() ?? config('app.timezone', 'UTC');
}
private function userTimezone(): ?string
{
$timezone = $this->user->timezone;
if ($timezone === null) {
return null;
}
try {
new DateTimeZone($timezone);
return $timezone;
} catch (\Exception) {
return null;
}
}
}

View File

@ -356,6 +356,150 @@ test('daily bank sync email job sends pending transactions once per day', functi
Mail::assertQueued(BankTransactionsSyncedEmail::class, 1);
});
test('daily bank sync email job releases during quiet hours in user timezone', function () {
Mail::fake();
test()->travelTo(Carbon::parse('2026-04-15 04:00:00', 'UTC'));
$user = User::factory()->onboarded()->create(['timezone' => 'Europe/Madrid']);
$bank = Bank::factory()->create(['name' => 'Quiet Hours 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,
'bank_id' => $bank->id,
]);
Transaction::factory()->count(2)->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
$job = (new SendDailyBankTransactionsSyncedEmailJob($user, now()->toDateString()))->withFakeQueueInteractions();
$job->handle();
$job->assertReleased(delay: 7200);
Mail::assertNothingQueued();
expect(UserMailLog::query()->count())->toBe(0);
});
test('daily bank sync email job sends at first allowed local hour for user timezone', function () {
Mail::fake();
test()->travelTo(Carbon::parse('2026-04-15 02:15:00', 'UTC'));
$user = User::factory()->onboarded()->create(['timezone' => 'Asia/Kathmandu']);
$bank = Bank::factory()->create(['name' => 'Allowed Hours 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,
'bank_id' => $bank->id,
]);
Transaction::factory()->count(3)->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
$job = new SendDailyBankTransactionsSyncedEmailJob($user, now()->toDateString());
$job->handle();
Mail::assertQueued(BankTransactionsSyncedEmail::class, function ($mail) use ($user) {
return $mail->totalTransactions === 3
&& $mail->transactionsPerBank === ['Allowed Hours Bank' => 3]
&& $mail->hasTo($user->email);
});
expect(UserMailLog::query()
->where('user_id', $user->id)
->where('email_type', DripEmailType::BankTransactionsSynced)
->where('email_identifier', '2026-04-15')
->exists())->toBeTrue();
});
test('daily bank sync email job ignores quiet hours when user timezone is missing', function () {
Mail::fake();
test()->travelTo(Carbon::parse('2026-04-15 02:00:00', 'UTC'));
$user = User::factory()->onboarded()->create(['timezone' => null]);
$bank = Bank::factory()->create(['name' => 'Timezone Fallback 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,
'bank_id' => $bank->id,
]);
Transaction::factory()->count(2)->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
$job = new SendDailyBankTransactionsSyncedEmailJob($user, now()->toDateString());
$job->handle();
Mail::assertQueued(BankTransactionsSyncedEmail::class, function ($mail) use ($user) {
return $mail->totalTransactions === 2
&& $mail->transactionsPerBank === ['Timezone Fallback Bank' => 2]
&& $mail->hasTo($user->email);
});
});
test('daily bank sync email job deduplicates per user local day', function () {
Mail::fake();
test()->travelTo(Carbon::parse('2026-04-15 00:30:00', 'UTC'));
$user = User::factory()->onboarded()->create(['timezone' => 'America/Los_Angeles']);
$bank = Bank::factory()->create(['name' => 'Local Day 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,
'bank_id' => $bank->id,
]);
Transaction::factory()->count(2)->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'created_at' => now()->subMinutes(15),
'updated_at' => now()->subMinutes(15),
]);
UserMailLog::create([
'user_id' => $user->id,
'email_type' => DripEmailType::BankTransactionsSynced,
'email_identifier' => '2026-04-14',
'sent_at' => Carbon::parse('2026-04-14 18:00:00', 'UTC'),
]);
$job = new SendDailyBankTransactionsSyncedEmailJob($user, now()->toDateString());
$job->handle();
Mail::assertNothingQueued();
});
test('daily bank sync email job sends unreported transactions next day even when current sync imported none', function () {
Mail::fake();