From 220b1e11f1bb6d28ca1c478999ef40e155ccc068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 16 Jun 2026 16:25:29 +0200 Subject: [PATCH] refactor(banking): scalable per-provider sync via a syncer factory (#545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Syncing was a growing `if/elseif` chain in `SyncBankingConnectionJob::handle()` plus one private method per provider. Every new provider meant editing the job, and the file mixed provider-specific logic with cross-cutting orchestration. This does not scale to dozens/hundreds of providers. ## What Introduces a **factory + strategy** pattern: - **`App\Contracts\BankingConnectionSyncer`** — interface: `sync()`, `expires()`, `notifiesOnAuthFailure()`. - **`AbstractBankingConnectionSyncer`** — defaults for the common case (API-key provider: never expires, notifies on auth failure). A new provider usually only implements `sync()`. - **`BankingConnectionSyncerFactory`** — maps `connection.provider` to its syncer (resolved from the container, so dependencies are injected). - **Six syncers** — `IndexaCapitalSyncer`, `BinanceSyncer`, `WiseSyncer`, `BitpandaSyncer`, `CoinbaseSyncer`, `EnableBankingSyncer`. Each fully owns its provider behavior, including EnableBanking's daily email and first-sync cutoff. `SyncBankingConnectionJob` drops from ~520 to ~190 lines and now only orchestrates: deleted-user/expiry/rate-limit checks, error handling, retries, logging, and status updates. It asks the syncer `expires()` / `notifiesOnAuthFailure()` instead of hardcoding provider lists. ### Adding a provider now 1. Create `XSyncer extends AbstractBankingConnectionSyncer` implementing `sync()`. 2. Add one line to the factory map. No changes to the job. ## Tests - Existing job tests (`SyncBankingConnectionJobTest`, `SyncRetryAndLoggingTest`) migrated through a shared `runSync()` helper in `tests/Pest.php`; all original assertions preserved. - New `BankingConnectionSyncerFactoryTest`: provider→syncer resolution, unknown-provider exception, and the capability flags. - Full suite green: **1604 passed**, 0 failures. PHPStan clean, Pint applied. ## Docs Adds `docs/adding-a-banking-provider.md` — a step-by-step developer guide (usable by a human or an AI agent) covering the sync provider and the broader end-to-end integration touchpoints. --- app/Contracts/BankingConnectionSyncer.php | 25 ++ app/Jobs/SyncBankingConnectionJob.php | 191 +---------- .../Sync/AbstractBankingConnectionSyncer.php | 25 ++ .../Sync/BankingConnectionSyncerFactory.php | 24 ++ app/Services/Banking/Sync/BinanceSyncer.php | 31 ++ app/Services/Banking/Sync/BitpandaSyncer.php | 25 ++ app/Services/Banking/Sync/CoinbaseSyncer.php | 25 ++ .../Banking/Sync/EnableBankingSyncer.php | 78 +++++ .../Banking/Sync/IndexaCapitalSyncer.php | 25 ++ app/Services/Banking/Sync/WiseSyncer.php | 41 +++ docs/adding-a-banking-provider.md | 320 ++++++++++++++++++ .../BankingConnectionSyncerFactoryTest.php | 51 +++ .../SyncBankingConnectionJobTest.php | 62 ++-- .../OpenBanking/SyncRetryAndLoggingTest.php | 28 +- tests/Pest.php | 25 ++ 15 files changed, 752 insertions(+), 224 deletions(-) create mode 100644 app/Contracts/BankingConnectionSyncer.php create mode 100644 app/Services/Banking/Sync/AbstractBankingConnectionSyncer.php create mode 100644 app/Services/Banking/Sync/BankingConnectionSyncerFactory.php create mode 100644 app/Services/Banking/Sync/BinanceSyncer.php create mode 100644 app/Services/Banking/Sync/BitpandaSyncer.php create mode 100644 app/Services/Banking/Sync/CoinbaseSyncer.php create mode 100644 app/Services/Banking/Sync/EnableBankingSyncer.php create mode 100644 app/Services/Banking/Sync/IndexaCapitalSyncer.php create mode 100644 app/Services/Banking/Sync/WiseSyncer.php create mode 100644 docs/adding-a-banking-provider.md create mode 100644 tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php diff --git a/app/Contracts/BankingConnectionSyncer.php b/app/Contracts/BankingConnectionSyncer.php new file mode 100644 index 00000000..c515ecaf --- /dev/null +++ b/app/Contracts/BankingConnectionSyncer.php @@ -0,0 +1,25 @@ + Metadata to persist on the sync log. + */ + public function sync(BankingConnection $connection, bool $isFirstSync): array; + + /** + * Whether the connection's consent can expire (consent-based providers). + */ + public function expires(): bool; + + /** + * Whether a permanent auth failure should notify the user (API-key providers). + */ + public function notifiesOnAuthFailure(): bool; +} diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index 9307509a..7901f3f1 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Contracts\BankingConnectionSyncer; use App\Enums\BankingConnectionStatus; use App\Enums\BankingSyncLogStatus; use App\Exceptions\Banking\TransientBankingProviderException; @@ -9,19 +10,7 @@ use App\Mail\BankingConnectionAuthFailedEmail; use App\Mail\BankingConnectionExpiredEmail; use App\Models\BankingConnection; use App\Models\BankingSyncLog; -use App\Services\Banking\BalanceSyncService; -use App\Services\Banking\BinanceBalanceSyncService; -use App\Services\Banking\BinanceClient; -use App\Services\Banking\BitpandaBalanceSyncService; -use App\Services\Banking\BitpandaClient; -use App\Services\Banking\CoinbaseBalanceSyncService; -use App\Services\Banking\CoinbaseClient; -use App\Services\Banking\IndexaCapitalBalanceSyncService; -use App\Services\Banking\IndexaCapitalClient; -use App\Services\Banking\TransactionSyncService; -use App\Services\Banking\WiseBalanceSyncService; -use App\Services\Banking\WiseClient; -use App\Services\Banking\WiseTransactionSyncService; +use App\Services\Banking\Sync\BankingConnectionSyncerFactory; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -62,7 +51,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue return $this->bankingConnection->id; } - public function handle(TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void + public function handle(BankingConnectionSyncerFactory $syncerFactory): void { $connection = $this->bankingConnection; $startTime = microtime(true); @@ -79,7 +68,9 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue return; } - if ($connection->isEnableBanking() && $connection->isExpired()) { + $syncer = $syncerFactory->make($connection); + + if ($syncer->expires() && $connection->isExpired()) { $shouldNotify = $connection->status !== BankingConnectionStatus::Expired; $connection->update(['status' => BankingConnectionStatus::Expired]); @@ -119,42 +110,16 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue try { $isFirstSync = ! $connection->last_synced_at || $this->fullSync; - $metadata = []; - if ($connection->isIndexaCapital()) { - $this->syncIndexaCapital($connection, $isFirstSync); - } elseif ($connection->isBinance()) { - $this->syncBinance($connection, $isFirstSync); - } elseif ($connection->isWise()) { - $metadata = $this->syncWise($connection, $isFirstSync); - } elseif ($connection->isBitpanda()) { - $this->syncBitpanda($connection); - } elseif ($connection->isCoinbase()) { - $this->syncCoinbase($connection, $isFirstSync); - } else { - $metadata = $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync); + $metadata = $syncer->sync($connection, $isFirstSync); - if (! $isFirstSync && $connection->user->canReceiveEmails()) { - SendDailyBankTransactionsSyncedEmailJob::dispatch( - $connection->user, - $syncedAt->toDateString(), - ); - } - } - - $connectionUpdates = [ + $connection->update([ 'status' => BankingConnectionStatus::Active, 'last_synced_at' => $syncedAt, 'error_message' => null, 'rate_limited_until' => null, 'consecutive_sync_failures' => 0, - ]; - - if ($connection->isEnableBanking() && $isFirstSync) { - $connectionUpdates['bank_transactions_email_cutoff_at'] = now(); - } - - $connection->update($connectionUpdates); + ]); $this->logSyncAttempt($connection, BankingSyncLogStatus::Success, $startTime, metadata: $metadata ?: null); } catch (\Throwable $e) { @@ -182,7 +147,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue $this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e); if ($this->isAuthError($e)) { - $this->handlePermanentError($connection, $e); + $this->handlePermanentError($connection, $syncer, $e); return; } @@ -213,7 +178,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue ]); } - private function handlePermanentError(BankingConnection $connection, \Throwable $e): void + private function handlePermanentError(BankingConnection $connection, BankingConnectionSyncer $syncer, \Throwable $e): void { $connection->update([ 'status' => BankingConnectionStatus::Error, @@ -221,7 +186,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue 'consecutive_sync_failures' => self::MAX_SCHEDULED_RETRIES + 1, ]); - if ($connection->usesApiKey() && $connection->user?->canReceiveEmails()) { + if ($syncer->notifiesOnAuthFailure() && $connection->user?->canReceiveEmails()) { Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail( $connection->user, $connection, @@ -305,138 +270,6 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue ]); } - private function syncIndexaCapital(BankingConnection $connection, bool $isFirstSync): void - { - $client = new IndexaCapitalClient($connection->api_token); - $syncService = app(IndexaCapitalBalanceSyncService::class); - - $connection->load('accounts'); - - foreach ($connection->accounts as $account) { - $syncService->sync($account, $client, $isFirstSync); - } - } - - private function syncBinance(BankingConnection $connection, bool $isFirstSync): void - { - $client = new BinanceClient($connection->api_token, $connection->api_secret); - $syncService = app(BinanceBalanceSyncService::class); - - $connection->load('accounts'); - - foreach ($connection->accounts as $account) { - if ($isFirstSync) { - $syncService->syncCurrentBalance($account, $client); - SyncBinanceHistoricalBalancesJob::dispatch($account)->delay(now()->addSeconds(30)); - } else { - $syncService->sync($account, $client, isFirstSync: false); - } - } - } - - /** - * @return array - */ - private function syncWise(BankingConnection $connection, bool $isFirstSync): array - { - $dateFrom = $isFirstSync - ? now()->subYear()->toDateString() - : ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString()); - $dateTo = now()->toDateString(); - - $client = new WiseClient($connection->api_token); - $syncService = app(WiseTransactionSyncService::class); - $balanceSyncService = app(WiseBalanceSyncService::class); - - $connection->load('accounts'); - - $transactionsPerAccount = []; - - foreach ($connection->accounts as $account) { - $count = $syncService->sync($account, $client, $dateFrom, $dateTo); - $balanceSyncService->sync($account, $client); - $transactionsPerAccount[$account->name] = $count; - } - - return [ - 'transactions_synced' => array_sum($transactionsPerAccount), - 'transactions_per_account' => $transactionsPerAccount, - ]; - } - - private function syncBitpanda(BankingConnection $connection): void - { - $client = new BitpandaClient($connection->api_token); - $syncService = app(BitpandaBalanceSyncService::class); - - $connection->load('accounts'); - - foreach ($connection->accounts as $account) { - $syncService->sync($account, $client); - } - } - - private function syncCoinbase(BankingConnection $connection, bool $isFirstSync): void - { - $client = new CoinbaseClient($connection->api_token, $connection->api_secret); - $syncService = app(CoinbaseBalanceSyncService::class); - - $connection->load('accounts'); - - foreach ($connection->accounts as $account) { - $syncService->sync($account, $client, $isFirstSync, backfillMissingHistory: true); - } - } - - /** - * @return array - */ - private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync, bool $isFirstSync): array - { - $dateFrom = $isFirstSync - ? now()->subYear()->toDateString() - : ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString()); - $dateTo = now()->toDateString(); - $strategy = $isFirstSync ? 'longest' : null; - - $transactionsPerBank = []; - - $connection->load('accounts.bank'); - - foreach ($connection->accounts as $account) { - if ($account->isLinked()) { - $lastTransaction = $account->transactions() - ->latest('transaction_date') - ->first(); - - $linkedDateFrom = $lastTransaction - ? $lastTransaction->transaction_date->toDateString() - : $dateFrom; - - if ($linkedDateFrom > $dateTo) { - $linkedDateFrom = $dateTo; - } - - $created = $transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); - $balanceSync->sync($account); - } else { - $created = $transactionSync->sync($account, $dateFrom, $dateTo, $strategy); - $balanceSync->sync($account); - - if ($isFirstSync) { - $balanceSync->calculateHistoricalBalances($account); - } - } - - if ($created > 0) { - $bankName = $account->bank->name ?? __('Unknown Bank'); - $transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created; - } - } - - return ['transactions_synced' => array_sum($transactionsPerBank), 'transactions_per_bank' => $transactionsPerBank]; - } - private function friendlyErrorMessage(\Throwable $e): string { if ($e instanceof TransientBankingProviderException) { diff --git a/app/Services/Banking/Sync/AbstractBankingConnectionSyncer.php b/app/Services/Banking/Sync/AbstractBankingConnectionSyncer.php new file mode 100644 index 00000000..5d82f997 --- /dev/null +++ b/app/Services/Banking/Sync/AbstractBankingConnectionSyncer.php @@ -0,0 +1,25 @@ +provider) { + BankingProvider::IndexaCapital => IndexaCapitalSyncer::class, + BankingProvider::Binance => BinanceSyncer::class, + BankingProvider::Wise => WiseSyncer::class, + BankingProvider::Bitpanda => BitpandaSyncer::class, + BankingProvider::Coinbase => CoinbaseSyncer::class, + BankingProvider::EnableBanking => EnableBankingSyncer::class, + }; + + return app($syncer); + } +} diff --git a/app/Services/Banking/Sync/BinanceSyncer.php b/app/Services/Banking/Sync/BinanceSyncer.php new file mode 100644 index 00000000..cc89b465 --- /dev/null +++ b/app/Services/Banking/Sync/BinanceSyncer.php @@ -0,0 +1,31 @@ +api_token, $connection->api_secret); + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + if ($isFirstSync) { + $this->balanceSync->syncCurrentBalance($account, $client); + SyncBinanceHistoricalBalancesJob::dispatch($account)->delay(now()->addSeconds(30)); + } else { + $this->balanceSync->sync($account, $client, isFirstSync: false); + } + } + + return []; + } +} diff --git a/app/Services/Banking/Sync/BitpandaSyncer.php b/app/Services/Banking/Sync/BitpandaSyncer.php new file mode 100644 index 00000000..b522367b --- /dev/null +++ b/app/Services/Banking/Sync/BitpandaSyncer.php @@ -0,0 +1,25 @@ +api_token); + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + $this->balanceSync->sync($account, $client); + } + + return []; + } +} diff --git a/app/Services/Banking/Sync/CoinbaseSyncer.php b/app/Services/Banking/Sync/CoinbaseSyncer.php new file mode 100644 index 00000000..e1181ac0 --- /dev/null +++ b/app/Services/Banking/Sync/CoinbaseSyncer.php @@ -0,0 +1,25 @@ +api_token, $connection->api_secret); + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + $this->balanceSync->sync($account, $client, $isFirstSync, backfillMissingHistory: true); + } + + return []; + } +} diff --git a/app/Services/Banking/Sync/EnableBankingSyncer.php b/app/Services/Banking/Sync/EnableBankingSyncer.php new file mode 100644 index 00000000..9f329407 --- /dev/null +++ b/app/Services/Banking/Sync/EnableBankingSyncer.php @@ -0,0 +1,78 @@ +subYear()->toDateString() + : ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString()); + $dateTo = now()->toDateString(); + $strategy = $isFirstSync ? 'longest' : null; + + $transactionsPerBank = []; + + $connection->load('accounts.bank'); + + foreach ($connection->accounts as $account) { + if ($account->isLinked()) { + $lastTransaction = $account->transactions() + ->latest('transaction_date') + ->first(); + + $linkedDateFrom = $lastTransaction + ? $lastTransaction->transaction_date->toDateString() + : $dateFrom; + + if ($linkedDateFrom > $dateTo) { + $linkedDateFrom = $dateTo; + } + + $created = $this->transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); + $this->balanceSync->sync($account); + } else { + $created = $this->transactionSync->sync($account, $dateFrom, $dateTo, $strategy); + $this->balanceSync->sync($account); + + if ($isFirstSync) { + $this->balanceSync->calculateHistoricalBalances($account); + } + } + + if ($created > 0) { + $bankName = $account->bank->name ?? __('Unknown Bank'); + $transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created; + } + } + + if ($isFirstSync) { + $connection->update(['bank_transactions_email_cutoff_at' => now()]); + } elseif ($connection->user->canReceiveEmails()) { + SendDailyBankTransactionsSyncedEmailJob::dispatch($connection->user, now()->toDateString()); + } + + return ['transactions_synced' => array_sum($transactionsPerBank), 'transactions_per_bank' => $transactionsPerBank]; + } +} diff --git a/app/Services/Banking/Sync/IndexaCapitalSyncer.php b/app/Services/Banking/Sync/IndexaCapitalSyncer.php new file mode 100644 index 00000000..fa6788b8 --- /dev/null +++ b/app/Services/Banking/Sync/IndexaCapitalSyncer.php @@ -0,0 +1,25 @@ +api_token); + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + $this->balanceSync->sync($account, $client, $isFirstSync); + } + + return []; + } +} diff --git a/app/Services/Banking/Sync/WiseSyncer.php b/app/Services/Banking/Sync/WiseSyncer.php new file mode 100644 index 00000000..0a75096b --- /dev/null +++ b/app/Services/Banking/Sync/WiseSyncer.php @@ -0,0 +1,41 @@ +subYear()->toDateString() + : ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString()); + $dateTo = now()->toDateString(); + + $client = new WiseClient($connection->api_token); + + $connection->load('accounts'); + + $transactionsPerAccount = []; + + foreach ($connection->accounts as $account) { + $count = $this->transactionSync->sync($account, $client, $dateFrom, $dateTo); + $this->balanceSync->sync($account, $client); + $transactionsPerAccount[$account->name] = $count; + } + + return [ + 'transactions_synced' => array_sum($transactionsPerAccount), + 'transactions_per_account' => $transactionsPerAccount, + ]; + } +} diff --git a/docs/adding-a-banking-provider.md b/docs/adding-a-banking-provider.md new file mode 100644 index 00000000..dcb40cf1 --- /dev/null +++ b/docs/adding-a-banking-provider.md @@ -0,0 +1,320 @@ +# Adding a banking sync provider + +This guide explains how to add a new banking/financial provider to the sync +pipeline. Follow it top to bottom — each step is self-contained and the example +(`Acme`) can be copy-pasted and renamed. + +It is written so that **a human or an AI agent** can complete the integration +without prior context. If you only need the data to flow in on the scheduled +sync, **Part 1** is all you need. **Part 2** covers the rest of a full +end-to-end integration (connecting the account, mapping it, the UI). + +--- + +## Architecture in one minute + +Syncing is split into three responsibilities: + +| Piece | Responsibility | Lives in | +| --- | --- | --- | +| `SyncBankingConnectionJob` | **Orchestration** — deleted-user/expiry/rate-limit checks, error handling, retries, logging, status updates. Provider-agnostic. | `app/Jobs/SyncBankingConnectionJob.php` | +| `BankingConnectionSyncer` (one per provider) | **Provider work** — talk to the provider API and persist balances/transactions for each account. | `app/Services/Banking/Sync/*Syncer.php` | +| `BankingConnectionSyncerFactory` | **Dispatch** — maps `connection.provider` (a `BankingProvider` enum) to the right syncer. | `app/Services/Banking/Sync/BankingConnectionSyncerFactory.php` | + +The job calls `factory->make($connection)` and then `$syncer->sync(...)`. You +**never edit the job** to add a provider — you add a `BankingProvider` enum case, +a syncer class, and one `match` arm in the factory. + +`connection.provider` is the `App\Enums\BankingProvider` enum (the DB column is +cast to it), so it is the single source of truth for the provider identifier and +shared capabilities (`usesApiKey()`, `defaultAccountType()`). + +### The contract + +Every syncer implements `App\Contracts\BankingConnectionSyncer`: + +```php +interface BankingConnectionSyncer +{ + /** Sync every account in the connection. Returns metadata for the sync log. */ + public function sync(BankingConnection $connection, bool $isFirstSync): array; + + /** Whether the connection's consent can expire (consent-based providers). */ + public function expires(): bool; + + /** Whether a permanent auth failure should notify the user (API-key providers). */ + public function notifiesOnAuthFailure(): bool; +} +``` + +`AbstractBankingConnectionSyncer` provides defaults for the **common case** — an +API-key integration that never expires and emails the user when its credentials +stop working: + +```php +public function expires(): bool { return false; } +public function notifiesOnAuthFailure(): bool { return true; } +``` + +So most providers only implement `sync()`. Override a flag only when your +provider differs: + +| Provider kind | `expires()` | `notifiesOnAuthFailure()` | Example | +| --- | --- | --- | --- | +| API-key (user supplies a key/token) | `false` (default) | `true` (default) | Binance, Coinbase, Bitpanda, Indexa Capital, Wise | +| Consent-based (OAuth, expires) | **`true`** | **`false`** | EnableBanking | + +This matches `BankingProvider::usesApiKey()` (everything except EnableBanking). +EnableBanking is the only provider that overrides both flags. + +--- + +## Part 1 — Add the sync provider + +### Step 1 — Write the API client (if the provider has its own API) + +Clients live in `app/Services/Banking/` and wrap the HTTP calls. Look at +`BinanceClient` or `IndexaCapitalClient` as templates. A client typically takes +the credentials in its constructor and exposes the few calls you need: + +```php +// app/Services/Banking/AcmeClient.php +namespace App\Services\Banking; + +use Illuminate\Support\Facades\Http; + +class AcmeClient +{ + private const BASE_URL = 'https://api.acme.com'; + + public function __construct(private string $apiToken) {} + + /** @return array */ + public function getBalances(): array + { + return Http::baseUrl(self::BASE_URL) + ->withToken($this->apiToken) + ->throw() + ->get('/v1/balances') + ->json(); + } +} +``` + +> Throw on 4xx/5xx (`->throw()`). The job relies on `RequestException` to detect +> `401/403` (auth) and `429` (rate limit). For flaky upstreams you can throw +> `App\Exceptions\Banking\TransientBankingProviderException` instead — the job +> treats it as a temporary error (logged as a warning, retried). + +### Step 2 — Write the sync service(s) + +The service maps provider data onto our models (`Account`, `AccountBalance`, +`Transaction`). Reuse the existing services as templates: + +- Balances only (crypto/investment): `BinanceBalanceSyncService`, `BitpandaBalanceSyncService`. +- Balances **and** transactions (banks): `TransactionSyncService` + `BalanceSyncService` (EnableBanking), or `WiseTransactionSyncService` + `WiseBalanceSyncService`. + +These are resolved from the container, so type-hint their dependencies in the +constructor and they get injected. + +### Step 3 — Write the syncer + +Create `app/Services/Banking/Sync/AcmeSyncer.php`. Extend +`AbstractBankingConnectionSyncer`, inject your sync service(s), and implement +`sync()`. Loop over `$connection->accounts` and do the provider work. + +```php +namespace App\Services\Banking\Sync; + +use App\Models\BankingConnection; +use App\Services\Banking\AcmeBalanceSyncService; +use App\Services\Banking\AcmeClient; + +class AcmeSyncer extends AbstractBankingConnectionSyncer +{ + public function __construct(private AcmeBalanceSyncService $balanceSync) {} + + public function sync(BankingConnection $connection, bool $isFirstSync): array + { + $client = new AcmeClient($connection->api_token); + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + $this->balanceSync->sync($account, $client); + } + + return []; // or ['transactions_synced' => N, ...] — stored on the sync log + } +} +``` + +Notes: + +- **Credentials** are on the connection: `$connection->api_token` and + `$connection->api_secret` (both `encrypted` casts, decrypted automatically). +- **Return value** is metadata persisted on `BankingSyncLog.metadata`. Return + `[]` if there's nothing useful. See `WiseSyncer` for a transaction-count example. +- **`$isFirstSync`** is `true` on the very first sync or a forced full sync. Use + it to backfill history once (see `BinanceSyncer`/`CoinbaseSyncer`). +- Override `expires()` / `notifiesOnAuthFailure()` here if your provider is not + the default API-key shape (see the table above). + +### Step 4 — Register the provider (enum case + factory arm) + +First add the identifier to `app/Enums/BankingProvider.php`: + +```php +enum BankingProvider: string +{ + // ...existing cases... + case Acme = 'acme'; // the value stored in banking_connections.provider +} +``` + +If your provider is **not** the default shape, also reflect it on the enum: +`usesApiKey()` (auth-failed email; `true` for everything except EnableBanking) +and `defaultAccountType()` (the account type its accounts default to — e.g. +`Investment` for crypto, `Checking` for banks). + +Then add one `match` arm in +`app/Services/Banking/Sync/BankingConnectionSyncerFactory.php`: + +```php +$syncer = match ($connection->provider) { + // ...existing arms... + BankingProvider::Acme => AcmeSyncer::class, +}; +``` + +The `match` is **exhaustive over the enum** — there is no `default`. If you add +an enum case without a syncer arm, Larastan flags the non-exhaustive `match` and +the factory throws `\UnhandledMatchError` at runtime, so you cannot forget this +step. That is the **only** wiring the sync pipeline needs. + +### Step 5 — Add a factory state (for tests) + +In `database/factories/BankingConnectionFactory.php`, add a state mirroring the +other providers so tests can build connections easily: + +```php +public function acme(): static +{ + return $this->state(fn (array $attributes) => [ + 'provider' => BankingProvider::Acme, + 'authorization_id' => null, + 'session_id' => null, + 'api_token' => 'test-acme-token-'.fake()->uuid(), + 'aspsp_name' => 'Acme', + 'aspsp_country' => 'ES', + 'valid_until' => null, // null = never expires (API-key provider) + ]); +} +``` + +### Step 6 — Test it + +Two layers of tests: + +**a) Factory wiring** — extend the dataset in +`tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php`: + +```php +'acme' => [BankingProvider::Acme, AcmeSyncer::class], +``` + +The `it('covers every provider enum case')` test already fails for any enum case +without a syncer. Add your flag expectations too if you overrode a default. + +**b) Sync behavior** — add a test in +`tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php`. Drive the job +through the shared `runSync()` helper (defined in `tests/Pest.php`) and fake the +provider HTTP with `Http::fake()`: + +```php +test('acme sync stores balances', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->acme()->create([ + 'user_id' => $user->id, + 'last_synced_at' => null, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + ]); + + Http::fake(['api.acme.com/*' => Http::response(['balances' => [/* ... */]])]); + + $job = new SyncBankingConnectionJob($connection); + runSync($job); // resolves the real factory → AcmeSyncer + + $connection->refresh(); + expect($connection->last_synced_at)->not->toBeNull(); + expect($account->balances()->count())->toBeGreaterThan(0); +}); +``` + +> `runSync($job, $transactionSync, $balanceSync)` also accepts mocked +> `TransactionSyncService` / `BalanceSyncService` and binds them into the +> container — only needed for EnableBanking-style providers that use those +> shared services. + +Run them: + +```bash +php artisan test --compact tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php \ + tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php +``` + +--- + +## Part 2 — Full end-to-end integration + +Part 1 makes the scheduled sync work. To let a user actually **connect** an Acme +account, you also need the pieces below. Use an existing API-key provider +(e.g. Binance) as the reference implementation and grep for its name. + +1. **Connection + onboarding controller** — `app/Http/Controllers/OpenBanking/`. + Each provider has a controller (e.g. `BinanceController`) that validates + credentials, creates the `BankingConnection`, and stores `pending_accounts_data`. + Add an `AcmeController`, a Form Request for its credentials, and its routes. + +2. **Model helper (optional)** — `app/Models/BankingConnection.php` exposes + `isBinance()`, `isCoinbase()`, etc. Add `isAcme()` only if other code needs to + branch on the provider. The sync pipeline does **not** need it (it uses the + factory), so don't add it speculatively. + +3. **Account mapping** — the created account type comes from + `BankingProvider::defaultAccountType()` (used by `AccountMappingController` and + `Concerns/CreatesAccountsFromPending`). You already set this in Step 4 when you + added the enum case, so there is nothing extra to wire here. + +4. **Frontend** — the connect flow lives under `resources/js/pages/` / + `resources/js/components/`. Mirror an existing provider's form and provider + list entry. + +> Search the codebase for an existing provider's identifier +> (e.g. `grep -rn "'binance'" app resources`) to find every touchpoint to mirror. + +--- + +## Checklist & CI + +Sync pipeline (Part 1): + +- [ ] `BankingProvider::Acme` enum case (+ `usesApiKey()`/`defaultAccountType()` if non-default) +- [ ] `AcmeClient` (if the provider has an API) +- [ ] `AcmeBalanceSyncService` / transaction service +- [ ] `AcmeSyncer extends AbstractBankingConnectionSyncer` +- [ ] flag overrides if not a default API-key provider +- [ ] one `match` arm in `BankingConnectionSyncerFactory` +- [ ] `acme()` factory state +- [ ] factory-wiring + sync-behavior tests, green + +Before finalizing, run the project checks: + +```bash +vendor/bin/pint --dirty # PHP formatting +vendor/bin/phpstan analyse # static analysis +php artisan test --exclude-testsuite=Browser --compact +``` diff --git a/tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php b/tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php new file mode 100644 index 00000000..2c833ecc --- /dev/null +++ b/tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php @@ -0,0 +1,51 @@ + [BankingProvider::IndexaCapital, IndexaCapitalSyncer::class], + 'binance' => [BankingProvider::Binance, BinanceSyncer::class], + 'wise' => [BankingProvider::Wise, WiseSyncer::class], + 'bitpanda' => [BankingProvider::Bitpanda, BitpandaSyncer::class], + 'coinbase' => [BankingProvider::Coinbase, CoinbaseSyncer::class], + 'enablebanking' => [BankingProvider::EnableBanking, EnableBankingSyncer::class], +]); + +it('resolves the matching syncer for each provider', function (BankingProvider $provider, string $expected) { + $connection = new BankingConnection(['provider' => $provider]); + + expect(app(BankingConnectionSyncerFactory::class)->make($connection))->toBeInstanceOf($expected); +})->with('providers'); + +it('covers every provider enum case', function () { + foreach (BankingProvider::cases() as $provider) { + $connection = new BankingConnection(['provider' => $provider]); + + expect(app(BankingConnectionSyncerFactory::class)->make($connection)) + ->toBeInstanceOf(BankingConnectionSyncer::class); + } +}); + +it('only lets consent-based providers expire', function () { + expect(app(EnableBankingSyncer::class)->expires())->toBeTrue() + ->and(app(BinanceSyncer::class)->expires())->toBeFalse() + ->and(app(WiseSyncer::class)->expires())->toBeFalse(); +}); + +it('notifies on auth failure for every API-key provider but not EnableBanking', function () { + expect(app(IndexaCapitalSyncer::class)->notifiesOnAuthFailure())->toBeTrue() + ->and(app(BinanceSyncer::class)->notifiesOnAuthFailure())->toBeTrue() + ->and(app(BitpandaSyncer::class)->notifiesOnAuthFailure())->toBeTrue() + ->and(app(CoinbaseSyncer::class)->notifiesOnAuthFailure())->toBeTrue() + ->and(app(WiseSyncer::class)->notifiesOnAuthFailure())->toBeTrue() + ->and(app(EnableBankingSyncer::class)->notifiesOnAuthFailure())->toBeFalse(); +}); diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php index 97792d46..8a10a234 100644 --- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php +++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php @@ -42,7 +42,7 @@ it('sets sentry user and banking connection context for sync jobs', function () ]); $job = new SyncBankingConnectionJob($connection); - $job->handle(Mockery::mock(TransactionSyncService::class), Mockery::mock(BalanceSyncService::class)); + runSync($job); SentrySdk::getCurrentHub()->configureScope(function (Scope $scope) use ($user, $connection): void { $sentryUser = $scope->getUser(); @@ -82,7 +82,7 @@ test('first sync calculates historical balances', function () { $balanceSync->shouldReceive('calculateHistoricalBalances')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); }); test('subsequent syncs do not calculate historical balances', function () { @@ -105,7 +105,7 @@ test('subsequent syncs do not calculate historical balances', function () { $balanceSync->shouldNotReceive('calculateHistoricalBalances'); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); }); test('linked accounts sync from last transaction date and skip historical balances', function () { @@ -139,7 +139,7 @@ test('linked accounts sync from last transaction date and skip historical balanc $balanceSync->shouldNotReceive('calculateHistoricalBalances'); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); }); test('linked accounts clamp linkedDateFrom to today when last transaction is in future', function () { @@ -175,7 +175,7 @@ test('linked accounts clamp linkedDateFrom to today when last transaction is in $balanceSync->shouldNotReceive('calculateHistoricalBalances'); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); }); test('mixed linked and new accounts in same connection', function () { @@ -207,7 +207,7 @@ test('mixed linked and new accounts in same connection', function () { ->with(Mockery::on(fn ($acct) => $acct->id === $newAccount->id)); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); }); test('sends email when new transactions are synced on subsequent sync', function () { @@ -233,7 +233,7 @@ test('sends email when new transactions are synced on subsequent sync', function $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Queue::assertPushed(SendDailyBankTransactionsSyncedEmailJob::class, function ($job) use ($user) { return $job->user->is($user) @@ -265,7 +265,7 @@ test('does not send email on first sync', function () { $balanceSync->shouldReceive('calculateHistoricalBalances')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Queue::assertNotPushed(SendDailyBankTransactionsSyncedEmailJob::class); @@ -311,7 +311,7 @@ test('first sync cutoff excludes transactions imported during onboarding from la $balanceSync->shouldReceive('calculateHistoricalBalances')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); @@ -350,7 +350,7 @@ test('schedules daily bank sync email check when subsequent sync imports zero ne $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Queue::assertPushed(SendDailyBankTransactionsSyncedEmailJob::class); }); @@ -385,7 +385,7 @@ test('aggregates multiple accounts under same bank', function () { $balanceSync->shouldReceive('sync')->twice(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Queue::assertPushed(SendDailyBankTransactionsSyncedEmailJob::class); }); @@ -421,7 +421,7 @@ test('lists different banks separately in email', function () { $balanceSync->shouldReceive('sync')->twice(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Queue::assertPushed(SendDailyBankTransactionsSyncedEmailJob::class); }); @@ -818,7 +818,7 @@ test('indexa capital sync only syncs balances, not transactions', function () { $balanceSync->shouldNotReceive('sync'); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->last_synced_at)->not->toBeNull(); @@ -851,7 +851,7 @@ test('indexa capital connections do not expire', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); @@ -884,7 +884,7 @@ test('indexa capital sync does not send email', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Mail::assertNothingQueued(); }); @@ -921,7 +921,7 @@ test('binance first sync gets current balance immediately and dispatches histori $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); expect($account->balances()->count())->toBe(1); expect($account->balances()->first()->balance)->toBe(5000000); @@ -966,7 +966,7 @@ test('binance subsequent sync does not dispatch historical job', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Mail::assertNothingQueued(); }); @@ -991,7 +991,7 @@ test('fullSync flag forces first-sync behavior on already-synced connection', fu $balanceSync->shouldReceive('calculateHistoricalBalances')->once(); $job = new SyncBankingConnectionJob($connection, fullSync: true); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); @@ -1055,7 +1055,7 @@ test('bitpanda sync calls balance sync service and updates last_synced_at', func $balanceSync->shouldNotReceive('sync'); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->last_synced_at)->not->toBeNull(); @@ -1088,7 +1088,7 @@ test('bitpanda connections do not expire', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); @@ -1121,7 +1121,7 @@ test('bitpanda sync does not send email', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); Mail::assertNothingQueued(); }); @@ -1157,7 +1157,7 @@ test('sends auth failed email immediately for indexa capital 401 error', functio $job->job = $mockQueueJob; try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected for auth failures after manually failing the job. } @@ -1201,7 +1201,7 @@ test('auth error sends email and fails job on any attempt', function () { $job->job->shouldReceive('fail')->once(); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected for auth failures after manually failing the job. } @@ -1242,7 +1242,7 @@ test('does not send auth failed email for non-auth errors', function () { $job->job->shouldReceive('hasFailed')->andReturn(false); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (Throwable) { // Expected } @@ -1280,7 +1280,7 @@ test('does not send auth failed email for enablebanking connections', function ( $job->job->shouldReceive('hasFailed')->andReturn(false); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RuntimeException) { // Expected } @@ -1319,7 +1319,7 @@ test('sends auth failed email immediately for binance 403 error', function () { $job->job->shouldReceive('fail')->once(); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected for auth failures after manually failing the job. } @@ -1371,7 +1371,7 @@ test('rate limit error sets backoff window without erroring connection', functio $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); @@ -1402,7 +1402,7 @@ test('daily rate limit backoff lasts until next UTC midnight', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); $expected = now()->utc()->addDay()->startOfDay(); @@ -1432,7 +1432,7 @@ test('rate limit backoff honours Retry-After header', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->rate_limited_until)->not->toBeNull(); @@ -1458,7 +1458,7 @@ test('rate limited connection is skipped without calling provider', function () $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); @@ -1483,7 +1483,7 @@ test('successful sync clears rate_limited_until', function () { $balanceSync->shouldReceive('sync')->andReturnNull(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->rate_limited_until)->toBeNull(); diff --git a/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php index 434b41b5..a98163bf 100644 --- a/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php +++ b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php @@ -53,7 +53,7 @@ test('temporary error on non-final attempt does not set error status', function $threw = false; try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { $threw = true; } @@ -96,7 +96,7 @@ test('temporary error on final attempt sets error status and increments consecut $job->job->shouldReceive('hasFailed')->andReturn(false); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected } @@ -141,7 +141,7 @@ test('transient banking provider error on final attempt uses retry later message $threw = false; try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (TransientBankingProviderException) { $threw = true; } @@ -185,7 +185,7 @@ test('consecutive sync failures accumulate across dispatch cycles', function () $job->job->shouldReceive('hasFailed')->andReturn(false); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected } @@ -214,7 +214,7 @@ test('successful sync resets consecutive failures', function () { $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); @@ -241,7 +241,7 @@ test('connection in error status can be synced', function () { $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); @@ -277,7 +277,7 @@ test('auth error sets consecutive failures beyond the cap', function () { $job->job->shouldReceive('fail')->once(); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected for auth failures after manually failing the job. } @@ -307,7 +307,7 @@ test('successful sync creates a success log entry', function () { $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); expect($log)->not->toBeNull(); @@ -348,7 +348,7 @@ test('failed sync creates a failed log entry', function () { $job->job->shouldReceive('hasFailed')->andReturn(false); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected } @@ -374,7 +374,7 @@ test('skipped sync creates a skipped log entry and emails user for newly expired $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $connection->refresh(); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); @@ -399,7 +399,7 @@ test('skipped sync creates a skipped log entry for non-syncable status', functio $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); expect($log)->not->toBeNull(); @@ -429,7 +429,7 @@ test('rate limit error creates a failed log entry', function () { $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); expect($log)->not->toBeNull(); @@ -466,7 +466,7 @@ test('sync log records attempt number', function () { $job->job->shouldReceive('hasFailed')->andReturn(false); try { - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected } @@ -494,7 +494,7 @@ test('connection sync logs relationship works', function () { $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); - $job->handle($transactionSync, $balanceSync); + runSync($job, $transactionSync, $balanceSync); expect($connection->syncLogs()->count())->toBe(1); expect($connection->latestSyncLog)->not->toBeNull(); diff --git a/tests/Pest.php b/tests/Pest.php index cff5d4c2..d3d6531f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,6 @@ click('[data-testid="submit-account"]') ->wait(2); } + +/** + * Run the banking sync job through the real syncer factory, binding any + * EnableBanking sync-service mocks the test provides so the resolved syncer + * uses them instead of the container defaults. + */ +function runSync( + SyncBankingConnectionJob $job, + ?object $transactionSync = null, + ?object $balanceSync = null, +): void { + if ($transactionSync !== null) { + app()->instance(TransactionSyncService::class, $transactionSync); + } + + if ($balanceSync !== null) { + app()->instance(BalanceSyncService::class, $balanceSync); + } + + $job->handle(app(BankingConnectionSyncerFactory::class)); +}