External Bank Feed Aggregator Integration
A design reference for pulling externally-aggregated bank transaction data into the reconciliation pipeline
1. Requirements
1.1 Functional requirements
- Let an admin link a legal entity’s bank accounts to a third-party bank-data aggregation service, so transaction history arrives automatically instead of a manually imported statement file (the manual path stays available for unlinked accounts; the two feed one pipeline, documented separately).
- Offer a single hosted linking flow, initiated in-app, authenticating the institution-and-account pair through the aggregator’s own hosted interface and returning a durable access credential reused for later pulls.
- Represent every surfaced account as an ordinary bank account master, tagged with the aggregator’s own account identifier, so it participates in the books like any other account.
- Pull new transactions across a window from “last successful sync” through today, defaulting to a twelve-month lookback on first sync.
- Land every pulled transaction as a normal bank feed line, submitted immediately, de-duplicated by the aggregator’s own transaction identifier.
- Offer both an on-demand pull and an unattended periodic pull, gated by two independent settings — enabled at all, and unattended sync separately enabled on top — and support selecting the aggregator’s operating environment (sandbox / development / production) plus an optional expanded country-and-language set.
1.2 Non-functional requirements
- Pull-only, by design: no inbound endpoint exists for the aggregator to call into. Freshness is bounded by how often a pull runs, not by how quickly the outside world can reach in (scheduling implications in §4, not a push flow invented here).
- Idempotent ingestion: a transaction already landed, by aggregator transaction id, is never landed twice.
- Non-fatal partial failure: one account’s pull failing must not block any other account’s pull in the same sweep.
- Credential confidentiality: client identifier/secret and each institution’s access credential are ordinary settings/master fields, masked where the field type supports it, never logged in plain text.
1.3 Constraints
- A single aggregator integration ships today — one settings record, one client wrapper, no multi-aggregator selection layer.
- All linked accounts share one set of aggregator credentials; the settings record is global, not scoped per legal entity.
- Unlike the payment-provider integration’s generic outbound-call auditing, no equivalent structured call log exists here — see §3.4/§5.
2. High-Level Design
2.1 Component diagram
2.2 Sequence — the pull round trip
The one system-boundary crossing, drawn as a sequence:
3. Deep Dive
3.1 Data model
Aggregator Settings (a single, global record) — an enabled flag, an unattended-sync flag (meaningful only once enabled), a client identifier, a client secret (masked field type), an environment selector (sandbox / development / production), and an optional expanded-coverage flag. The only configuration surface (per §1.3).
External Account Link — not a dedicated join record. Unlike the payment-provider integration’s dedicated link entity, this integration stores linkage as plain fields on the two masters it connects: the Banking Institution carries the durable access credential (one per institution, shared by every account linked under it), and the Bank Account carries the aggregator’s account identifier plus a “last successfully synced” watermark. So a credential is fetched once per institution, not per account — sibling accounts cannot be re-authenticated independently — and there is no independent “linked accounts” table: any Bank Account with a non-empty aggregator identifier is, by construction, linked, and that field alone is what both triggers use to discover what to pull.
Bank Feed Line — reused as defined in the bank reconciliation design (deposit/withdrawal amount, currency, description, reference number, bank-supplied transaction id/type). The aggregator-created line is inserted and submitted in one step — no intermediate unsubmitted preview, unlike a manually imported file — landing directly in the “submitted, balance remains” branch of that design’s status lifecycle. The aggregator’s transaction identifier is the de-duplication key; a transaction the aggregator itself still marks pending is skipped outright rather than landed early.
3.2 The sync algorithm
- Resolve the window. Read the account’s watermark; default to twelve months back on first sync. Window end is always today.
- Request and paginate. Ask the client for every transaction in that window; it requests pages until the running count matches the aggregator-reported total, returning one assembled list.
- Land each transaction, oldest first: create and submit a Bank Feed Line for each not already present by transaction id and not still pending.
- Advance the watermark to the newest transaction’s date, once the whole list is walked without an unhandled exception.
- On exception at any earlier step: log the error for this account only and stop; the watermark stays at its previous value, so the next pull re-requests the same window from scratch. Transactions already landed earlier in the same failed pass are protected from duplication by the transaction-id check — a partial pull is safely retryable with no explicit resume point needed.
3.3 Aggregator client contract (illustrative)
Client(access_token=None):
authenticate() -> uses client id + secret + environment from Aggregator Settings
exchange_public_token(one_time_token) -> durable access credential
get_link_token(update_mode=False) -> short-lived token for the hosted linking flow
(update_mode re-authenticates an expired link)
get_transactions(access_token, start_date, end_date, account_id?) -> paginated list
The client is a thin wrapper: no linked-account state of its own (that lives on the Bank Account masters), no retry or backoff logic — retry happens one level up, by re-running the sync algorithm.
3.4 Error handling
- Credential/session expiry is distinguished from a generic failure: the aggregator’s own “re-authentication required” error type logs under a distinct message pointing at re-linking, rather than folding into the same catch-all as everything else — the one failure mode most likely to need a person’s action, though nothing beyond the log entry proactively notifies anyone.
- No structured call log. The payment-provider integration records every outbound call and callback in a generic, replayable log; this integration has no equivalent. Failures go to the generic error log; successes leave no call-level record, only the resulting Bank Feed Lines — a scope gap carried into §5–6, not fixed here.
- The module’s generic webhook helpers are unused here. The same source module defines a signed-request validator and a callback-address builder for a named “connector,” but neither is called in this pull path or anywhere else in the read tree (repository-wide search). The address builder’s own naming implies a connector-registry structure that has no actual connector modules in this codebase — generic, unused scaffolding, plausibly left over from a broader push-capable layer that was never populated, not evidence of a hidden push path. The one client-side callback that does exist is the browser SDK’s completion handler for the linking flow — an in-page UI callback, not a server-side webhook receiver.
- Non-fatal by account: one account’s mid-pull exception aborts that account only; siblings already enqueued as separate jobs are unaffected.
4. Scale and Reliability
- Load is entirely self-scheduled. With no push channel, all traffic against the aggregator is initiated on this system’s own timetable — the polling interval is simultaneously the freshness bound and the main lever on call volume.
- The periodic sweep runs on an hourly maintenance cadence, not a daily one. The scheduler configuration places the automatic-sync entry point in the same bucket as several other short-interval tasks (including this plan’s own bulk-conversion retry sweep, documented separately); the daily bucket is empty. A fully-enabled account can be at most about an hour stale plus queue drain time, not up to a day.
- One job per account, trivially parallel — a slow or failing pull for one account never delays another; more accounts add jobs rather than lengthening a shared one.
- No rate-limit or backoff handling: the client paginates against the reported total but throttles nothing of its own; a rate-limit response surfaces as a generic logged failure, not a handled one.
- Credential expiry is silent until noticed, surfacing only as the distinct log entry from §3.4, with no proactive notification.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Pull-only, with no structured call log and unused webhook helpers, unlike the payment-provider pattern | Smaller security surface and less infrastructure to build for a single aggregator, at the cost of freshness capped by the sweep interval and a failed pull being visible only in a generic error log. |
| Linkage as plain fields on existing masters, not a dedicated join record | Fewer moving parts; but no first-class list of “links” — discovery is always “any account whose identifier field is set.” |
| Access credential shared per institution, not per account | One credential per institution to manage; but no way to revoke one account’s access independently of its siblings. |
| Single global settings record, not scoped per legal entity | Simplest configuration surface; but no isolation — a multi-entity deployment cannot vary credentials or environment by entity. |
| Watermark advances only after a full page loop completes | Simple to reason about; a late-loop failure re-requests the entire window on retry, mitigated (not eliminated) by per-transaction de-duplication. |
6. What to Revisit as the System Grows
- A structured per-pull call log, mirroring the payment-provider pattern, so a failed pull is queryable by account and window rather than only discoverable in a generic error log.
- Mid-window checkpointing, so a failure deep into a long transaction list doesn’t force re-requesting the entire window next time.
- A deliberate decision on the unused webhook helpers: build a genuine push path if the service tier ever supports one, or remove the dead surface area — leaving it as-is invites a future reader to assume a push flow exists when it does not.
- Multi-aggregator support, promoting today’s single settings record and hard-coded client into a small adapter registry, the same shape the payment-provider integration already uses, if a second service is ever added.