whisper-money/docs/adding-a-banking-provider.md

13 KiB

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:

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:

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:

// 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.

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:

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:

$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:

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:

'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():

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:

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 controllerapp/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:

vendor/bin/pint --dirty            # PHP formatting
vendor/bin/phpstan analyse         # static analysis
php artisan test --exclude-testsuite=Browser --compact