packages/banking/README.md
Technical documentation for Midday's multi-provider banking integration.
Dashboard (React)
│
▼
tRPC Router (apps/api/src/trpc/routers/banking.ts)
│
▼
Provider Facade (packages/banking/src/index.ts)
│
├── GoCardLessProvider (EU/UK — xior HTTP client, OAuth2 tokens)
├── PlaidProvider (US/CA — official Plaid SDK)
├── TellerProvider (US — native fetch with mTLS)
└── EnableBankingProvider (EU — xior HTTP client, RSA-signed JWT)
│
▼
Redis Cache (packages/cache/src/banking-cache.ts)
│
▼
Trigger.dev Jobs (packages/jobs/src/tasks/bank/)
The Provider class in index.ts dispatches to the correct provider based on a
provider string param ("gocardless" | "plaid" | "teller" | "enablebanking").
All four providers implement a common interface:
getAccounts() — list accounts with balances for the account selection screengetAccountBalance() — fetch current balance for a single accountgetTransactions() — fetch transactions (full history or latest 5 days)getConnectionStatus() — check if the bank connection is still validgetInstitutions() — list supported banks/institutionsdeleteAccounts() / deleteConnection() — disconnect from providergetHealthCheck() — provider availability checkHTTP_X_RATELIMIT_REMAINING — remaining requests in windowHTTP_X_RATELIMIT_ACCOUNT_SUCCESS_RESET — seconds until resetreference_id)transaction_total_days field
in the institutions list (e.g., 540 days for ABN AMRO, 730 for Revolut)createEndUserAgreement() tries 180 days first (EEA standard
under Article 10a RTS). If the bank rejects it, automatically falls back to 90 days.
UK banks are limited to 90 days by FCA regulation. The actual access_valid_for_days
accepted by the bank is read from the agreement response and used for expires_at.Transaction history strategy:
The maximum history is determined per-institution via the end user agreement:
/institutions/ endpoint returns transaction_total_days for each bankcreateEndUserAgreement() requests max_historical_days set to that valuegetMaxHistoricalDays() checks the GoCardless
separate_continuous_history_consent flag and a hardcoded fallback list, capping to 90 days.date_from filter)date_from = 5 days ago)max_historical_days. If the call fails, it's an auth/rate issue, not a
data volume issue. withRateLimitRetry handles rate limits.Key implementation details:
getAccounts() pre-resolves the access token once and passes it to all sub-methods
to avoid repeated Redis lookups (~8 → 1 per call)PLAID-CLIENT-ID + PLAID-SECRET headers.
Per-Item access tokens from the Link flow./accounts/get: 15/min per Item, 15,000/min per client/transactions/get: 30/min per Item, 20,000/min per client/transactions/sync: 50/min per Item, 2,500/min per client/institutions/get: 50/min per client/institutions/get_by_id: 400/min per clienterror_type: "RATE_LIMIT_EXCEEDED" with endpoint-specific error codesdays_requested: 730)Key implementation details:
/transactions/sync with no cursor (returns all history)/transactions/get with a 5-day windowfetch (required for mTLS tls: { cert, key } support)running_balance in recent transactions (free).
Avoids the paid /balances endpoint.Key implementation details:
getConnectionStatus() simplified to a single /accounts call (was N+1 calls)Key implementation details:
Psu-Ip-Address and Psu-User-Agent headers on GET requestsinternal_id)All caching uses bankingCache from @midday/cache/banking-cache, backed by
Redis (Upstash) with the "banking" key prefix.
| Data | TTL | Rationale |
|---|---|---|
| GoCardless access token | Dynamic (expires - 1h) | OAuth token lifecycle |
| GoCardless refresh token | Dynamic (expires - 1h) | OAuth token lifecycle |
| Account details | 30 minutes | Static within a flow, rarely changes |
| Account balance | 30 minutes | DB is source of truth; prevents redundant API calls |
| Individual institution | 24 hours | Institution data is static |
| Institution lists | 24 hours | Lists change very rarely |
| EnableBanking JWT | ~20 hours (in-memory) | Avoids RSA signing per request |
getOrSet PatternThe bankingCache.getOrSet(key, ttl, fn) helper eliminates cache boilerplate:
// Instead of:
const cached = await bankingCache.get(key);
if (cached) return cached as T;
const result = await fetchFromApi();
bankingCache.set(key, result, ttl);
return result;
// Write:
return bankingCache.getOrSet(key, CacheTTL.THIRTY_MINUTES, () => fetchFromApi());
1. User selects provider + bank in dashboard
2. OAuth/Link flow with provider (provider-specific)
3. Dashboard calls getProviderAccounts → populates cache
4. User selects which accounts to enable
5. API creates bank_connection + bank_accounts in DB
6. Triggers "initial-bank-setup" job:
a. Creates daily cron schedule for the team (randomized time)
b. Runs syncConnection (manualSync: true) — waits for completion
c. Triggers second syncConnection after 5 minutes (catches delayed data)
1. Cron triggers bankSyncScheduler for the team
2. Batch-triggers syncConnection for each bank_connection
3. syncConnection:
a. Checks connection status via provider API
b. If connected: syncs each enabled account sequentially (60s delay between accounts)
c. If disconnected: updates status in DB
4. syncAccount (per account):
a. Fetches balance → updates DB
b. Fetches transactions (latest 5 days) → upserts in batches of 500
5. Transaction notifications triggered after 5 minutes (background only)
1. User re-authenticates with provider
2. "reconnect-connection" job:
a. Fetches fresh accounts from provider
b. Matches old account IDs to new ones:
- GoCardless/Teller/EnableBanking: uses account_reference matching
- Plaid: IDs preserved via update mode (no remapping needed)
c. Updates account_id mappings in DB
d. Triggers syncConnection (manualSync: true)
error_retries count in the DBerror_retries; success resets themAll providers are wrapped with withRateLimitRetry which:
RATE_LIMIT_EXCEEDED error type)HTTP_X_RATELIMIT_ACCOUNT_SUCCESS_RESET (seconds until reset)Retry-After headerAdditionally:
Some banks limit to 4 API calls per day per account per endpoint. Our caching strategy ensures account details, balances, and institution data are fetched once during account selection and reused during the initial sync, staying within even the strictest limits.
Requisitions can have status "EX" (expired) or "RJ" (rejected). The connection status
check detects both and marks the connection as disconnected.
createEndUserAgreement() uses a try-180, fall-back-to-90 strategy for
access_valid_for_days. Per the EC Article 10a RTS (effective July 2023), EEA banks
should accept 180 days, but compliance varies. If a bank rejects 180 days, the method
automatically retries with 90. The actual value the bank accepted is read from the
agreement response and threaded through to transformAccount for an accurate expires_at.
On reconnect, the value is passed via the redirect URL so updateBankConnection also
stores the correct expiry.
Some banks only provide extended (>90 day) transaction history once and require separate
consent for continuous access. getMaxHistoricalDays() uses two signals to detect these:
separate_continuous_history_consent on the
/institutions/ endpoint. When true, history is capped to 90 days.When neither signal matches, the full transaction_total_days from the institution is used.
See: https://bankaccountdata.zendesk.com/hc/en-gb/articles/11529718632476
PSD2 banks return an array of balances from the /balances endpoint, each with its own
balanceType and currency. For single-currency accounts this array typically has one or
two entries. For multi-currency accounts (common with Nordic/European banks), it can have
entries in multiple currencies — e.g., both DKK and EUR.
The selectPrimaryBalance utility (gocardless/utils.ts, enablebanking/utils.ts) picks
the balance to use as the account's displayed balance using a booked-first strategy
(settled amounts are more appropriate for accounting):
interimBooked / ITBD — current intraday settled balance (best: current + settled)closingBooked / CLBD — end-of-day settled balance (settled but may be stale)interimAvailable / ITAV — current available (may include credit limits)expected / XPCDaccount.currency),
balances matching that currency are tried first within each tier. This prevents multi-currency
accounts from picking the wrong currency based on raw amount comparison alone. If the hint
is "XXX" or no balances match, the hint is ignored and all balances are considered.The available_balance field is populated separately by scanning the full balances array
for an "available" type entry (interimAvailable, ITAV, closingAvailable, CLAV,
OPAV), regardless of which balance was selected as primary.
Some PSD2 banks return "XXX" (ISO 4217 for "no currency") as the account-level currency
in the account details endpoint, while individual transactions correctly report the real
currency (e.g., EUR). This affects both GoCardless and EnableBanking since they connect
to the same underlying European banks.
The system handles this at three levels:
gocardless/transform.ts, enablebanking/transform.ts): When
account.currency is "XXX", falls back to the balance currency, then to currencies
from the balances array. If all sources are "XXX", the raw value is preserved (no
hardcoded fallback — these could be GBP, SEK, DKK, etc.).sync/account.ts): During daily sync, if the stored currency is
"XXX", the job updates it from the balance response currency. If the balance is also
"XXX", it derives the currency from the first transaction with a valid currency code.apps/dashboard/src/utils/format.ts): formatAmount detects
"XXX" and formats the value as a plain decimal number (e.g., 5,000.00) without a
currency symbol, avoiding misleading display.Plaid's /transactions/sync can return TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION
if data changes during pagination. The withRateLimitRetry wrapper handles retries.
Consider adding count: 500 (max) to reduce the likelihood of this error.
Some ASPSPs (e.g., Wise) return stale/cached data with the "longest" transaction strategy. The implementation uses a hybrid approach: fetches with "longest" first, checks if the most recent transaction is within 7 days, and if stale, supplements with "default" strategy data. Duplicates are handled by the database upsert layer.
The JWT is cached with a 5-minute safety margin — if the JWT has less than 5 minutes of validity left, a new one is generated. This prevents requests from failing due to token expiry during execution.
Balance is derived from running_balance in the first 50 transactions. If no transactions
have a running_balance (rare — new accounts or uncommon institutions), balance defaults
to 0. A fallback to the paid /balances endpoint could be added if needed.
When a user reconnects a GoCardless, Teller, or EnableBanking connection, the provider
issues new account identifiers. The reconnect-connection job
(packages/jobs/src/tasks/reconnect/connection.ts) handles this by:
findMatchingAccountaccount_id, account_reference, and iban on matched rowsThe matching algorithm (packages/supabase/src/utils/account-matching.ts) uses a
tiered strategy:
account_reference)Each DB account can only be matched once to prevent duplicate assignments.
Plaid preserves account IDs across reconnects via "update mode", so no remapping is needed.
If Redis is temporarily unavailable, all cache operations fail gracefully (RedisCache has try/catch on get/set). Methods fall through to the fetch function as if the cache was empty. This means the system degrades to making direct API calls — slower but functional.
Impact: High — reduces daily sync API calls and catches modified/removed transactions
Currently, daily syncs use /transactions/get with a 5-day window. Plaid recommends
persisting the /transactions/sync cursor between syncs for true incremental updates.
Requirements:
plaid_sync_cursor column to bank_connections table (cursor is per-Item)transactionsSync at the connection level, then distribute
transactions to individual accountsmodified and removed arrays from the sync response (currently ignored)SYNC_UPDATES_AVAILABLE webhook (can drop HISTORICAL_UPDATE,
DEFAULT_UPDATE, INITIAL_UPDATE)Impact: Low — prevents documented pagination error
Add count: 500 to transactionsSync calls to reduce the likelihood of
TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION errors during initial sync pagination.
Impact: Medium — prevents hitting bank-imposed limits
Read HTTP_X_RATELIMIT_REMAINING from successful responses (not just 429 errors).
If remaining calls are low, proactively delay or skip non-essential requests.
Impact: Low — saves HTTP client allocations
GoCardless and EnableBanking both cache their xior instances already. A further optimization
would be to share instances across Provider class instantiations (currently each new Provider()
creates a new API class with its own cache). This would require a singleton or module-level
cache for HTTP clients.
packages/banking/src/)| File | Purpose |
|---|---|
index.ts | Provider facade class + exports |
interface.ts | Common Provider interface |
types.ts | Core request/response types |
providers/*/provider.ts | Provider interface implementations |
providers/*/api.ts | HTTP API clients with caching + rate limit retry |
providers/*/transform.ts | Provider → common type transformers |
providers/*/types.ts | Provider-specific types |
utils/retry.ts | withRetry + withRateLimitRetry utilities |
utils/error.ts | ProviderError class |
packages/cache/src/)| File | Purpose |
|---|---|
banking-cache.ts | bankingCache object, getOrSet helper, CacheTTL constants |
redis-client.ts | RedisCache class (generic Redis wrapper) |
packages/jobs/src/tasks/bank/)| File | Purpose |
|---|---|
setup/initial.ts | Initial bank setup (schedule + first sync) |
scheduler/bank-scheduler.ts | Daily cron → fan-out to connections |
sync/connection.ts | Connection status check + fan-out to accounts |
sync/account.ts | Balance + transaction sync per account |
transactions/upsert.ts | DB upsert + trigger embeddings |
notifications/transactions.ts | New transaction notifications |
packages/jobs/src/tasks/reconnect/)| File | Purpose |
|---|---|
connection.ts | Account ID remapping after reconnect |