diff --git a/app/Services/Banking/TransactionSyncService.php b/app/Services/Banking/TransactionSyncService.php index c6292ea1..ef05684f 100644 --- a/app/Services/Banking/TransactionSyncService.php +++ b/app/Services/Banking/TransactionSyncService.php @@ -4,12 +4,24 @@ namespace App\Services\Banking; use App\Contracts\BankingProviderInterface; use App\Enums\TransactionSource; +use App\Exceptions\Banking\WrongTransactionsPeriodException; use App\Models\Account; use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; class TransactionSyncService { + /** + * Fallback lookback windows (in days back from date_to) tried in order when + * the bank rejects the requested transactions period as too wide. Ordered + * widest-first so the user keeps as much history as the bank will serve; + * the last step is the floor before the account is skipped. + * + * @var list + */ + private const array WRONG_PERIOD_LOOKBACK_DAYS = [90, 30, 7]; + public function __construct( private BankingProviderInterface $provider, private TransactionDescriptionFormatter $descriptionFormatter, @@ -40,27 +52,59 @@ class TransactionSyncService // ever dwarfs its sync window, narrow this to the incoming batch's keys. [$knownFingerprints, $knownExternalIds] = $this->loadExistingDedupKeys($account); - do { - $result = $this->provider->getTransactions( - $account->external_account_id, - $dateFrom, - $dateTo, - $continuationKey, - $strategy, - ); + // The bank can reject the requested window as too wide (HTTP 422). When + // that happens, restart the account from the first page with a + // progressively narrower window so the user still gets the history the + // bank is willing to serve, instead of crashing the whole connection + // sync. Re-fetched pages are idempotent (dedup skips already-imported + // rows; daily balances are keyed by date), and strategy is dropped on + // the narrowed retry so the explicit date_from is honoured rather than + // overridden by "longest". + while (true) { + try { + $continuationKey = null; - foreach ($result['transactions'] as $transaction) { - if ($this->importTransaction($account, $transaction, $bankName, $knownFingerprints, $knownExternalIds)) { - $created++; + do { + $result = $this->provider->getTransactions( + $account->external_account_id, + $dateFrom, + $dateTo, + $continuationKey, + $strategy, + ); + + foreach ($result['transactions'] as $transaction) { + if ($this->importTransaction($account, $transaction, $bankName, $knownFingerprints, $knownExternalIds)) { + $created++; + } + + if ($saveDailyBalances) { + $this->trackDailyBalance($transaction, $dailyBalances); + } + } + + $continuationKey = $result['continuation_key']; + } while ($continuationKey); + + break; + } catch (WrongTransactionsPeriodException $e) { + $narrowedDateFrom = $this->nextNarrowerDateFrom($dateFrom, $dateTo); + + if ($narrowedDateFrom === null) { + throw $e; } - if ($saveDailyBalances) { - $this->trackDailyBalance($transaction, $dailyBalances); - } + Log::warning('EnableBanking rejected the transactions period; retrying with a narrower window', [ + 'account_id' => $account->id, + 'rejected_date_from' => $dateFrom, + 'retry_date_from' => $narrowedDateFrom, + 'date_to' => $dateTo, + ]); + + $dateFrom = $narrowedDateFrom; + $strategy = null; } - - $continuationKey = $result['continuation_key']; - } while ($continuationKey); + } if ($saveDailyBalances) { $this->saveDailyBalances($account, $dailyBalances); @@ -76,6 +120,28 @@ class TransactionSyncService return $created; } + /** + * The next window start strictly narrower (later) than the current one, + * stepping down the bounded lookback ladder. Returns null when no ladder + * step narrows the current window, so the caller gives up on the account + * rather than looping forever. Candidates are always <= date_to, so the + * window never inverts. Dates are 'Y-m-d', where string order is date order. + */ + private function nextNarrowerDateFrom(string $dateFrom, string $dateTo): ?string + { + $to = Carbon::parse($dateTo); + + foreach (self::WRONG_PERIOD_LOOKBACK_DAYS as $days) { + $candidate = $to->copy()->subDays($days)->toDateString(); + + if ($candidate > $dateFrom) { + return $candidate; + } + } + + return null; + } + /** * Import a single transaction, skipping duplicates. * diff --git a/tests/Feature/OpenBanking/TransactionSyncServiceTest.php b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php index 721272f7..a88ce545 100644 --- a/tests/Feature/OpenBanking/TransactionSyncServiceTest.php +++ b/tests/Feature/OpenBanking/TransactionSyncServiceTest.php @@ -2,6 +2,7 @@ use App\Contracts\BankingProviderInterface; use App\Enums\TransactionSource; +use App\Exceptions\Banking\WrongTransactionsPeriodException; use App\Models\Account; use App\Models\Bank; use App\Models\BankingConnection; @@ -674,3 +675,91 @@ test('sync dedupes against soft-deleted fingerprinted transactions', function () expect($account->transactions()->withTrashed()->count())->toBe(1); expect($account->transactions()->withTrashed()->first()->trashed())->toBeTrue(); }); + +test('sync clamps date_from and retries once when the bank rejects the period', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + + // Requested 92-day window is refused; the ladder's widest step (90 days + // before date_to = 2026-04-08) is retried, with strategy dropped to null. + $mockProvider->shouldReceive('getTransactions') + ->with('ext-123', '2026-04-06', '2026-07-07', null, 'longest') + ->once() + ->andThrow(new WrongTransactionsPeriodException('too wide')); + + $mockProvider->shouldReceive('getTransactions') + ->with('ext-123', '2026-04-08', '2026-07-07', null, null) + ->once() + ->andReturn([ + 'transactions' => [ + [ + 'transaction_id' => 'txn-001', + 'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'], + 'credit_debit_indicator' => 'DBIT', + 'booking_date' => '2026-04-15', + 'remittance_information' => ['Grocery Store Purchase'], + ], + ], + 'continuation_key' => null, + ]); + + $service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter); + $created = $service->sync($account, '2026-04-06', '2026-07-07', 'longest', saveDailyBalances: false); + + expect($created)->toBe(1); + expect($account->transactions()->count())->toBe(1); +}); + +test('sync gives up on the account when even the narrowest window is rejected', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + + // Initial attempt + one per ladder step (90/30/7) = 4 refused attempts, + // then the service rethrows so the caller (syncer) can skip the account. + $mockProvider->shouldReceive('getTransactions') + ->times(4) + ->andThrow(new WrongTransactionsPeriodException('too wide')); + + $service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter); + + expect(fn () => $service->sync($account, '2026-04-06', '2026-07-07', saveDailyBalances: false)) + ->toThrow(WrongTransactionsPeriodException::class); +}); + +test('sync does not retry when the rejected window is already narrow', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create(['user_id' => $user->id]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + + // date_from is only 3 days back — no ladder step narrows it further, so the + // exception is rethrown immediately without a pointless retry. + $mockProvider->shouldReceive('getTransactions') + ->with('ext-123', '2026-07-04', '2026-07-07', null, null) + ->once() + ->andThrow(new WrongTransactionsPeriodException('too wide')); + + $service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter); + + expect(fn () => $service->sync($account, '2026-07-04', '2026-07-07', saveDailyBalances: false)) + ->toThrow(WrongTransactionsPeriodException::class); +});