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:
parent
a05e020b00
commit
5bdf734c42
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue