Bundul
Internal
Browse docs
Waiting for review

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:

  1. We computed the fix and threw it away. isAccessTokenValid already worked out updateModeRequired: true, but validateAccessToken rethrew only the message and dropped the flag. The app had no machine-readable signal to open Plaid Link.
  2. Provider copy reached a customer. "Use Link's update mode" is an instruction for us.
  3. 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 parse message.
  • actionRequired == null → behave exactly as before (including "Insufficient balance…").
  • requiresReconnect == true → Reconnect button, open Link with linkToken.
  • 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:

  1. App calls exchangeAndSavePlaidTokens with accessTokenToUpdate → matches the detail, sets isAccessTokenValid = true.
  2. App calls it without accessTokenToUpdate → Plaid update mode returns the same access token, so the newAccessToken branch matches and the flag flips anyway.
  3. App calls nothing at all → still heals. Plaid update mode does not strictly require an exchange, so this is a real scenario. inspectAccessToken does a live /item/get on every attempt and clears a stale isAccessTokenValid: false before 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
BEbank-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-reconnect Infobip template. webhook.service.ts sends 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.