docs/credit-card-transaction-handling.md
This document explains how Midday handles credit card transactions from different banking providers to ensure correct categorization and amount signs.
Credit card transactions have unique semantics compared to regular bank accounts:
The challenge is that different providers report these transactions with different sign conventions, and money coming IN to a credit card is NOT income - it could be a payment, refund, or reward.
| Account Type | Transaction | Teller Reports | After Transform |
|---|---|---|---|
| Credit | Purchase $100 | +100 | -100 (expense) |
| Credit | Payment $200 | -200 | +200 (payment) |
| Depository | Deposit $500 | +500 | +500 (income) |
| Depository | Withdrawal $50 | -50 | -50 (expense) |
Transform: Credit accounts have sign inverted (amount * -1), depository accounts use raw value.
| Transaction | Plaid Reports | After Transform |
|---|---|---|
| Money OUT | +amount | -amount |
| Money IN | -amount | +amount |
Transform: ALL amounts are inverted (amount * -1). Plaid convention is "positive = money out".
| Transaction | API Reports | Result |
|---|---|---|
| Money IN | credit_debit_indicator: "CRDT" + positive amount | Positive |
| Money OUT | credit_debit_indicator: "DBIT" + positive amount | Negative |
Transform: Uses credit_debit_indicator field. No account-type-specific handling needed.
| Transaction | API Reports | Result |
|---|---|---|
| Money IN | Positive amount | Positive |
| Money OUT | Negative amount | Negative |
Transform: Uses signed amounts directly. No transformation needed.
After amount transformation, all providers use consistent category logic:
if (amount > 0) {
if (accountType === "credit") {
// Money IN to credit card - NOT automatically income
if (looksLikePayment(transaction)) {
return "credit-card-payment";
}
// Might be refund, cashback, etc. - let user categorize
return null;
}
return "income"; // Depository account
}
return null; // Negative amounts (expenses) - let user categorize
| Provider | Payment Indicators |
|---|---|
| Teller | transaction.type is payment, bill_payment, digital_payment, ach, or transfer |
| Plaid | personal_finance_category.primary is LOAN_PAYMENTS or TRANSFER_IN, or transaction_code is "bill payment" |
| Enable Banking | bank_transaction_code.description is "Transfer" or "Payment" |
| GoCardless | proprietaryBankTransactionCode is "Transfer" or "Payment" |
transaction.details.category === "income", categorized as "income"personal_finance_category.primary === "INCOME", categorized as "income"null so user can manually categorize (could be miscategorized as income otherwise)cashAccountType / cash_account_type | Midday Type |
|---|---|
CARD | credit |
LOAN | other_asset |
CACC (Current Account) | depository |
SVGS (Savings) | depository |
TRAN (Transaction) | depository |
CASH | depository |
| Others | depository |
Account type is explicitly provided by the API (e.g., credit, depository).
Each provider has tests covering:
"income"nullnull (negative amount)"credit-card-payment"null (user categorizes)Incorrect handling could cause:
The fix ensures:
"credit-card-payment" (a transfer, not income)"income"apps/engine/src/providers/teller/transform.tsapps/engine/src/providers/plaid/transform.tsapps/engine/src/providers/enablebanking/transform.tsapps/engine/src/providers/gocardless/transform.tsFrom Plaid's official API documentation and confirmed in our codebase comment:
"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, and refunds are negative."
Source: Plaid Transactions API - amount field
Our implementation inverts ALL Plaid amounts, which converts their convention to: positive = money IN, negative = money OUT.
From Teller's API documentation:
"The signed amount of the transaction as a string."
For credit accounts specifically, Teller uses opposite signs:
Source: Teller Transactions API
Our implementation inverts ONLY credit account amounts.
Both providers follow the Berlin Group NextGenPSD2 standard which uses:
credit_debit_indicator: "CRDT" (Credit = money IN) or "DBIT" (Debit = money OUT)cashAccountType: ISO 20022 account type codes (CACC, CARD, SVGS, etc.)Sources:
| Code | Name | Description |
|---|---|---|
CACC | Current | Account for transactional operations |
CARD | CardAccount | Account for card payments |
CASH | CashPayment | Cash payment account |
SVGS | Savings | Savings account |
TRAN | Transaction | Transaction account |
LOAN | Loan | Loan account |
Source: ISO 20022 External Cash Account Type Code
| Provider | Sign Convention | Category Logic | Confidence |
|---|---|---|---|
| Plaid | ✅ Documented in code, matches Plaid docs | ✅ Uses Plaid's own categories | HIGH |
| Teller | ✅ Documented in code, verified with API | ✅ Uses transaction type field | HIGH |
| Enable Banking | ✅ Uses standard CRDT/DBIT indicator | ✅ Uses bank_transaction_code | HIGH |
| GoCardless | ✅ Uses standard signed amounts | ✅ Uses proprietaryBankTransactionCode | HIGH |