diff --git a/app/Console/Commands/SyncBankingConnections.php b/app/Console/Commands/SyncBankingConnections.php index 59ea05a6..7568d767 100644 --- a/app/Console/Commands/SyncBankingConnections.php +++ b/app/Console/Commands/SyncBankingConnections.php @@ -41,7 +41,13 @@ class SyncBankingConnections extends Command } $query = BankingConnection::query() - ->where('status', BankingConnectionStatus::Active) + ->where(function ($query) { + $query->where('status', BankingConnectionStatus::Active) + ->orWhere(function ($query) { + $query->where('status', BankingConnectionStatus::Error) + ->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES); + }); + }) ->where(function ($query) { $query->whereNull('valid_until') ->orWhere('valid_until', '>', now()); diff --git a/app/Enums/BankingSyncLogStatus.php b/app/Enums/BankingSyncLogStatus.php new file mode 100644 index 00000000..eed4eff4 --- /dev/null +++ b/app/Enums/BankingSyncLogStatus.php @@ -0,0 +1,10 @@ +update([ 'status' => BankingConnectionStatus::Active, 'error_message' => null, + 'consecutive_sync_failures' => 0, ]); SyncBankingConnectionJob::dispatch($connection); @@ -88,6 +89,7 @@ class ConnectionController extends Controller ...$updateData, 'status' => BankingConnectionStatus::Active, 'error_message' => null, + 'consecutive_sync_failures' => 0, ]); SyncBankingConnectionJob::dispatch($connection); diff --git a/app/Jobs/SyncAllBankingConnectionsJob.php b/app/Jobs/SyncAllBankingConnectionsJob.php index 4250004c..2ab2f3ca 100644 --- a/app/Jobs/SyncAllBankingConnectionsJob.php +++ b/app/Jobs/SyncAllBankingConnectionsJob.php @@ -21,7 +21,13 @@ class SyncAllBankingConnectionsJob implements ShouldQueue public function handle(): void { BankingConnection::query() - ->where('status', BankingConnectionStatus::Active) + ->where(function ($query) { + $query->where('status', BankingConnectionStatus::Active) + ->orWhere(function ($query) { + $query->where('status', BankingConnectionStatus::Error) + ->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES); + }); + }) ->where(function ($query) { $query->whereNull('valid_until') ->orWhere('valid_until', '>', now()); diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index e66cef6a..9695adb6 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -3,9 +3,11 @@ namespace App\Jobs; use App\Enums\BankingConnectionStatus; +use App\Enums\BankingSyncLogStatus; use App\Mail\BankingConnectionAuthFailedEmail; use App\Mail\BankTransactionsSyncedEmail; use App\Models\BankingConnection; +use App\Models\BankingSyncLog; use App\Services\Banking\BalanceSyncService; use App\Services\Banking\BinanceBalanceSyncService; use App\Services\Banking\BinanceClient; @@ -32,6 +34,12 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue public int $backoff = 30; + /** + * 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, @@ -45,20 +53,26 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue public function handle(TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void { $connection = $this->bankingConnection; + $startTime = microtime(true); if ($connection->isEnableBanking() && $connection->isExpired()) { $connection->update(['status' => BankingConnectionStatus::Expired]); Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]); + $this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'expired']); + return; } - if (! $connection->isActive()) { + if (! $this->isSyncableStatus($connection)) { + $this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'not_syncable', 'status' => $connection->status->value]); + return; } try { $isFirstSync = ! $connection->last_synced_at || $this->fullSync; + $metadata = []; if ($connection->isIndexaCapital()) { $this->syncIndexaCapital($connection, $isFirstSync); @@ -67,37 +81,112 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } elseif ($connection->isBitpanda()) { $this->syncBitpanda($connection); } else { - $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync); + $metadata = $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync); } $connection->update([ + 'status' => BankingConnectionStatus::Active, 'last_synced_at' => now(), 'error_message' => null, + 'consecutive_sync_failures' => 0, ]); + + $this->logSyncAttempt($connection, BankingSyncLogStatus::Success, $startTime, metadata: $metadata ?: null); } catch (\Throwable $e) { Log::error('Banking sync failed', [ 'connection_id' => $connection->id, 'error' => $e->getMessage(), + 'attempt' => $this->attempts(), ]); if ($this->isRateLimitError($e)) { + $this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e); + return; } + $this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e); + + if ($this->isAuthError($e)) { + $this->handlePermanentError($connection, $e); + + return; + } + + $this->handleTemporaryError($connection, $e); + } + } + + /** + * Handle permanent errors (auth failures) that should not be retried. + */ + private function handlePermanentError(BankingConnection $connection, \Throwable $e): void + { + $connection->update([ + 'status' => BankingConnectionStatus::Error, + 'error_message' => $this->friendlyErrorMessage($e), + 'consecutive_sync_failures' => self::MAX_SCHEDULED_RETRIES + 1, + ]); + + if ($this->isApiKeyProvider($connection)) { + Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail( + $connection->user, + $connection, + )); + } + + $this->fail($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, ]); - - if ($this->isAuthError($e) && $this->isApiKeyProvider($connection) && $this->attempts() >= $this->tries) { - Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail( - $connection->user, - $connection, - )); - } - - throw $e; } + + 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 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 syncIndexaCapital(BankingConnection $connection, bool $isFirstSync): void @@ -141,7 +230,10 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } } - private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync, bool $isFirstSync): void + /** + * @return array + */ + private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync, bool $isFirstSync): array { $dateFrom = $isFirstSync ? now()->subYear()->toDateString() @@ -189,6 +281,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue $transactionsPerBank, )); } + + return ['transactions_synced' => $totalTransactions, 'transactions_per_bank' => $transactionsPerBank]; } private function friendlyErrorMessage(\Throwable $e): string diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index 87942831..43f6bf14 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property BankingConnectionStatus $status * @property Carbon|null $valid_until * @property Carbon|null $last_synced_at + * @property int $consecutive_sync_failures * @property array|null $pending_accounts_data */ class BankingConnection extends Model @@ -36,6 +38,7 @@ class BankingConnection extends Model 'valid_until', 'last_synced_at', 'error_message', + 'consecutive_sync_failures', 'pending_accounts_data', 'api_token', 'api_secret', @@ -73,6 +76,18 @@ class BankingConnection extends Model return $this->hasMany(Account::class); } + /** @return HasMany */ + public function syncLogs(): HasMany + { + return $this->hasMany(BankingSyncLog::class); + } + + /** @return HasOne */ + public function latestSyncLog(): HasOne + { + return $this->hasOne(BankingSyncLog::class)->latestOfMany(); + } + public function isActive(): bool { return $this->status === BankingConnectionStatus::Active; diff --git a/app/Models/BankingSyncLog.php b/app/Models/BankingSyncLog.php new file mode 100644 index 00000000..5c8514ba --- /dev/null +++ b/app/Models/BankingSyncLog.php @@ -0,0 +1,38 @@ + BankingSyncLogStatus::class, + 'metadata' => 'array', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function bankingConnection(): BelongsTo + { + return $this->belongsTo(BankingConnection::class); + } +} diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php index 2a286c1b..6c29f2cc 100644 --- a/database/factories/BankingConnectionFactory.php +++ b/database/factories/BankingConnectionFactory.php @@ -122,6 +122,7 @@ class BankingConnectionFactory extends Factory return $this->state(fn (array $attributes) => [ 'status' => BankingConnectionStatus::Error, 'error_message' => 'Connection failed: bank returned an error', + 'consecutive_sync_failures' => 1, ]); } } diff --git a/database/migrations/2026_03_30_123011_add_consecutive_sync_failures_to_banking_connections_table.php b/database/migrations/2026_03_30_123011_add_consecutive_sync_failures_to_banking_connections_table.php new file mode 100644 index 00000000..443b5246 --- /dev/null +++ b/database/migrations/2026_03_30_123011_add_consecutive_sync_failures_to_banking_connections_table.php @@ -0,0 +1,28 @@ +unsignedTinyInteger('consecutive_sync_failures')->default(0)->after('error_message'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('banking_connections', function (Blueprint $table) { + $table->dropColumn('consecutive_sync_failures'); + }); + } +}; diff --git a/database/migrations/2026_03_30_123011_create_banking_sync_logs_table.php b/database/migrations/2026_03_30_123011_create_banking_sync_logs_table.php new file mode 100644 index 00000000..0d704e64 --- /dev/null +++ b/database/migrations/2026_03_30_123011_create_banking_sync_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignUuid('banking_connection_id')->constrained()->cascadeOnDelete(); + $table->string('status'); + $table->unsignedTinyInteger('attempt')->default(1); + $table->text('error_message')->nullable(); + $table->string('error_class')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('banking_sync_logs'); + } +}; diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php index 4a46785e..7eaef6f4 100644 --- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php +++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php @@ -626,7 +626,7 @@ test('bitpanda sync does not send email', function () { Mail::assertNothingQueued(); }); -test('sends auth failed email on final retry for indexa capital 401 error', function () { +test('sends auth failed email immediately for indexa capital 401 error', function () { Mail::fake(); $user = User::factory()->onboarded()->create(); @@ -647,38 +647,26 @@ test('sends auth failed email on final retry for indexa capital 401 error', func $transactionSync = Mockery::mock(TransactionSyncService::class); $balanceSync = Mockery::mock(BalanceSyncService::class); - // Simulate final attempt (3 of 3) - SHOULD send email $job = new SyncBankingConnectionJob($connection); $mockQueueJob = Mockery::mock(Job::class); - $mockQueueJob->shouldReceive('attempts')->andReturn(3); + $mockQueueJob->shouldReceive('attempts')->andReturn(1); $mockQueueJob->shouldReceive('isReleased')->andReturn(false); $mockQueueJob->shouldReceive('isDeletedOrReleased')->andReturn(false); $mockQueueJob->shouldReceive('hasFailed')->andReturn(false); + $mockQueueJob->shouldReceive('fail')->once(); $job->job = $mockQueueJob; - expect($connection->isActive())->toBeTrue(); - expect($job->attempts())->toBe(3); - - $threw = false; - - try { - $job->handle($transactionSync, $balanceSync); - } catch (Throwable $e) { - $threw = true; - expect($e)->toBeInstanceOf(RequestException::class); - expect($e->response->status())->toBe(401); - } - - expect($threw)->toBeTrue(); + $job->handle($transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Error); expect($connection->error_message)->toContain('Authentication failed'); + expect($connection->consecutive_sync_failures)->toBe(SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1); Mail::assertQueued(BankingConnectionAuthFailedEmail::class); }); -test('does not send auth failed email before final retry attempt', function () { +test('auth error sends email and fails job on any attempt', function () { Mail::fake(); $user = User::factory()->onboarded()->create(); @@ -699,21 +687,22 @@ test('does not send auth failed email before final retry attempt', function () { $transactionSync = Mockery::mock(TransactionSyncService::class); $balanceSync = Mockery::mock(BalanceSyncService::class); - // Simulate attempt 1 of 3 - should NOT send email + // Simulate attempt 1 of 3 - auth errors should STILL send email immediately $job = new SyncBankingConnectionJob($connection); $job->job = Mockery::mock(Job::class); $job->job->shouldReceive('attempts')->andReturn(1); $job->job->shouldReceive('isReleased')->andReturn(false); $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); $job->job->shouldReceive('hasFailed')->andReturn(false); + $job->job->shouldReceive('fail')->once(); - try { - $job->handle($transactionSync, $balanceSync); - } catch (Throwable) { - // Expected - } + $job->handle($transactionSync, $balanceSync); - Mail::assertNotQueued(BankingConnectionAuthFailedEmail::class); + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Error); + expect($connection->consecutive_sync_failures)->toBe(SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1); + + Mail::assertQueued(BankingConnectionAuthFailedEmail::class); }); test('does not send auth failed email for non-auth errors', function () { @@ -791,7 +780,7 @@ test('does not send auth failed email for enablebanking connections', function ( Mail::assertNotQueued(BankingConnectionAuthFailedEmail::class); }); -test('sends auth failed email for binance 403 error on final attempt', function () { +test('sends auth failed email immediately for binance 403 error', function () { Mail::fake(); $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); @@ -815,16 +804,13 @@ test('sends auth failed email for binance 403 error on final attempt', function $job = new SyncBankingConnectionJob($connection); $job->job = Mockery::mock(Job::class); - $job->job->shouldReceive('attempts')->andReturn(3); + $job->job->shouldReceive('attempts')->andReturn(1); $job->job->shouldReceive('isReleased')->andReturn(false); $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); $job->job->shouldReceive('hasFailed')->andReturn(false); + $job->job->shouldReceive('fail')->once(); - try { - $job->handle($transactionSync, $balanceSync); - } catch (Throwable) { - // Expected - } + $job->handle($transactionSync, $balanceSync); Mail::assertQueued(BankingConnectionAuthFailedEmail::class, function ($mail) use ($user, $connection) { return $mail->hasTo($user->email) diff --git a/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php new file mode 100644 index 00000000..ac2be600 --- /dev/null +++ b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php @@ -0,0 +1,531 @@ +onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->andThrow( + new RequestException( + new Illuminate\Http\Client\Response(new Response(500)) + ) + ); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + + // Simulate attempt 1 of 3 + $job = new SyncBankingConnectionJob($connection); + $job->job = Mockery::mock(Job::class); + $job->job->shouldReceive('attempts')->andReturn(1); + $job->job->shouldReceive('isReleased')->andReturn(false); + $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->job->shouldReceive('hasFailed')->andReturn(false); + + $threw = false; + + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + $threw = true; + } + + expect($threw)->toBeTrue(); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->error_message)->toBeNull(); + expect($connection->consecutive_sync_failures)->toBe(0); +}); + +test('temporary error on final attempt sets error status and increments consecutive failures', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->andThrow( + new RequestException( + new Illuminate\Http\Client\Response(new Response(500)) + ) + ); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + + // Simulate final attempt (3 of 3) + $job = new SyncBankingConnectionJob($connection); + $job->job = Mockery::mock(Job::class); + $job->job->shouldReceive('attempts')->andReturn(3); + $job->job->shouldReceive('isReleased')->andReturn(false); + $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->job->shouldReceive('hasFailed')->andReturn(false); + + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected + } + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Error); + expect($connection->error_message)->toContain('provider is experiencing issues'); + expect($connection->consecutive_sync_failures)->toBe(1); +}); + +test('consecutive sync failures accumulate across dispatch cycles', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + 'consecutive_sync_failures' => 2, + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->andThrow( + new RequestException( + new Illuminate\Http\Client\Response(new Response(500)) + ) + ); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + + // Simulate final attempt of a new dispatch cycle + $job = new SyncBankingConnectionJob($connection); + $job->job = Mockery::mock(Job::class); + $job->job->shouldReceive('attempts')->andReturn(3); + $job->job->shouldReceive('isReleased')->andReturn(false); + $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->job->shouldReceive('hasFailed')->andReturn(false); + + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected + } + + $connection->refresh(); + expect($connection->consecutive_sync_failures)->toBe(3); +}); + +test('successful sync resets consecutive failures', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + 'consecutive_sync_failures' => 2, + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once()->andReturn(0); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->error_message)->toBeNull(); + expect($connection->consecutive_sync_failures)->toBe(0); +}); + +test('connection in error status can be synced', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once()->andReturn(3); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->last_synced_at)->not->toBeNull(); + expect($connection->error_message)->toBeNull(); +}); + +test('auth error sets consecutive failures beyond the cap', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->indexaCapital()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'IC-001', + ]); + + Http::fake([ + 'api.indexacapital.com/*' => Http::response(['error' => 'Unauthorized'], 401), + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $balanceSync = Mockery::mock(BalanceSyncService::class); + + $job = new SyncBankingConnectionJob($connection); + $job->job = Mockery::mock(Job::class); + $job->job->shouldReceive('attempts')->andReturn(1); + $job->job->shouldReceive('isReleased')->andReturn(false); + $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->job->shouldReceive('hasFailed')->andReturn(false); + $job->job->shouldReceive('fail')->once(); + + $job->handle($transactionSync, $balanceSync); + + $connection->refresh(); + expect($connection->consecutive_sync_failures)->toBe(SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1); +}); + +// --- Sync Log Tests --- + +test('successful sync creates a success log entry', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once()->andReturn(5); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); + expect($log)->not->toBeNull(); + expect($log->status)->toBe(BankingSyncLogStatus::Success); + expect($log->error_message)->toBeNull(); + expect($log->error_class)->toBeNull(); + expect($log->duration_ms)->toBeGreaterThanOrEqual(0); + expect($log->metadata['transactions_synced'])->toBe(5); + expect($log->metadata)->toHaveKey('transactions_per_bank'); +}); + +test('failed sync creates a failed log entry', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->andThrow( + new RequestException( + new Illuminate\Http\Client\Response(new Response(500)) + ) + ); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + + $job = new SyncBankingConnectionJob($connection); + $job->job = Mockery::mock(Job::class); + $job->job->shouldReceive('attempts')->andReturn(1); + $job->job->shouldReceive('isReleased')->andReturn(false); + $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->job->shouldReceive('hasFailed')->andReturn(false); + + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected + } + + $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); + expect($log)->not->toBeNull(); + expect($log->status)->toBe(BankingSyncLogStatus::Failed); + expect($log->attempt)->toBe(1); + expect($log->error_message)->not->toBeNull(); + expect($log->error_class)->toBe(RequestException::class); +}); + +test('skipped sync creates a skipped log entry for expired connection', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'valid_until' => now()->subDay(), + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $balanceSync = Mockery::mock(BalanceSyncService::class); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); + expect($log)->not->toBeNull(); + expect($log->status)->toBe(BankingSyncLogStatus::Skipped); + expect($log->metadata)->toBe(['reason' => 'expired']); +}); + +test('skipped sync creates a skipped log entry for non-syncable status', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $balanceSync = Mockery::mock(BalanceSyncService::class); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); + expect($log)->not->toBeNull(); + expect($log->status)->toBe(BankingSyncLogStatus::Skipped); + expect($log->metadata)->toMatchArray(['reason' => 'not_syncable']); +}); + +test('rate limit error creates a failed log entry', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->andThrow( + new RequestException( + new Illuminate\Http\Client\Response(new Response(429)) + ) + ); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); + expect($log)->not->toBeNull(); + expect($log->status)->toBe(BankingSyncLogStatus::Failed); +}); + +test('sync log records attempt number', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->andThrow( + new RequestException( + new Illuminate\Http\Client\Response(new Response(500)) + ) + ); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + + // Simulate attempt 2 of 3 + $job = new SyncBankingConnectionJob($connection); + $job->job = Mockery::mock(Job::class); + $job->job->shouldReceive('attempts')->andReturn(2); + $job->job->shouldReceive('isReleased')->andReturn(false); + $job->job->shouldReceive('isDeletedOrReleased')->andReturn(false); + $job->job->shouldReceive('hasFailed')->andReturn(false); + + try { + $job->handle($transactionSync, $balanceSync); + } catch (RequestException) { + // Expected + } + + $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); + expect($log->attempt)->toBe(2); +}); + +test('connection sync logs relationship works', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subDay(), + ]); + Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $transactionSync = Mockery::mock(TransactionSyncService::class); + $transactionSync->shouldReceive('sync')->once()->andReturn(0); + + $balanceSync = Mockery::mock(BalanceSyncService::class); + $balanceSync->shouldReceive('sync')->once(); + + $job = new SyncBankingConnectionJob($connection); + $job->handle($transactionSync, $balanceSync); + + expect($connection->syncLogs()->count())->toBe(1); + expect($connection->latestSyncLog)->not->toBeNull(); + expect($connection->latestSyncLog->status)->toBe(BankingSyncLogStatus::Success); +}); + +// --- SyncAllBankingConnectionsJob Retry Tests --- + +test('scheduled sync includes error connections under retry cap', function () { + Queue::fake(SyncBankingConnectionJob::class); + + $user = User::factory()->onboarded()->create(); + + $activeConnection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + ]); + + $errorConnection = BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'consecutive_sync_failures' => 1, + ]); + + $job = new SyncAllBankingConnectionsJob; + $job->handle(); + + Queue::assertPushed(SyncBankingConnectionJob::class, 2); + Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $activeConnection->id); + Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $errorConnection->id); +}); + +test('scheduled sync excludes error connections at or over retry cap', function () { + Queue::fake(SyncBankingConnectionJob::class); + + $user = User::factory()->onboarded()->create(); + + $activeConnection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + ]); + + // At the cap - should be excluded + BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'consecutive_sync_failures' => SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES, + ]); + + // Over the cap (auth error) - should be excluded + BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'consecutive_sync_failures' => SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1, + ]); + + $job = new SyncAllBankingConnectionsJob; + $job->handle(); + + Queue::assertPushed(SyncBankingConnectionJob::class, 1); + Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $activeConnection->id); +}); + +test('scheduled sync excludes error connections with expired valid_until', function () { + Queue::fake(SyncBankingConnectionJob::class); + + $user = User::factory()->onboarded()->create(); + + BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'consecutive_sync_failures' => 1, + 'valid_until' => now()->subDay(), + ]); + + $job = new SyncAllBankingConnectionsJob; + $job->handle(); + + Queue::assertNotPushed(SyncBankingConnectionJob::class); +}); + +// --- Manual Retry Reset Tests --- + +test('manual sync resets consecutive sync failures', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'consecutive_sync_failures' => 2, + ]); + + Queue::fake(SyncBankingConnectionJob::class); + + $this->actingAs($user) + ->post(route('settings.connections.sync', $connection)) + ->assertRedirect(); + + $connection->refresh(); + expect($connection->consecutive_sync_failures)->toBe(0); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->error_message)->toBeNull(); +});