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:
parent
3d0fd9cc70
commit
220b1e11f1
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue