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:
parent
827acb8c15
commit
fbffdd3f3c
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue