explanation
Bank connection failures
Written by the build · 2 September 2026 · owner @farhan-s · reviewed 2026-09-01
Bank connection failures
What the customer sees when their bank connection breaks, how the app and the backend divide the work, and every line of copy we ship — for review.
Companion to docs/explanation/email-invoice-parsing.md. Written after a real incident, described below.
1. What went wrong
On 2026-08-27 a customer opened "Ready to go" with a $153.80 bundle, paying from
****3490 | Chase, and tapped Confirm & Bundul. The screen turned red:
Failed to initiate subs processing: Error: Failed to confirm if account has enough: BadRequestException: the login details of this item have changed (credentials, MFA, or required user action) and a user login is required to update this information. use Link's update mode to restore the item to a good state
That is Plaid's message, written for developers, wrapped in two of our own error prefixes, with no button attached. He tapped Confirm eight times in five minutes, switching banks in the picker halfway through — to a second connection that was also dead.
Three separate problems, all visible in that one screenshot:
- We computed the fix and threw it away.
isAccessTokenValidalready worked outupdateModeRequired: true, butvalidateAccessTokenrethrew only the message and dropped the flag. The app had no machine-readable signal to open Plaid Link. - Provider copy reached a customer. "Use Link's update mode" is an instruction for us.
- The error code was lost. Two of the eight failures came back as
Failed to check account balance: Request failed with status code 400— the bare axios string. Reconstructing what actually happened meant substring-matching stack traces.
Underneath all three sat one asymmetry:
confirmIfPlaiDAccountHasEnoughForSub()
├─ not enough money → returned { hasEnough: false } → caller wrote clean copy ✅
└─ broken connection → THREW → string-wrapped ×3 → screen ❌
Insufficient funds read well because it was a return value the caller controlled. The connection failure read badly because it was an exception nobody owned. Both now leave through the same door.
2. The contract
2.1 Confirm screen — PaymentResponse.actionRequired
type PaymentActionRequired {
code: String! # BANK_RECONNECT_REQUIRED | BANK_NO_ACCOUNTS
# | BANK_UNAVAILABLE | BANK_CONNECTION_LOST | BANK_CHECK_FAILED
title: String! # "Sign in to Chase"
message: String! # plain copy, safe to render as-is
ctaLabel: String! # "Reconnect Chase" / "Try again"
requiresReconnect: Boolean!
deeplink: String # bundulio://plaid/relink?institution=Chase&mask=3490
webUrl: String # https twin, for email/web CTAs
institutionName: String # "Chase"
accountMask: String # "3490"
linkToken: String # ALREADY in update mode for this item
hostedLinkUrl: String # works even if the app is uninstalled
}
type PaymentResponse {
error: Boolean!
message: String!
paymentProcessId: String
actionRequired: PaymentActionRequired # ← new, nullable
excludedServices: [ExcludedService!]
}
Rules for the app
- Branch on
code. Never parsemessage. actionRequired == null→ behave exactly as before (including "Insufficient balance…").requiresReconnect == true→ Reconnect button, open Link withlinkToken.requiresReconnect == false→ Try again button. Do not offer a reconnect; the fix is waiting, and sending someone into Link during a bank outage wastes their minute.
linkToken is returned inline on purpose. The customer is standing on the confirm screen
with a dead connection; a second round trip to fetch a token is one more thing that can fail at
exactly the wrong moment.
plaidErrorCode and plaidRequestId are deliberately not in this type. They are
diagnostics and never leave the backend.
2.2 Account picker — PlaidConnectionStatus
type PlaidConnectionStatus {
institutionName: String!
isValid: Boolean!
accountCount: Int!
accountIds: [String!]! # ← new
accountMasks: [String!]! # ← new, same order as accountIds
linkToken: String # present only when isValid is false
}
accountIds closes a real gap: health previously returned only institutionName, so the app
could not tell which listed account was broken. Matching on bank name breaks precisely when
a customer has two connections to the same bank — and that is exactly when getting it wrong
matters, because one of the two may be fine.
2.3 Relink deeplink
bundulio://plaid/relink?institution=Chase&mask=3490
https://cdn.bundul.io/plaid/relink?institution=Chase&mask=3490
Params are URL-encoded (Capital%20One) and omitted rather than sent empty when unknown,
so a bare bundulio://plaid/relink still behaves exactly as it always did.
3. Flows
Flow A — the failure, end to end
Setup: the real incident. $153.80, ****3490 | Chase, item in ITEM_LOGIN_REQUIRED.
1 [FE] Taps "Confirm & Bundul"
→ mutation processPayment(accountId: "DZ1wjqrz…", isSplitPayment: false)
2 [BE] processPayment: loads user + subs, filters utilities → excludedServices,
computes total = 153.80
3 [BE] confirmIfPlaiDAccountHasEnoughForSub("DZ1wjqrz…", 153.80)
resolves from plaidtokens: accessToken, mask "3490", institution "Chase"
4 [BE] inspectAccessToken() → live Plaid /item/get
→ item.error.error_code = ITEM_LOGIN_REQUIRED
5 [BE] • flips isAccessTokenValid = false
• describePlaidProblem() → BANK_RECONNECT_REQUIRED + copy
• requiresReconnect === true, so mints an update-mode linkToken
• logs { problemCode, plaidErrorCode, plaidRequestId }
6 [BE] returns { hasEnough: false, problem }
↑ hasEnough is false too — we genuinely could not confirm the funds
7 [BE] processPayment checks `problem` BEFORE `hasEnough`
→ returns error + actionRequired
→ NO BundleRun created, NO charge, NO Passport call
8 [FE] actionRequired != null → render the card, not red text:
┌──────────────────────────────────────┐
│ Sign in to Chase │
│ │
│ Please sign in to re-verify your │
│ Chase connection. Your bundle │
│ settings are saved. │
│ │
│ [ Reconnect Chase ] │
└──────────────────────────────────────┘
9 [FE] Tap → open Plaid Link with actionRequired.linkToken
(or route actionRequired.deeplink)
10 [FE] Link opens in update mode, preselected on Chase. Customer signs in.
11 [FE] Link onSuccess → mutation exchangeAndSavePlaidTokens([
{ publicToken, metadata, accessTokenToUpdate }
])
12 [BE] sets isAccessTokenValid = true, refreshes recurring data,
emits PLAID_DATA_SYNCED
13 [FE] Back on "Ready to go". Customer taps Confirm & Bundul again.
14 [BE] inspectAccessToken → /item/get clean → clears the stale flag →
balance check runs → $153.80 covered → bundul proceeds
Step 7 is load-bearing. The BundleRun is created after this gate, so a failed bank check
leaves nothing behind — no orphan run, no half-created job for an admin to find later.
Step 14 is the safety net. See §5.
Flow B — catching it one screen earlier
1 [FE] Opens the payment-method picker → query getPlaidHealthCheck
2 [BE] Per connection: isValid, institutionName, accountIds[], accountMasks[],
plus a fresh linkToken for any dead one
3 [FE] Match each account row's id against connections[].accountIds:
****3490 | Chase ⚠ Needs reconnecting
****6182 | Wallet ⚠ Needs reconnecting
****0824 | Capital One ⚠ Needs reconnecting
4 [FE] Tap the warning → same Link flow as Flow A step 9,
using that connection's linkToken
This is where the incident should have been caught. All three of those accounts were dead before he ever reached the confirm screen, and the picker showed them as perfectly normal.
Flow C — the bank is merely down
Identical to Flow A through step 7. Only the mapping differs:
code BANK_UNAVAILABLE
requiresReconnect false
deeplink null ← deliberately no route
ctaLabel "Try again"
The app branches on requiresReconnect and shows Try again. Before this, a two-minute
outage and a permanently dead consent were indistinguishable on screen.
Flow D — nobody is on the screen
1 [Plaid] → POST /webhook ITEM.ERROR / ITEM_LOGIN_REQUIRED
2 [BE] marks the token invalid, looks up institution + mask, mints a hosted link
3 [BE] push: "Action needed: Chase" / "Your Chase connection needs to be
renewed to keep your bundle active. Tap to sign in."
url: bundulio://plaid/relink?institution=Chase&mask=3490
4 [FE] Router opens the relink screen already naming Chase ••••3490
5 [BE] email fallback with the hosted link (works if the app is uninstalled)
⚠ template 'bank-reconnect' still carries a TODO — see §7
4. Copy — for review
Every string below is generated from the shipped code, not transcribed. Change them in
src/plaid/plaid-connection-problem.ts.
4.1 Customer-facing, bank known (examples use "Chase")
| Problem code | Plaid codes mapped to it | Title | Message | Button | Reconnect? |
|---|---|---|---|---|---|
BANK_RECONNECT_REQUIRED |
ITEM_LOGIN_REQUIRED, PENDING_EXPIRATION, PENDING_DISCONNECT, ITEM_LOCKED, USER_PERMISSION_REVOKED, USER_ACCOUNT_REVOKED, ACCESS_NOT_GRANTED, ITEM_NOT_SUPPORTED |
Sign in to Chase | Please sign in to re-verify your Chase connection. Your bundle settings are saved. | Reconnect Chase | ✅ |
BANK_NO_ACCOUNTS |
NO_ACCOUNTS, NO_AUTH_ACCOUNTS |
No usable accounts found | We couldn't detect a payment account linked to Chase. Reconnect to select an account or pick another bank. | Reconnect Chase | ✅ |
BANK_UNAVAILABLE |
INSTITUTION_DOWN, INSTITUTION_NOT_RESPONDING, INSTITUTION_NO_LONGER_SUPPORTED, PLANNED_MAINTENANCE, INSTITUTION_NOT_AVAILABLE, RATE_LIMIT_EXCEEDED, INTERNAL_SERVER_ERROR |
Chase is temporarily offline | Chase isn't responding right now. Please try again shortly or switch funding accounts. | Try again | ❌ |
BANK_CONNECTION_LOST |
INVALID_ACCESS_TOKEN, INVALID_CREDENTIALS, ITEM_NO_ERROR, ITEM_NOT_FOUND |
Chase connection lost | Your link to Chase has expired. Reconnect your account to complete your bundle schedule. | Reconnect Chase | ✅ |
BANK_CHECK_FAILED |
anything unrecognised | Couldn't verify Chase balance | We couldn't confirm your balance with Chase. Please try again shortly or switch funding accounts. | Try again | ❌ |
4.2 Customer-facing, bank unknown
Not token substitution. "your {Bank} connection" would render as "your your bank connection", and the approved unknown-bank copy is genuinely different wording in places — so both variants are written out in full in the code.
| Problem code | Title | Message | Button |
|---|---|---|---|
BANK_RECONNECT_REQUIRED |
Sign in to your bank | Please sign in to re-verify your bank connection. Your bundle settings are saved. | Reconnect your bank |
BANK_NO_ACCOUNTS † |
No usable accounts found | We couldn't detect a payment account linked to your bank. Reconnect to select an account or pick another bank. | Reconnect your bank |
BANK_UNAVAILABLE |
Your bank isn't responding | We couldn't connect to your bank right now. Please try again shortly or switch funding accounts. | Try again |
BANK_CONNECTION_LOST † |
Bank connection lost | Your bank link has expired. Reconnect your account to complete your bundle schedule. | Reconnect your bank |
BANK_CHECK_FAILED † |
Couldn't verify your balance | We couldn't confirm your balance with your bank. Please try again shortly or switch funding accounts. | Try again |
† Derived, not supplied. The approved copy covered only the two rows above without a dagger; these three were written to match their tone while avoiding the doubled-"your" problem. Worth a review pass.
getPlaidHealthCheck defaults a missing institution to the literal "Unknown Bank"; that is
treated as unknown and never printed.
4.3 Push notification
| Case | Title | Message | URL |
|---|---|---|---|
| Bank known | Action needed: Chase | Your Chase connection needs to be renewed to keep your bundle active. Tap to sign in. | bundulio://plaid/relink?institution=Chase&mask=3490 |
| Bank unknown | Action needed: Reconnect bank | One of your bank connections needs to be re-authenticated. Tap to reconnect. | bundulio://plaid/relink |
4.4 Admin-facing (not customer copy)
payment-admin.service.ts deliberately says something different — an admin cannot press a
Reconnect button on the customer's behalf, so they get the cause and the code:
Cannot add services: the customer's Chase connection needs reconnecting (BANK_RECONNECT_REQUIRED). Ask them to reconnect it from the app before retrying.
4.5 Unchanged copy
Insufficient funds was already clean and is not part of this change. It returns no
actionRequired. It is the pattern the above was modelled on.
| Where | Message |
|---|---|
processPayment |
Insufficient balance to complete payment. |
addServicesToOneSub |
Insufficient balance to add services. |
processReconnectPayment |
Insufficient balance to reconnect your One Sub. |
5. Relink completion — three independent routes
The customer's connection recovers whichever of these happens:
- App calls
exchangeAndSavePlaidTokenswithaccessTokenToUpdate→ matches the detail, setsisAccessTokenValid = true. - App calls it without
accessTokenToUpdate→ Plaid update mode returns the same access token, so thenewAccessTokenbranch matches and the flag flips anyway. - App calls nothing at all → still heals. Plaid update mode does not strictly require an
exchange, so this is a real scenario.
inspectAccessTokendoes a live/item/geton every attempt and clears a staleisAccessTokenValid: falsebefore proceeding.
Route 3 is why Flow A step 14 works even if the app skips step 11.
The app never needs the raw access token — actionRequired.linkToken is already scoped to the
right item. getPlaidLinkToken(accessToken) remains available as an alternative.
6. Who owns what
| Status | |
|---|---|
BE — codes, copy, linkToken, deeplink params, gate ordering, error-code capture, indexes |
✅ built & verified |
| BE — relink completion (3 routes) | ✅ pre-existing, confirmed |
BE — bank-reconnect Infobip template |
⚠️ unverified — see §7 |
FE — confirm screen reads actionRequired, branches on requiresReconnect |
🔨 |
FE — open Link with linkToken; relink screen reads ?institution=&mask= |
🔨 |
FE — picker matches accountIds, shows "Needs reconnecting" |
🔨 |
| FE — disable Confirm while a request is in flight | 🔨 |
| FE/product — auto-resume the bundul after relink, or make them tap again | ❓ open |
7. Known gaps
bank-reconnectInfobip template.webhook.service.tssends it with a// TODO: create this template in Infobip. If it does not exist, the email leg of Flow D silently no-ops and push is the only channel. Needs confirming.- No auto-resume after relink. The customer lands back on the confirm screen and taps again. Deliberate for now; revisit if it shows up in support.
- Repeat taps are still cheap to make. Server-side throttling of identical repeat failures was considered and not built; the FE fix (swap the button, disable while in flight) covers the observed behaviour.
8. File map
| File | Role |
|---|---|
src/plaid/plaid-connection-problem.ts |
Codes, copy table, describePlaidProblem, PlaidConnectionError |
src/plaid/plaid.service.ts |
Preserves error_code + request_id on both isAccessTokenValid and checkPlaidAccountBalance |
src/plaid/services/plaid-token-lifecycle.service.ts |
inspectAccessToken (non-throwing) + validateAccessToken (throwing, for background paths) |
src/plaid/services/plaid-balance.service.ts |
Returns problem instead of throwing |
src/plaid/entities/plaid-health.entity.ts |
accountIds / accountMasks for the picker |
src/payment/entities/charge-details.entity.ts |
PaymentActionRequired, toPaymentActionRequired |
src/payment/orchestration/payment-orchestration.service.ts |
The gate: problem before hasEnough |
src/payment/orchestration/payment-recurring-subscription.service.ts |
Same gate, blocks every split part |
src/payment/resolvers/payment.resolver.ts |
Addition + reconnect call sites |
src/payment/services/payment-admin.service.ts |
Admin-flavoured message |
src/payment/schemas/background-job.schema.ts |
plaidErrorCode / plaidProblemCode / plaidRequestId |
src/payment/orchestration/payment-background-job.service.ts |
Promotes those fields; creates their indexes in onModuleInit |
src/push-notification/notification-catalog.ts |
PlaidRelinkParams, parameterised relink links |
src/webhook/webhook.service.ts |
Names the bank on the ITEM.ERROR push |
Why the indexes are created in code
autoIndex is off in production, so @Prop({ index: true }) never actually applies there.
PaymentBackgroundJobService.onModuleInit creates them explicitly:
background_jobs_by_plaid_error {plaidErrorCode: 1, createdAt: -1}
background_jobs_by_plaid_problem {plaidProblemCode: 1, createdAt: -1}
Compound with createdAt so "which customers hit ITEM_LOGIN_REQUIRED this week" is one index
scan. Idempotent, background: true, and failures are logged and swallowed — a missing
diagnostic index must never stop the app booting and taking payments.
Before this, the code existed only as a substring inside errorDetails.stack. A customer could
fail eight times in five minutes and nothing countable recorded it.