whisper-money/app/Jobs/SyncBankingConnectionJob.php

369 lines
13 KiB
PHP

<?php
namespace App\Jobs;
use App\Contracts\BankingConnectionSyncer;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingSyncLogStatus;
use App\Exceptions\Banking\ExpiredBankingSessionException;
use App\Exceptions\Banking\TransientBankingProviderException;
use App\Mail\BankingConnectionAuthFailedEmail;
use App\Mail\BankingConnectionExpiredEmail;
use App\Models\BankingConnection;
use App\Models\BankingSyncLog;
use App\Services\Banking\Sync\BankingConnectionSyncerFactory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Sentry\State\Scope;
use function Sentry\configureScope;
class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public int $timeout = 120;
/**
* Maximum number of scheduled sync cycles that will auto-retry
* a connection in Error state before requiring manual intervention.
*/
public const int MAX_SCHEDULED_RETRIES = 3;
public function __construct(
public BankingConnection $bankingConnection,
public bool $fullSync = false,
) {}
public function uniqueId(): string
{
return $this->bankingConnection->id;
}
public function handle(BankingConnectionSyncerFactory $syncerFactory): void
{
$connection = $this->bankingConnection;
$startTime = microtime(true);
$syncedAt = now();
$connection->loadMissing('user');
$this->setSentryContext($connection);
if (! $connection->user) {
Log::info('Banking connection belongs to deleted user, skipping sync', ['connection_id' => $connection->id]);
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'deleted_user']);
return;
}
$syncer = $syncerFactory->make($connection);
if ($syncer->expires() && $connection->isExpired()) {
$this->markExpired($connection, $startTime);
return;
}
if (! $this->isSyncableStatus($connection)) {
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'not_syncable', 'status' => $connection->status->value]);
return;
}
if ($connection->isRateLimited()) {
Log::info('Banking connection rate limited, skipping sync', [
'connection_id' => $connection->id,
'rate_limited_until' => $connection->rate_limited_until?->toIso8601String(),
]);
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: [
'reason' => 'rate_limited',
'rate_limited_until' => $connection->rate_limited_until?->toIso8601String(),
]);
return;
}
try {
$isFirstSync = ! $connection->last_synced_at || $this->fullSync;
$metadata = $syncer->sync($connection, $isFirstSync);
$connection->update([
'status' => BankingConnectionStatus::Active,
'last_synced_at' => $syncedAt,
'error_message' => null,
'rate_limited_until' => null,
'consecutive_sync_failures' => 0,
]);
$this->logSyncAttempt($connection, BankingSyncLogStatus::Success, $startTime, metadata: $metadata ?: null);
} catch (ExpiredBankingSessionException) {
$this->markExpired($connection, $startTime);
return;
} catch (\Throwable $e) {
$context = [
'connection_id' => $connection->id,
'error' => $e->getMessage(),
'attempt' => $this->attempts(),
];
if ($e instanceof TransientBankingProviderException) {
$context['provider'] = $e->provider;
$context['status_code'] = $e->statusCode;
$context['provider_code'] = $e->providerCode;
}
// Only report once the connection actually gives up. Transient errors on a
// non-final attempt are recovered by the retry and would otherwise spam one
// warning per scheduled cycle for connections that ultimately sync fine.
if ($this->attempts() >= $this->tries || $this->isAuthError($e)) {
Log::log($e instanceof TransientBankingProviderException ? 'warning' : 'error', 'Banking sync failed', $context);
}
if ($this->isRateLimitError($e)) {
$this->applyRateLimitBackoff($connection, $e);
$this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e);
return;
}
$this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e);
if ($this->isAuthError($e)) {
$this->handlePermanentError($connection, $syncer, $e);
return;
}
$this->handleTemporaryError($connection, $e);
}
}
/**
* Handle permanent errors (auth failures) that should not be retried.
*/
public function failed(?\Throwable $e): void
{
$connection = $this->bankingConnection->fresh();
if (! $connection || $connection->status === BankingConnectionStatus::Error) {
return;
}
if (! $this->isSyncableStatus($connection)) {
return;
}
$connection->update([
'status' => BankingConnectionStatus::Error,
'error_message' => $e ? $this->friendlyErrorMessage($e) : __('An unexpected error occurred during sync. Please try again later.'),
'consecutive_sync_failures' => $connection->consecutive_sync_failures + 1,
]);
}
/**
* Mark the connection as expired and notify the user to reconnect.
*
* Reached both when the stored consent window lapses and when the provider
* reports the session itself has expired mid-sync. Either way it is an
* expected lifecycle event, not a failure to report.
*/
private function markExpired(BankingConnection $connection, float $startTime): void
{
$shouldNotify = $connection->status !== BankingConnectionStatus::Expired;
$connection->update(['status' => BankingConnectionStatus::Expired]);
Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]);
if ($shouldNotify && $connection->user?->canReceiveEmails()) {
Mail::to($connection->user)->send(new BankingConnectionExpiredEmail(
$connection->user,
$connection,
));
}
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'expired']);
}
private function handlePermanentError(BankingConnection $connection, BankingConnectionSyncer $syncer, \Throwable $e): void
{
$connection->update([
'status' => BankingConnectionStatus::Error,
'error_message' => $this->friendlyErrorMessage($e),
'consecutive_sync_failures' => self::MAX_SCHEDULED_RETRIES + 1,
]);
if ($syncer->notifiesOnAuthFailure() && $connection->user?->canReceiveEmails()) {
Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail(
$connection->user,
$connection,
));
}
$this->fail($e);
throw $e;
}
/**
* Handle temporary errors that may resolve on retry.
*/
private function handleTemporaryError(BankingConnection $connection, \Throwable $e): void
{
$isFinalAttempt = $this->attempts() >= $this->tries;
if ($isFinalAttempt) {
$connection->update([
'status' => BankingConnectionStatus::Error,
'error_message' => $this->friendlyErrorMessage($e),
'consecutive_sync_failures' => $connection->consecutive_sync_failures + 1,
]);
}
throw $e;
}
/**
* Whether the connection status allows syncing.
* Allows both Active and Error (for auto-retry from scheduled runs).
*/
private function isSyncableStatus(BankingConnection $connection): bool
{
return in_array($connection->status, [
BankingConnectionStatus::Active,
BankingConnectionStatus::Error,
]);
}
private function setSentryContext(BankingConnection $connection): void
{
configureScope(function (Scope $scope) use ($connection): void {
$scope->setTag('banking_connection_id', (string) $connection->id);
$scope->setContext('banking_connection', [
'id' => $connection->id,
'provider' => $connection->provider->value,
'status' => $connection->status->value,
]);
if ($connection->user === null) {
return;
}
$scope->setUser([
'id' => (string) $connection->user->getAuthIdentifier(),
'email' => $connection->user->email,
]);
});
}
private function logSyncAttempt(
BankingConnection $connection,
BankingSyncLogStatus $status,
float $startTime,
?\Throwable $error = null,
?array $metadata = null,
): void {
$durationMs = (int) round((microtime(true) - $startTime) * 1000);
BankingSyncLog::create([
'banking_connection_id' => $connection->id,
'status' => $status,
'attempt' => $this->attempts(),
'error_message' => $error?->getMessage(),
'error_class' => $error ? get_class($error) : null,
'duration_ms' => $durationMs,
'metadata' => $metadata,
'created_at' => now(),
]);
}
private function friendlyErrorMessage(\Throwable $e): string
{
if ($e instanceof TransientBankingProviderException) {
return __('The bank provider is temporarily unavailable. We will try syncing again later.');
}
if ($e instanceof RequestException) {
$status = $e->response->status();
return match (true) {
$status === 429 => __('Rate limit exceeded. Please wait a few minutes and try again.'),
$status === 401 || $status === 403 => __('Authentication failed. Your credentials may have expired or been revoked.'),
$status >= 500 => __('The provider is experiencing issues. Please try again later.'),
default => __('Failed to sync with the provider. Please try again later.'),
};
}
return __('An unexpected error occurred during sync. Please try again later.');
}
private function isRateLimitError(\Throwable $e): bool
{
return $e instanceof RequestException && $e->response->status() === 429;
}
/**
* Persist a backoff window so the scheduler stops re-dispatching
* the same connection until the provider quota resets.
*/
private function applyRateLimitBackoff(BankingConnection $connection, \Throwable $e): void
{
$until = $this->resolveRateLimitBackoffUntil($e);
$connection->update([
'rate_limited_until' => $until,
'error_message' => $this->friendlyErrorMessage($e),
]);
Log::warning('Banking connection rate limited, backing off', [
'connection_id' => $connection->id,
'rate_limited_until' => $until->toIso8601String(),
]);
}
private function resolveRateLimitBackoffUntil(\Throwable $e): Carbon
{
$now = now();
if ($e instanceof RequestException) {
$retryAfter = $e->response->header('Retry-After');
if (is_numeric($retryAfter) && (int) $retryAfter > 0) {
return $now->copy()->addSeconds((int) $retryAfter);
}
$body = $e->response->json();
$message = is_array($body) ? (string) ($body['message'] ?? '') : '';
// Daily PSU consultation limit resets at midnight UTC.
if (str_contains(strtolower($message), 'daily')) {
return $now->copy()->utc()->addDay()->startOfDay();
}
}
// Default: back off one hour for consent / generic 429 responses.
return $now->copy()->addHour();
}
private function isAuthError(\Throwable $e): bool
{
return $e instanceof RequestException
&& in_array($e->response->status(), [401, 403]);
}
}