refactor(banking): scalable per-provider sync via a syncer factory (#545)

## 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.
This commit is contained in:
Víctor Falcón 2026-06-16 16:25:29 +02:00 committed by GitHub
parent 3d0fd9cc70
commit 220b1e11f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 752 additions and 224 deletions

View File

@ -0,0 +1,25 @@
<?php
namespace App\Contracts;
use App\Models\BankingConnection;
interface BankingConnectionSyncer
{
/**
* Sync every account belonging to the connection.
*
* @return array<string, mixed> 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;
}

View File

@ -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<string, mixed>
*/
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<string, mixed>
*/
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) {

View File

@ -0,0 +1,25 @@
<?php
namespace App\Services\Banking\Sync;
use App\Contracts\BankingConnectionSyncer;
/**
* Sensible defaults for the common provider shape: an API-key integration that
* never expires and notifies the user when its credentials stop working.
*
* Consent-based providers override expires(); providers that authenticate
* without user-managed credentials override notifiesOnAuthFailure().
*/
abstract class AbstractBankingConnectionSyncer implements BankingConnectionSyncer
{
public function expires(): bool
{
return false;
}
public function notifiesOnAuthFailure(): bool
{
return true;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Services\Banking\Sync;
use App\Contracts\BankingConnectionSyncer;
use App\Enums\BankingProvider;
use App\Models\BankingConnection;
class BankingConnectionSyncerFactory
{
public function make(BankingConnection $connection): BankingConnectionSyncer
{
$syncer = match ($connection->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);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\Banking\Sync;
use App\Jobs\SyncBinanceHistoricalBalancesJob;
use App\Models\BankingConnection;
use App\Services\Banking\BinanceBalanceSyncService;
use App\Services\Banking\BinanceClient;
class BinanceSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(private BinanceBalanceSyncService $balanceSync) {}
public function sync(BankingConnection $connection, bool $isFirstSync): array
{
$client = new BinanceClient($connection->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 [];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Services\Banking\Sync;
use App\Models\BankingConnection;
use App\Services\Banking\BitpandaBalanceSyncService;
use App\Services\Banking\BitpandaClient;
class BitpandaSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(private BitpandaBalanceSyncService $balanceSync) {}
public function sync(BankingConnection $connection, bool $isFirstSync): array
{
$client = new BitpandaClient($connection->api_token);
$connection->load('accounts');
foreach ($connection->accounts as $account) {
$this->balanceSync->sync($account, $client);
}
return [];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Services\Banking\Sync;
use App\Models\BankingConnection;
use App\Services\Banking\CoinbaseBalanceSyncService;
use App\Services\Banking\CoinbaseClient;
class CoinbaseSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(private CoinbaseBalanceSyncService $balanceSync) {}
public function sync(BankingConnection $connection, bool $isFirstSync): array
{
$client = new CoinbaseClient($connection->api_token, $connection->api_secret);
$connection->load('accounts');
foreach ($connection->accounts as $account) {
$this->balanceSync->sync($account, $client, $isFirstSync, backfillMissingHistory: true);
}
return [];
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Services\Banking\Sync;
use App\Jobs\SendDailyBankTransactionsSyncedEmailJob;
use App\Models\BankingConnection;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
class EnableBankingSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(
private TransactionSyncService $transactionSync,
private BalanceSyncService $balanceSync,
) {}
public function expires(): bool
{
return true;
}
public function notifiesOnAuthFailure(): bool
{
return false;
}
public function sync(BankingConnection $connection, 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 = $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];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Services\Banking\Sync;
use App\Models\BankingConnection;
use App\Services\Banking\IndexaCapitalBalanceSyncService;
use App\Services\Banking\IndexaCapitalClient;
class IndexaCapitalSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(private IndexaCapitalBalanceSyncService $balanceSync) {}
public function sync(BankingConnection $connection, bool $isFirstSync): array
{
$client = new IndexaCapitalClient($connection->api_token);
$connection->load('accounts');
foreach ($connection->accounts as $account) {
$this->balanceSync->sync($account, $client, $isFirstSync);
}
return [];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Services\Banking\Sync;
use App\Models\BankingConnection;
use App\Services\Banking\WiseBalanceSyncService;
use App\Services\Banking\WiseClient;
use App\Services\Banking\WiseTransactionSyncService;
class WiseSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(
private WiseTransactionSyncService $transactionSync,
private WiseBalanceSyncService $balanceSync,
) {}
public function sync(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);
$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,
];
}
}

View File

@ -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<string, mixed> */
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
```

View File

@ -0,0 +1,51 @@
<?php
use App\Contracts\BankingConnectionSyncer;
use App\Enums\BankingProvider;
use App\Models\BankingConnection;
use App\Services\Banking\Sync\BankingConnectionSyncerFactory;
use App\Services\Banking\Sync\BinanceSyncer;
use App\Services\Banking\Sync\BitpandaSyncer;
use App\Services\Banking\Sync\CoinbaseSyncer;
use App\Services\Banking\Sync\EnableBankingSyncer;
use App\Services\Banking\Sync\IndexaCapitalSyncer;
use App\Services\Banking\Sync\WiseSyncer;
dataset('providers', [
'indexacapital' => [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();
});

View File

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

View File

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

View File

@ -1,5 +1,6 @@
<?php
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Budget;
@ -8,6 +9,9 @@ use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\Sync\BankingConnectionSyncerFactory;
use App\Services\Banking\TransactionSyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Stripe\Collection as StripeCollection;
@ -263,3 +267,24 @@ function createAccountViaUI($page, string $displayName, string $bankName, string
->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));
}