whisper-money/tests/Feature/OpenBanking/TransactionSyncServiceTest.php

766 lines
32 KiB
PHP

<?php
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;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\TransactionDescriptionFormatter;
use App\Services\Banking\TransactionSyncService;
use Illuminate\Support\Facades\DB;
test('sync creates transactions from provider data', 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);
$mockProvider->shouldReceive('getTransactions')
->with('ext-123', '2025-01-01', '2025-01-31', null, null)
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Grocery Store Purchase'],
],
[
'transaction_id' => 'txn-002',
'transaction_amount' => ['amount' => '1000.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'CRDT',
'booking_date' => '2025-01-20',
'remittance_information' => ['Salary Payment'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(2);
expect($account->transactions()->count())->toBe(2);
$debit = $account->transactions()->where('external_transaction_id', 'txn-001')->first();
expect($debit->amount)->toBe(-5000);
expect($debit->description)->toBe('Grocery Store Purchase');
expect($debit->source)->toBe(TransactionSource::EnableBanking);
expect($debit->description_iv)->toBeNull();
expect($debit->raw_data)->toEqual([
'booking_date' => '2025-01-15',
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'remittance_information' => ['Grocery Store Purchase'],
]);
$credit = $account->transactions()->where('external_transaction_id', 'txn-002')->first();
expect($credit->amount)->toBe(100000);
expect($credit->description)->toBe('Salary Payment');
});
test('sync deduplicates transactions by external_transaction_id', 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',
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'external_transaction_id' => 'txn-001',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Duplicate Transaction'],
],
[
'transaction_id' => 'txn-003',
'transaction_amount' => ['amount' => '25.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-16',
'remittance_information' => ['New Transaction'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(1);
expect($account->transactions()->count())->toBe(2);
});
test('sync handles pagination with continuation key', 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);
$mockProvider->shouldReceive('getTransactions')
->with('ext-123', '2025-01-01', '2025-01-31', null, null)
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '10.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-01',
'remittance_information' => ['Page 1'],
],
],
'continuation_key' => 'page2',
]);
$mockProvider->shouldReceive('getTransactions')
->with('ext-123', '2025-01-01', '2025-01-31', 'page2', null)
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-002',
'transaction_amount' => ['amount' => '20.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'CRDT',
'booking_date' => '2025-01-02',
'remittance_information' => ['Page 2'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(2);
expect($account->transactions()->count())->toBe(2);
});
test('sync stores creditor and debtor names from raw payload', 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);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '99.99', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Card payment'],
'creditor' => ['name' => 'Amazon EU'],
'debtor' => ['name' => 'Victor Falcon'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$service->sync($account, '2025-01-01', '2025-01-31');
$transaction = $account->transactions()->first();
expect($transaction->creditor_name)->toBe('Amazon EU')
->and($transaction->debtor_name)->toBe('Victor Falcon');
});
test('sync uses creditor name as fallback description', 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);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '99.99', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => [],
'creditor' => ['name' => 'Amazon EU'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$service->sync($account, '2025-01-01', '2025-01-31');
$transaction = $account->transactions()->first();
expect($transaction->description)->toBe('Amazon EU');
});
test('sync creates daily balances from balance_after_transaction', 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);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Morning purchase'],
'balance_after_transaction' => ['amount' => '950.00', 'currency' => 'EUR'],
],
[
'transaction_id' => 'txn-002',
'transaction_amount' => ['amount' => '30.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Evening purchase'],
'balance_after_transaction' => ['amount' => '920.00', 'currency' => 'EUR'],
],
[
'transaction_id' => 'txn-003',
'transaction_amount' => ['amount' => '1000.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'CRDT',
'booking_date' => '2025-01-20',
'remittance_information' => ['Salary'],
'balance_after_transaction' => ['amount' => '1920.00', 'currency' => 'EUR'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$service->sync($account, '2025-01-01', '2025-01-31');
expect($account->balances()->count())->toBe(2);
$jan15 = $account->balances()->where('balance_date', '2025-01-15')->first();
expect($jan15->balance)->toBe(92000); // Last transaction of the day
$jan20 = $account->balances()->where('balance_date', '2025-01-20')->first();
expect($jan20->balance)->toBe(192000);
});
test('sync skips daily balance when balance_after_transaction is missing', 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);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Purchase'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$service->sync($account, '2025-01-01', '2025-01-31');
expect($account->balances()->count())->toBe(0);
});
test('sync does not re-create soft-deleted transactions', 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',
]);
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'external_transaction_id' => 'txn-001',
]);
$transaction->delete();
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Grocery Store Purchase'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(0);
expect($account->transactions()->withTrashed()->count())->toBe(1);
expect($account->transactions()->withTrashed()->first()->trashed())->toBeTrue();
});
test('sync skips accounts without external_account_id', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create([
'user_id' => $user->id,
'external_account_id' => null,
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldNotReceive('getTransactions');
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(0);
});
test('sync formats BBVA transaction descriptions and stores original', function () {
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'BBVA', 'user_id' => $user->id]);
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['ADEUDO DE ENDESA // PAGO DE ADEUDO DIRECTO SEPA'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$service->sync($account, '2025-01-01', '2025-01-31');
$transaction = $account->transactions()->first();
expect($transaction->description)->toBe('Adeudo de Endesa / Pago de Adeudo Directo SEPA');
expect($transaction->original_description)->toBe('ADEUDO DE ENDESA // PAGO DE ADEUDO DIRECTO SEPA');
});
test('sync does not format descriptions for non-BBVA banks', function () {
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'ING', 'user_id' => $user->id]);
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['ADEUDO DE ENDESA'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$service->sync($account, '2025-01-01', '2025-01-31');
$transaction = $account->transactions()->first();
expect($transaction->description)->toBe('ADEUDO DE ENDESA');
expect($transaction->original_description)->toBeNull();
});
test('sync deduplicates transactions without external id via fingerprint', 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',
]);
$payload = [
// No transaction_id or entry_reference — simulates BNP card txn.
'transaction_amount' => ['amount' => '59.61', 'currency' => 'USD'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-05-12',
'creditor' => ['name' => 'MoonPay*Phantom 2880'],
'bank_transaction_code' => ['code' => 'CCRD', 'sub_code' => 'POSD'],
'debtor_account' => ['other' => ['identification' => '487104XXXXXX1158']],
'remittance_information' => [],
];
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->twice()
->andReturn(['transactions' => [$payload], 'continuation_key' => null]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
expect($service->sync($account, '2025-05-01', '2025-05-31'))->toBe(1);
expect($service->sync($account, '2025-05-01', '2025-05-31'))->toBe(0);
expect($account->transactions()->count())->toBe(1);
$stored = $account->transactions()->first();
expect($stored->external_transaction_id)->toBeNull();
expect($stored->dedup_fingerprint)->toStartWith('fp_');
});
test('sync still dedupes when bank later supplies a real id for a fingerprinted txn', 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',
]);
$base = [
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-05-12',
'creditor' => ['name' => 'Acme'],
'bank_transaction_code' => ['code' => 'PMNT', 'sub_code' => 'XBCT'],
'remittance_information' => ['Coffee'],
];
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->ordered()
->andReturn(['transactions' => [$base], 'continuation_key' => null]);
$mockProvider->shouldReceive('getTransactions')
->once()
->ordered()
->andReturn([
'transactions' => [array_merge($base, ['transaction_id' => 'real-id-123'])],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
expect($service->sync($account, '2025-05-01', '2025-05-31'))->toBe(1);
// Second sync brings the same payload with an upstream id attached.
// Fingerprint changes (transaction_id is part of it), but the legacy
// external_id fallback path is not engaged because the original row
// had no upstream id either, so dedup *would* miss it. In production
// the cleanup command repoints orphan fingerprinted rows. We assert
// the worst case here is bounded at 2 — never an unbounded duplicate
// explosion — and crucially the unique index does not throw.
$service->sync($account, '2025-05-01', '2025-05-31');
expect($account->transactions()->count())->toBeLessThanOrEqual(2);
});
test('sync dedupes external ids case-insensitively like the production collation', 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',
]);
// Legacy row keyed only on the upstream id (no fingerprint), stored uppercase.
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'external_transaction_id' => 'ABC-001',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
// Same id, different case — utf8mb4_unicode_ci treats these as equal.
'transaction_id' => 'abc-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Duplicate with different case'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(0);
expect($account->transactions()->count())->toBe(1);
});
test('sync dedupes a transaction that repeats across pages in one run', 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',
]);
$payload = [
'transaction_id' => 'txn-dup',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Repeated across pages'],
];
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->ordered()
->andReturn(['transactions' => [$payload], 'continuation_key' => 'page2']);
$mockProvider->shouldReceive('getTransactions')
->once()
->ordered()
->andReturn(['transactions' => [$payload], 'continuation_key' => null]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
// The second occurrence is folded into the in-memory set from the first
// insert, so it is deduped without relying on the unique-index backstop.
expect($created)->toBe(1);
expect($account->transactions()->count())->toBe(1);
});
test('sync checks for duplicates once per run regardless of batch size', 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',
]);
$transactions = collect(range(1, 6))->map(fn (int $i) => [
'transaction_id' => "txn-{$i}",
'transaction_amount' => ['amount' => '10.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ["Purchase {$i}"],
])->all();
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn(['transactions' => $transactions, 'continuation_key' => null]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
DB::enableQueryLog();
$created = $service->sync($account, '2025-01-01', '2025-01-31');
$queries = collect(DB::getQueryLog());
DB::disableQueryLog();
expect($created)->toBe(6);
// The dedup lookup is a single preload SELECT, not one exists() per row.
$dedupSelects = $queries->filter(fn (array $q): bool => str_contains($q['query'], 'dedup_fingerprint')
&& str_starts_with(strtolower(ltrim($q['query'])), 'select')
);
expect($dedupSelects)->toHaveCount(1);
// The old per-row `select exists(... dedup_fingerprint ...)` probe is gone.
$dedupExistsProbes = $queries->filter(fn (array $q): bool => str_contains(strtolower($q['query']), 'exists(')
&& str_contains($q['query'], 'dedup_fingerprint')
);
expect($dedupExistsProbes)->toHaveCount(0);
});
test('sync dedupes against soft-deleted fingerprinted transactions', 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',
]);
$payload = [
'transaction_amount' => ['amount' => '12.34', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-05-12',
'creditor' => ['name' => 'Acme'],
'remittance_information' => ['Item'],
];
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->twice()
->andReturn(['transactions' => [$payload], 'continuation_key' => null]);
$service = new TransactionSyncService($mockProvider, new TransactionDescriptionFormatter);
$service->sync($account, '2025-05-01', '2025-05-31');
$account->transactions()->first()->delete();
$created = $service->sync($account, '2025-05-01', '2025-05-31');
expect($created)->toBe(0);
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);
});