337 lines
13 KiB
Markdown
337 lines
13 KiB
Markdown
# 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, Interactive Brokers |
|
|
| 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 — one registry entry.** The connect dialog, the inline connect
|
|
flow, and the update-credentials dialog are all driven by a single registry:
|
|
`resources/js/lib/connect-providers.tsx`. Add **one** `CONNECT_PROVIDERS`
|
|
entry describing the provider — `institution` (name/country/logo), the
|
|
connect `endpoint`, the credential `fields`, and the on-screen copy. There is
|
|
**no per-component wiring**: the fields render the form *and* define the POST
|
|
body (each field `key` is the body key), so you don't touch the dialogs.
|
|
|
|
Useful per-entry flags:
|
|
|
|
- `onlyCountry` — offer only when connecting from that country (e.g. Indexa: `'ES'`).
|
|
- `sendsCountry` — also POST the selected `country` (banks/crypto that need it).
|
|
- `feature` — hide behind a Pennant flag (a `keyof Features`) until it's ready.
|
|
- `updatable: false` — the provider has no credential-update path on the backend.
|
|
|
|
> Copy in the registry is plain English wrapped with `__()` at render (like
|
|
> `COUNTRIES`), so it is **not** auto-detected by `LocalizationTest` — add the
|
|
> Spanish strings to `lang/es.json` yourself or the UI renders them untranslated.
|
|
|
|
> The backend touchpoints (controller, Form Request, route, optional model
|
|
> helper) are still per-provider. `grep -rn "'binance'" app routes` finds them
|
|
> to mirror; the frontend is just the registry entry above.
|
|
|
|
---
|
|
|
|
## 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
|
|
```
|