fix(banking): clamp and retry the sync window when the bank rejects the period

On WrongTransactionsPeriodException, restart the account sync from the first
page with a progressively narrower window (90/30/7 days before date_to) so the
user still gets the history the bank is willing to serve, instead of the whole
connection sync crashing. strategy='longest' is dropped on the narrowed retry so
the explicit date_from is honoured, and re-fetched pages are idempotent (dedup +
date-keyed daily balances). If even the narrowest window is refused, the
exception is rethrown so the syncer can skip just that account.
This commit is contained in:
Víctor Falcón 2026-07-07 02:53:07 +02:00
parent a05e020b00
commit 5bdf734c42
2 changed files with 172 additions and 17 deletions

View File

@ -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<int>
*/
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.
*

View File

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