fix(banking): retry failed sync connections and log every sync attempt (#251)

## Summary

- **Fixes the broken retry mechanism** — after the first failed attempt
set status to `Error`, subsequent retry attempts bailed out because
`isActive()` returns `false` for `Error`. Now both `Active` and `Error`
statuses are syncable.
- **Adds auto-retry across scheduled runs** —
`SyncAllBankingConnectionsJob` and `banking:sync` command now include
`Error` connections where `consecutive_sync_failures < 3` (configurable
via `MAX_SCHEDULED_RETRIES`). After 3 full dispatch cycles, manual
intervention is required.
- **Logs every sync attempt to DB** — new `banking_sync_logs` table
records status (Success/Failed/Skipped), attempt number, error details,
duration, and metadata for each sync.

## Changes

### Core logic (`SyncBankingConnectionJob`)
- `isSyncableStatus()` allows both `Active` and `Error` through the gate
- Temporary errors: status only set to `Error` on the final attempt
(attempt 3); earlier attempts re-throw without changing status
- Permanent auth errors (401/403): `$this->fail()` called immediately,
`consecutive_sync_failures` set beyond the cap
- Rate limit (429): handled silently (existing behavior preserved)
- Every attempt is logged to `banking_sync_logs`

### Query updates
- `SyncAllBankingConnectionsJob`: includes `Error` connections under
retry cap
- `SyncBankingConnections` command: same query update for
`--user`/`--connection` filtered runs

### Controller updates
- `ConnectionController::sync()` and `updateCredentials()` reset
`consecutive_sync_failures` to 0

### New files
- `BankingSyncLogStatus` enum (Success, Failed, Skipped)
- `BankingSyncLog` model
- Two migrations: `add_consecutive_sync_failures` column,
`create_banking_sync_logs` table

### Tests
- Updated 3 existing auth error tests in `SyncBankingConnectionJobTest`
(24 pass)
- Added 17 new tests in `SyncRetryAndLoggingTest` covering retry
behavior, sync logging, scheduled retry inclusion/exclusion, and manual
retry reset
- All 10 `SyncBankingConnectionsCommandTest` tests still pass
This commit is contained in:
Víctor Falcón 2026-03-31 11:34:35 +01:00 committed by GitHub
parent 755452d6ce
commit f3b5929ecc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 797 additions and 46 deletions

View File

@ -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());

View File

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum BankingSyncLogStatus: string
{
case Success = 'success';
case Failed = 'failed';
case Skipped = 'skipped';
}

View File

@ -57,6 +57,7 @@ class ConnectionController extends Controller
$connection->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);

View File

@ -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());

View File

@ -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<string, mixed>
*/
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

View File

@ -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<int, mixed>|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<BankingSyncLog, $this> */
public function syncLogs(): HasMany
{
return $this->hasMany(BankingSyncLog::class);
}
/** @return HasOne<BankingSyncLog, $this> */
public function latestSyncLog(): HasOne
{
return $this->hasOne(BankingSyncLog::class)->latestOfMany();
}
public function isActive(): bool
{
return $this->status === BankingConnectionStatus::Active;

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Enums\BankingSyncLogStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BankingSyncLog extends Model
{
public $timestamps = false;
protected $fillable = [
'banking_connection_id',
'status',
'attempt',
'error_message',
'error_class',
'duration_ms',
'metadata',
'created_at',
];
protected function casts(): array
{
return [
'status' => BankingSyncLogStatus::class,
'metadata' => 'array',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<BankingConnection, $this> */
public function bankingConnection(): BelongsTo
{
return $this->belongsTo(BankingConnection::class);
}
}

View File

@ -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,
]);
}
}

View File

@ -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->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');
});
}
};

View File

@ -0,0 +1,34 @@
<?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::create('banking_sync_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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)

View File

@ -0,0 +1,531 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingSyncLogStatus;
use App\Jobs\SyncAllBankingConnectionsJob;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\BankingSyncLog;
use App\Models\User;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
use GuzzleHttp\Psr7\Response;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
// --- Retry Behavior Tests ---
test('temporary error on non-final attempt does not set error status', 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 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();
});