> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Plaid Bank Feed Integration Contract

> Version: 1.0.0 Created: 2026-01-28 Updated: 2026-01-29 Status: 📋 Planned Owner: FA (Finance & Accounting)

**Version:** 1.0.0\
**Created:** 2026-01-28\
**Updated:** 2026-01-29\
**Status:** 📋 Planned\
**Owner:** FA (Finance & Accounting)

***

## Overview

This document defines the integration contract between Encore Health OS's FA module and Plaid for automated bank feed integration. Plaid provides broad institution coverage (11,000+ globally), OAuth-based authentication, and a developer-friendly API.

***

## Integration Pattern

**Type:** External API Integration via Edge Functions\
**Direction:** Outbound (Encore Health OS → Plaid) + Inbound (Webhooks)\
**Authentication:** API Keys (client\_id + secret)

***

## Authentication Requirements

### Environment Matrix

| Environment | URL                     | Notes                          |
| ----------- | ----------------------- | ------------------------------ |
| Sandbox     | `sandbox.plaid.com`     | Test data, no real banks       |
| Development | `development.plaid.com` | Real banks, limited access     |
| Production  | `production.plaid.com`  | Full access, requires approval |

### Credentials

| Credential             | Purpose                        | Storage         |
| ---------------------- | ------------------------------ | --------------- |
| `PLAID_CLIENT_ID`      | Application identification     | Supabase Secret |
| `PLAID_SECRET`         | API authentication             | Supabase Secret |
| `PLAID_ENVIRONMENT`    | Environment selection          | Supabase Secret |
| `PLAID_WEBHOOK_SECRET` | Webhook signature verification | Supabase Secret |

***

## API Endpoints

### Plaid API (Outbound)

| Endpoint                         | Method | Purpose                                          |
| -------------------------------- | ------ | ------------------------------------------------ |
| `/link/token/create`             | POST   | Create Link token for frontend                   |
| `/item/public_token/exchange`    | POST   | Exchange public token for access token           |
| `/transactions/sync`             | POST   | Sync transactions (incremental)                  |
| `/accounts/get`                  | POST   | Get account details                              |
| `/accounts/balance/get`          | POST   | Get account balances                             |
| `/item/remove`                   | POST   | Disconnect account                               |
| `/transfer/authorization/create` | POST   | Authorize a transfer (required before create)    |
| `/transfer/create`               | POST   | Create transfer using approved authorization\_id |
| `/transfer/intent/create`        | POST   | Create transfer intent for Transfer UI           |
| `/transfer/intent/get`           | POST   | Get transfer intent status and transfer\_id      |

### Edge Functions (Internal)

| Function                       | Method | Purpose                                                                   |
| ------------------------------ | ------ | ------------------------------------------------------------------------- |
| `plaid-create-link-token`      | POST   | Create Plaid Link token                                                   |
| `plaid-exchange-token`         | POST   | Exchange public\_token for access\_token                                  |
| `plaid-sync`                   | POST   | Sync transactions for account                                             |
| `plaid-disconnect`             | POST   | Disconnect Plaid account                                                  |
| `plaid-webhook`                | POST   | Handle Plaid webhook events                                               |
| `hr-plaid-transfer-create`     | POST   | Create ACH credits for payroll (uses authorize then create)               |
| `plaid-transfer-intent-create` | POST   | Create transfer intent for Transfer UI (one-time payment/disbursement)    |
| `plaid-transfer-intent-get`    | POST   | Get transfer intent status and transfer\_id after Transfer UI             |
| `plaid-create-link-token`      | POST   | Supports `transfer_intent_id` + `link_customization_name` for Transfer UI |

***

## Transfer UI (One-Time Payment / Disbursement)

Transfer UI is Plaid’s hosted flow for **one-time** debit or credit. It is **not** used for batch payroll (see Programmatic Plaid Transfer below).

**Flow:**

1. **Create intent:** Frontend calls `plaid-transfer-intent-create` with `organization_id`, `mode` (PAYMENT | DISBURSEMENT), `amount`, `description`, `ach_class`, `user` (e.g. `legal_name`). Backend calls Plaid `POST /transfer/intent/create` and returns `transfer_intent_id`.
2. **Link token:** Frontend calls `plaid-create-link-token` with `organization_id`, `transfer_intent_id`, and optional `link_customization_name`. Backend returns a link token with `products: ["transfer"]` and `transfer.intent_id`.
3. **Open Link:** Frontend opens Plaid Link with that token; user authorizes the transfer in Plaid’s UI.
4. **Post-success:** On Link `onSuccess`, `metadata.transfer_status` is available; frontend may call `plaid-transfer-intent-get` with `transfer_intent_id` to get final `status`, `transfer_id`, and `failure_reason`.

**Configuration:**

* **Origination account:** Required for transfer intent. Resolved in order: (1) `fa_module_settings.plaid_origination_account_id` for the organization, (2) env `PLAID_ORIGINATION_ACCOUNT_ID`. At least one must be set.
* **Link customization:** In Plaid Dashboard, create a Link customization with **Account Select: Enabled for one account**. Optionally set `fa_module_settings.plaid_link_customization_name` per org, or pass `link_customization_name` when requesting the link token / from frontend (`useTransferUI` / `TransferUIButton`).

**Frontend:** Platform banking exposes `useTransferUI` and `TransferUIButton` from `@/platform/banking`; they orchestrate intent create → link token → Link open → optional intent/get.

***

## Programmatic Plaid Transfer (HR Payroll)

Payroll direct deposit uses **Plaid Transfer** in a two-step flow. Per [Plaid: Creating transfers](https://plaid.com/docs/transfer/creating-transfers/):

1. **Authorize:** Call `POST /transfer/authorization/create` with `access_token`, `account_id`, `type`, `network`, `amount`, `ach_class`, `user`, and an **idempotency\_key**. Plaid returns `authorization_id` and `decision` (approved/declined).
2. **Create:** If approved, call `POST /transfer/create` with `authorization_id`, `access_token`, `account_id`, and `description` only.

The `hr-plaid-transfer-create` edge function uses the shared `plaid-client.ts` (`createTransferAuthorization`, `createTransfer`) and passes an idempotency key per payment item (e.g. `hr-pay-{batchId}-{itemId}`) to avoid duplicate transfers on retries. ACH description is limited to 10 characters (e.g. `PAY` + batch number suffix).

***

## Data Flow

```text theme={null}
┌─────────────────┐                 ┌─────────────────┐
│   Encore Health OS    │                 │   Plaid API     │
│   Frontend      │                 │                 │
└────────┬────────┘                 └────────┬────────┘
         │                                    │
         │ 1. Request Link Token              │
         │    (to Edge Function)              │
         ▼                                    │
┌─────────────────┐                           │
│ plaid-create-   │ 2. POST /link/token/create│
│ link-token      ├──────────────────────────►│
│                 │                           │
│                 │ 3. link_token             │
│                 │◄──────────────────────────┤
└────────┬────────┘                           │
         │                                    │
         │ 4. link_token to frontend          │
         ▼                                    │
┌─────────────────┐                           │
│   Plaid Link    │ 5. User authenticates     │
│   (Frontend)    ├──────────────────────────►│
│                 │                           │
│                 │ 6. public_token           │
│                 │◄──────────────────────────┤
└────────┬────────┘                           │
         │                                    │
         │ 7. POST /plaid-exchange-token      │
         ▼                                    │
┌─────────────────┐ 8. POST /item/public_     │
│ plaid-exchange- │    token/exchange         │
│ token           ├──────────────────────────►│
│                 │                           │
│                 │ 9. access_token + accounts│
│                 │◄──────────────────────────┤
│                 │                           │
│                 │ 10. Store in fa_bank_accounts
└────────┬────────┘                           │
         │                                    │
         │ 11. Webhook: INITIAL_UPDATE        │
         │◄───────────────────────────────────┤
         │                                    │
         ▼                                    │
┌─────────────────┐ 12. POST /transactions/sync
│   plaid-sync    ├──────────────────────────►│
│                 │                           │
│                 │ 13. transactions + cursor │
│                 │◄──────────────────────────┤
│                 │                           │
│                 │ 14. Store in fa_bank_statement_lines
└─────────────────┘
```

***

## Webhook Events

### Supported Events

| Event                  | Action                                       |
| ---------------------- | -------------------------------------------- |
| `INITIAL_UPDATE`       | Trigger initial transaction sync             |
| `HISTORICAL_UPDATE`    | Trigger historical transaction sync          |
| `DEFAULT_UPDATE`       | Trigger incremental transaction sync         |
| `TRANSACTIONS_REMOVED` | Mark removed transactions                    |
| `ITEM_LOGIN_REQUIRED`  | Set status to `reauth_required`              |
| `ERROR`                | Log error, potentially set status to `error` |

### Signature Verification

**Header:** `Plaid-Verification`

The webhook body is signed using JWT. Verification requires:

1. Fetch Plaid's verification key
2. Verify JWT signature
3. Validate claims (iat, request\_body\_sha256)

```typescript theme={null}
// Webhook verification (simplified)
const verifyPlaidWebhook = async (body: string, verification: string) => {
  // 1. Decode JWT header to get key_id
  // 2. Fetch verification key from Plaid
  // 3. Verify JWT signature
  // 4. Verify request_body_sha256 matches
  return isValid;
};
```

***

## Database Schema

### fa\_bank\_accounts (Additional Columns)

| Column                    | Type        | Purpose                                       |
| ------------------------- | ----------- | --------------------------------------------- |
| `plaid_account_id`        | TEXT        | Plaid account ID                              |
| `plaid_item_id`           | TEXT        | Plaid Item ID (groups accounts)               |
| `plaid_access_token`      | TEXT        | API access token (encrypted)                  |
| `plaid_institution_id`    | TEXT        | Institution identifier                        |
| `plaid_sync_enabled`      | BOOLEAN     | Auto-sync enabled flag                        |
| `plaid_last_synced_at`    | TIMESTAMPTZ | Last sync timestamp                           |
| `plaid_cursor`            | TEXT        | Sync API pagination cursor                    |
| `plaid_connection_status` | TEXT        | connected/disconnected/error/reauth\_required |
| `bank_provider`           | TEXT        | manual/teller/plaid                           |

### fa\_bank\_statement\_lines (Additional Column)

| Column                 | Type | Purpose                                |
| ---------------------- | ---- | -------------------------------------- |
| `plaid_transaction_id` | TEXT | Plaid transaction ID for deduplication |

### fa\_module\_settings (Additional Columns)

| Column                          | Type    | Purpose                                                                                 |
| ------------------------------- | ------- | --------------------------------------------------------------------------------------- |
| `plaid_integration_enabled`     | BOOLEAN | Feature flag                                                                            |
| `plaid_origination_account_id`  | TEXT    | Transfer UI: Plaid origination (funding) account ID; overrides env when set             |
| `plaid_link_customization_name` | TEXT    | Transfer UI: Link customization name (Account Select: one account) from Plaid Dashboard |

***

## Security Considerations

### Token Storage

* Access tokens stored in `plaid_access_token` column (encrypted at rest by Supabase)
* Tokens never exposed to frontend
* Edge functions access tokens via service role

### Multi-Tenancy

* All Edge Function queries include `organization_id` filter
* RLS policies prevent cross-tenant access
* Audit logging for all Plaid operations

### Webhook Security

* JWT signature verification
* Request body hash validation
* Rate limiting via Supabase

***

## Institution-specific behavior (Duplicate Items)

For **Chase, PNC, Navy Federal Credit Union, and Charles Schwab**, linking the same credentials again (creating a second Plaid Item for the same institution) can invalidate the existing Item at the time the user completes the institution’s OAuth flow. When this occurs:

* **Option 1:** Use Plaid’s **update mode** to repair the existing Item (re-launch Link with the existing `access_token`). That will invalidate the new Item; then remove the new Item via `/item/remove` if it was already created.
* **Option 2:** Remove the old Item using Plaid’s `/item/remove` endpoint and use the new Item.

Encore Health OS prevents duplicate Items per organization by checking `plaid_institution_id` before exchanging the public token (see duplicate prevention in implementation). This runbook is for awareness when handling support cases or when adding Item-level health handling (e.g. webhooks) that may replace an invalidated Item.

***

## Error Handling

### Plaid API Errors

| Error Code              | Meaning                       | Action                          |
| ----------------------- | ----------------------------- | ------------------------------- |
| `ITEM_LOGIN_REQUIRED`   | User needs to re-authenticate | Set status to `reauth_required` |
| `INVALID_ACCESS_TOKEN`  | Token invalid or revoked      | Set status to `disconnected`    |
| `RATE_LIMIT_EXCEEDED`   | Too many requests             | Exponential backoff, retry      |
| `INTERNAL_SERVER_ERROR` | Plaid error                   | Log, retry with backoff         |

### Retry Strategy

```typescript theme={null}
// Exponential backoff for rate limiting
const delays = [1000, 2000, 4000, 8000, 16000]; // ms
for (const delay of delays) {
  const result = await callPlaidApi();
  if (result.error_code !== 'RATE_LIMIT_EXCEEDED') break;
  await sleep(delay);
}
```

***

## Logging

### Allowlisted Fields (Safe to Log)

* `bank_account_id`
* `plaid_item_id` (not access token)
* `plaid_account_id`
* `transactions_synced`
* `sync_duration_ms`

### Prohibited (Never Log)

* `plaid_access_token`
* `plaid_secret`
* Full account numbers

***

## Testing Strategy

### Sandbox Testing

* Use Plaid sandbox environment
* Test institutions: `ins_109508` (Chase), `ins_109511` (Bank of America)
* Test credentials: `user_good` / any password

### Integration Tests

* Link token creation
* Token exchange
* Transaction sync with deduplication
* Webhook signature verification

### E2E Tests

* Full connect → sync → disconnect flow

***

## Rollback Strategy

All Plaid columns are nullable. Rollback procedure:

1. Disable feature flag: `plaid_integration_enabled = false`
2. UI hides all Plaid components
3. Manual CSV/OFX import continues to work
4. No data migration required

***

## Account selection and GL mapping

After Plaid Link succeeds, Encore Health OS uses a **deferred exchange** flow when `onPlaidLinkSuccess` is set:

1. Link returns `public_token` and `metadata.accounts` (all accounts the user linked at the institution).
2. The app shows **PlaidAccountMappingDialog**: user selects which accounts to connect and assigns a **GL account** to each.
3. Only selected accounts are sent to `plaid-exchange-token` with `gl_account_id` per account.
4. Backend creates `fa_bank_accounts` rows and triggers initial sync for each.

**Backend contract:** `plaid-exchange-token` accepts `accounts[]` with optional `gl_account_id`. If provided, that GL is used (and must be an asset, posting-allowed account in the same organization); otherwise the server falls back to default cash/asset lookup.

**Plaid docs:** `metadata.accounts` in the Link `onSuccess` callback contains `id`, `name`, `mask`, `type`, `subtype`. Account selection in Link is controlled by the user inside Plaid’s UI; we do not filter which accounts appear in Link for initial link (optional: use `account_filters` when creating the link token to limit types/subtypes).

***

## Recommendations (from Plaid docs and product)

| Recommendation                   | Purpose                                                                                                                                                                                                  |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Account/GL mapping dialog**    | Let users choose which linked accounts to import and which GL to use (implemented).                                                                                                                      |
| **account\_filters** (optional)  | When creating the link token, pass `account_filters` (e.g. `depository.account_subtypes: ['checking','savings']`, `credit.account_subtypes: ['credit card']`) so Link only shows relevant account types. |
| **transactions.days\_requested** | Request up to 730 days of history in link token if product supports it; improves initial sync.                                                                                                           |
| **Update mode**                  | For existing Items, use `update.account_selection_enabled: true` and `access_token` when creating the link token so users can add/remove accounts without reconnecting.                                  |
| **Link customization**           | Use `link_customization_name` for a consistent/branded Link experience if configured in Plaid Dashboard.                                                                                                 |
| **Error surfacing**              | When exchange returns `accounts: []`, surface `errors[]` from the response (e.g. “No GL account found…”) so users can fix setup (implemented).                                                           |

### Product initialization

We request **Transactions** and **Balance** when creating Link tokens. The **Balance** product is required for `/accounts/balance/get` and for populating `fa_bank_accounts.balance`; without it, Bank Summary and Cash Allocation widgets will show zero or omit the account. Existing Items created with only `transactions` do not have Balance—re-linking (or Plaid update mode with balance added) is needed to get balance. When we add Auth or Identity, consider `optional_products` (Auth) or `required_if_supported_products` (Identity) per Plaid's [Choosing when to initialize products](https://plaid.com/docs/link/initializing-products/).

### Balance and cash position

When Plaid updates `fa_bank_accounts.balance` (in **plaid-sync** after a manual or post-exchange sync, or in **plaid-webhook** on `SYNC_UPDATES_AVAILABLE`), the same cash position flow as Teller is used: we invoke the **fa-handle-bank-balance-updated** edge function with `organization_id`, `bank_account_id`, `old_balance`, `new_balance`, and optional `updated_by`. That handler recalculates from all active bank accounts and upserts `fa_cash_positions` for the current date, so the Cash Allocation & Credit widget stays in sync with Plaid balance changes.

***

## References

* **Spec:** `specs/fa/specs/FA-PLAID-INTEGRATION-SPEC.md`
* **Review:** `specs/fa/reviews/FA-PLAID-INTEGRATION-REVIEW.md`
* **Teller Integration:** `docs/architecture/integrations/FA_TELLER_INTEGRATION.md`
* **Plaid Sandbox verification:** `docs/testing/PLAID_SANDBOX_VERIFICATION.md` (env, payroll \$11.11, Transfer UI flow, optional script)
* **Plaid Docs:** [https://plaid.com/docs/](https://plaid.com/docs/)
* **Plaid API Reference:** [https://plaid.com/docs/api/](https://plaid.com/docs/api/)
* **react-plaid-link:** [https://github.com/plaid/react-plaid-link](https://github.com/plaid/react-plaid-link)
