ENGINEERING
5 bugs every Plaid integration ships (and how to catch them)
Every team that integrates Plaid for the first time writes roughly the same code, ships it to production, and then — six to eighteen months later — discovers the same five bugs. We've audited our own codebase, the codebases of three design partners, and several open-source fintech projects on GitHub. These bugs are in all of them.
This isn't a knock on Plaid. The Plaid API is well-documented and the design decisions behind it are defensible. These bugs come from the friction of translating Plaid's shape into your application's shape — the normalization layer every fintech product eventually writes. The bugs are so consistent that we've made avoiding them the core design of @claremesh/transforms.
If you're building treasury, FP&A, month-end close, or any product that reads from Plaid, you should read this before your next sprint.
The Plaid data model in 60 seconds
Plaid returns two main object types when you're dealing with balances:
- Account — a bank account, credit card, loan, or investment account. Has
account_id,balances,type,subtype. - Transaction — a single financial event. Has
transaction_id,account_id,amount,date,category.
Both look reasonable. The bugs are in how these fields interact with the rest of your application.
Bug #1: The sign flip
This is the single most common production bug in fintech applications that use Plaid.
Plaid represents a $50 restaurant charge as amount: 50.0 — a positive number. Plaid's documentation is explicit about this:
Positive values when money moves out of the account; negative values when money moves in. For example, debit card purchases are positive; credit card payments, direct deposits, or refunds are negative.
Now read that again carefully. A debit card purchase is +50. A credit card payment (i.e., paying off the card) is -50.
The problem: almost every accounting system in the world uses the opposite convention. In double-entry bookkeeping, a debit is negative (money leaving an asset) and a credit is positive (money entering). When you persist Plaid transactions into your GL, your ledger or FP&A platform, every sign is backwards.
Teams typically discover this one of three ways:
- The first real customer runs a cash flow report and sees "net inflows" where they expect outflows.
- An auditor reviews reconciliation and flags that checking account activity looks inverted.
- Someone syncs Plaid data into QuickBooks and the QuickBooks balance diverges from the Plaid balance by 2x.
The fix is trivial — multiply every Plaid transaction amount by -1 when normalizing — but teams often make it in the wrong place. The cleanest fix is in your transformation layer, before the data enters your application domain:
// Wrong: handle it at query time
const cashFlow = transactions.reduce((sum, t) => sum - t.amount, 0);
// Wrong: handle it in the UI
<span>{transaction.amount > 0 ? '-' : '+'}${Math.abs(transaction.amount)}</span>
// Right: handle it once, at ingestion, in a dedicated normalization layer
function normalizePlaidTransaction(raw: PlaidTransaction): Transaction {
return {
// ... other fields
amount: -raw.amount, // flip sign to match accounting convention
};
}Put the sign flip in one place. Make that place obvious. Write a test for it. Every downstream consumer should be able to trust that amount follows a consistent convention.
Bug #2: Assuming pending transactions are immutable
Plaid's pending field is a boolean. That's the first trap: developers treat it as a permanent property of a transaction. "This is a pending transaction." "That's a posted transaction."
It's actually a state that every transaction passes through.
When a transaction first appears, Plaid returns it with pending: true and a transaction_id. Days later, when the transaction posts, Plaid returns the same underlying event as a new transaction with pending: false and a completely different transaction_id. The relationship between the two is represented by a pending_transaction_id field on the posted version.
Teams get bitten when they:
- Dedupe on
transaction_idalone and end up with both the pending and posted version in their database. Now the user's running total is wrong by exactly the amount of every pending-then-posted transaction. - Treat the pending transaction as canonical and never update it when the posted version arrives. Now the amount is wrong (posted amounts often differ from pending — tips on restaurant charges, gas station pre-authorizations).
- Use the pending transaction's category/merchant/date. These can all change when the transaction posts.
The correct handling: maintain a join key between pending_transaction_id and transaction_id, and when a posted transaction arrives, supersede the pending one.
// When ingesting a new Plaid transaction
function upsertTransaction(raw: PlaidTransaction) {
if (raw.pending_transaction_id) {
// This is a posted version of a previously-pending transaction.
// Delete or archive the old pending row, insert the new posted one.
deleteByTransactionId(raw.pending_transaction_id);
}
insertTransaction(normalizePlaidTransaction(raw));
}The second trap: Plaid retains pending transactions for a limited window. If a pending transaction is removed before it posts (an authorization that never captures, for example), you'll see it in removed_transactions in the /transactions/sync response. You need to handle removals or your database will accumulate ghost transactions.
Bug #3: Currency drift on multi-currency accounts
Plaid returns iso_currency_code on transactions and balances. For most US banks, it's USD. Good.
For multi-currency accounts, for international fintech products, and for investment accounts holding foreign securities, there's a second field called unofficial_currency_code. This is what Plaid returns when the currency isn't part of ISO 4217 — crypto, reward points, certain emerging market currencies.
The bug: teams select iso_currency_code as the canonical currency field and don't handle the case where it's null. When Plaid returns an account denominated in, say, a stablecoin, the transaction arrives with iso_currency_code: null and unofficial_currency_code: "USDC". Your application reads null, assumes USD (the common default), and now has a mixed-currency account modeled as if it were dollars.
The fix is to pick a canonical field at the normalization layer and fall through cleanly:
function normalizeCurrency(raw: PlaidAccount | PlaidTransaction): string {
if (raw.iso_currency_code) return raw.iso_currency_code;
if (raw.unofficial_currency_code) return raw.unofficial_currency_code;
throw new Error(`Account ${raw.account_id} has no currency information`);
}The exception throw is deliberate. Silent fallback to USD is how you end up with ledger errors six months later. Loud failure at ingestion time is how you catch the problem in development.
Bug #4: Category assumptions that break when Plaid changes
Plaid returns a category array and a category_id. The category looks like ["Food and Drink", "Restaurants"]. The category_id looks like 13005000.
Teams universally treat these as stable. They write application logic like:
if (transaction.category[0] === 'Food and Drink') {
// handle restaurant expense
}Three things are wrong here.
First, Plaid's category taxonomy changes. Not constantly, but enough. In 2024 Plaid introduced a new taxonomy called "Personal Finance Categories" that replaced the legacy category field for new customers. Legacy customers still get the old field. Both coexist. If you're a new customer, transaction.category is null by default and you need to read transaction.personal_finance_category.
Second, the category array ordering isn't guaranteed to be consistent over time for edge cases. A transaction categorized as ["Transfer", "Internal Account Transfer"] today might be ["Transfer", "Account Transfer"] after a taxonomy update.
Third, category strings are localized in some markets. Your hardcoded English string match fails for non-US institutions.
The fix has two layers:
// Normalization layer: pick a canonical category representation
function normalizeCategory(raw: PlaidTransaction): string {
// Prefer the newer personal_finance_category if available
if (raw.personal_finance_category) {
return raw.personal_finance_category.primary;
}
// Fall back to category_id (stable integer taxonomy)
if (raw.category_id) {
return PLAID_CATEGORY_ID_TO_NAME[raw.category_id];
}
// Last resort: the category array
return (raw.category ?? []).join(' · ');
}
// Application layer: use your own category enum, not Plaid's strings
enum ExpenseCategory {
FOOD_AND_DRINK = 'food_and_drink',
TRANSPORTATION = 'transportation',
// ...
}The key move is to never persist Plaid's category strings into your business logic. Normalize once on the way in, map to your own stable enum, and decouple from Plaid's taxonomy changes.
Bug #5: The account_type / subtype confusion
Plaid's account type is one of: depository, credit, loan, investment, brokerage, other.
Plaid's account subtype is more granular: checking, savings, money market, cd, credit card, student loan, mortgage, ira, 401k, and about 30 more.
Teams usually do one of two things that create bugs:
Option A: Use type for classification. Result: a 401(k) and a savings account look the same to your application because both are investment or depository at the type level.
Option B: Use subtype for classification. Result: adding new accounts breaks your application whenever a user connects something your enum doesn't recognize.
Neither is right. The correct move is to map Plaid's type/subtype combo to your own canonical account classification — typically the five double-entry accounting categories:
type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
function classifyAccount(plaidType: string, plaidSubtype: string): AccountType {
// Asset: things you own
if (plaidType === 'depository') return 'asset'; // checking, savings
if (plaidType === 'investment') return 'asset'; // 401k, IRA, brokerage
if (plaidType === 'brokerage') return 'asset';
// Liability: things you owe
if (plaidType === 'credit') return 'liability'; // credit cards
if (plaidType === 'loan') return 'liability'; // mortgages, student loans
// Fallback
return 'asset'; // or throw, depending on your risk tolerance
}Once classified this way, your downstream GL, balance sheet, and reconciliation code can reason about accounts without caring whether a specific account is a checking or an IRA. The granularity stays available in the subtype field for display purposes, but doesn't leak into business logic.
Meta: the pattern behind all five bugs
Notice the shape of every fix above. It's the same pattern:
- Plaid returns data in Plaid's shape.
- You write a normalization layer that converts Plaid's shape to your application's shape.
- All downstream code operates on your shape, not Plaid's.
- When Plaid changes (and they do), you change one file.
This is why we built ClareMesh. Every fintech team eventually writes this normalization layer. They all get the same bugs wrong the first time. They all end up rewriting the layer after the first production incident. The layer is generic enough that sharing it across teams is valuable, and specific enough (5 object types, 6 providers) that it's tractable to maintain.
@claremesh/transforms/plaid handles all five bugs above correctly. The sign flip happens at ingestion. The pending-to-posted transition is a first-class operation. Currency is canonicalized from both Plaid fields. Categories are mapped through a stable intermediate representation. Account classification is via the double-entry framework.
It's MIT licensed and runs entirely in your infrastructure. There's a playground where you can paste a raw Plaid response and see the normalized output in real-time.
Your action items
If you're integrating Plaid right now:
- Search your codebase for
amountand verify sign convention is consistent. - Check whether your dedupe logic handles
pending_transaction_id. - Grep for
iso_currency_codeand confirm you handle null. - Find every string match against
category[0]— those are landmines. - Audit how
account.typeandaccount.subtypeare used in your business logic.
If any of those reveal problems, you can either fix them case-by-case or adopt a normalization layer (ours or your own) and push the fixes into one place.
If you're just starting a Plaid integration, skip ahead — use a normalization layer from day one. You'll save yourself the six-month production incident.
ABOUT CLAREMESH
ClareMesh is an open-source financial data schema and bi-directional sync SDK. It publishes a unified schema for financial primitives and provides MIT-licensed transforms for Plaid, Stripe, QuickBooks, Xero, and NetSuite. Customer data never leaves customer infrastructure.
Questions or corrections? Email malik@claremesh.com or open an issue on GitHub.