Midday / apps /engine /src /providers /plaid /transform.ts
Jules
Final deployment with all fixes and verified content
c09f67c
import { Providers } from "@engine/common/schema";
import { getType } from "@engine/utils/account";
import { getLogoURL } from "@engine/utils/logo";
import { capitalCase } from "change-case";
import type { Transaction, TransactionCode } from "plaid";
import type {
Account as BaseAccount,
Transaction as BaseTransaction,
GetAccountBalanceResponse,
} from "../types";
import type {
TransformAccount,
TransformAccountBalance,
TransformInstitution,
TransformTransactionPayload,
} from "./types";
export const mapTransactionMethod = (type?: TransactionCode | null) => {
switch (type) {
case "bill payment":
return "payment";
case "purchase":
return "card_purchase";
case "atm":
return "card_atm";
case "transfer":
return "transfer";
case "interest":
return "interest";
case "bank charge":
return "fee";
default:
return "other";
}
};
type MapTransactionCategory = {
transaction: Transaction;
amount: number;
accountType: string;
};
export const mapTransactionCategory = ({
transaction,
amount,
accountType,
}: MapTransactionCategory) => {
// Check Plaid's category first - they have good categorization
if (transaction.personal_finance_category?.primary === "INCOME") {
return "income";
}
// Plaid categorizes credit card payments under the detailed category
if (
transaction.personal_finance_category?.detailed ===
"LOAN_PAYMENTS_CREDIT_CARD_PAYMENT"
) {
return "credit-card-payment";
}
if (amount > 0) {
// For credit accounts, positive amount means money came IN (payment, refund, cashback)
if (accountType === "credit") {
// Check if it's a transfer type
if (
transaction.personal_finance_category?.primary === "TRANSFER_IN" ||
transaction.transaction_code === "bill payment"
) {
return "credit-card-payment";
}
// Otherwise it's likely a refund - don't auto-categorize
return null;
}
return "income";
}
if (
transaction.transaction_code === "bank charge" ||
transaction.personal_finance_category?.primary === "BANK_FEES"
) {
return "fees";
}
if (transaction.personal_finance_category?.primary === "FOOD_AND_DRINK") {
return "meals";
}
if (
transaction.personal_finance_category?.primary === "TRANSPORTATION" ||
transaction.personal_finance_category?.primary === "TRAVEL"
) {
return "travel";
}
// Software and technology
if (
transaction.personal_finance_category?.detailed ===
"GENERAL_SERVICES_OTHER_GENERAL_SERVICES"
) {
return "software";
}
// Utilities - use new utilities category instead of facilities-expenses
if (
transaction.personal_finance_category?.detailed ===
"RENT_AND_UTILITIES_GAS_AND_ELECTRICITY" ||
transaction.personal_finance_category?.detailed ===
"RENT_AND_UTILITIES_SEWAGE_AND_WASTE_MANAGEMENT" ||
transaction.personal_finance_category?.detailed ===
"RENT_AND_UTILITIES_WATER" ||
transaction.personal_finance_category?.detailed ===
"RENT_AND_UTILITIES_OTHER_UTILITIES"
) {
return "utilities"; // Updated to use new utilities category
}
if (
transaction.personal_finance_category?.detailed ===
"RENT_AND_UTILITIES_RENT"
) {
return "rent";
}
if (
transaction.personal_finance_category?.detailed ===
"RENT_AND_UTILITIES_INTERNET_AND_CABLE" ||
transaction.personal_finance_category?.detailed ===
"RENT_AND_UTILITIES_TELEPHONE"
) {
return "internet-and-telephone";
}
// Professional services
if (
transaction.personal_finance_category?.primary === "PROFESSIONAL_SERVICES"
) {
return "professional-services-fees";
}
// Insurance
if (transaction.personal_finance_category?.primary === "INSURANCE") {
return "insurance";
}
// Marketing and advertising
if (transaction.personal_finance_category?.primary === "MARKETING") {
return "marketing";
}
// Home improvement for office supplies
if (transaction.personal_finance_category?.primary === "HOME_IMPROVEMENT") {
return "office-supplies";
}
if (transaction.personal_finance_category?.primary === "ENTERTAINMENT") {
return "activity";
}
// Tax payments
if (transaction.personal_finance_category?.primary === "TAX") {
return "taxes";
}
// Healthcare/medical - could be benefits
if (transaction.personal_finance_category?.primary === "MEDICAL") {
return "benefits";
}
// General merchandise - could be office supplies for small amounts
if (
transaction.personal_finance_category?.primary === "GENERAL_MERCHANDISE" &&
Math.abs(amount) < 500 // Small amounts likely office supplies
) {
return "office-supplies";
}
// Large general merchandise - likely equipment
if (
transaction.personal_finance_category?.primary === "GENERAL_MERCHANDISE" &&
Math.abs(amount) >= 500 // Large amounts likely equipment
) {
return "equipment";
}
return null;
};
const formatAmout = (amount: number) => {
// 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.
return +(amount * -1);
};
const transformDescription = (transaction: Transaction) => {
const name = capitalCase(transaction.name);
if (
transaction?.original_description &&
transaction.original_description !== name
) {
return capitalCase(transaction.original_description);
}
if (transaction?.merchant_name && transaction?.merchant_name !== name) {
return transaction?.merchant_name;
}
return null;
};
export const transformTransaction = ({
transaction,
accountType,
}: TransformTransactionPayload): BaseTransaction => {
const method = mapTransactionMethod(transaction?.transaction_code);
const amount = formatAmout(transaction.amount);
const description = transformDescription(transaction) ?? null;
return {
id: transaction.transaction_id,
date: transaction.date,
name: transaction.name,
description,
currency_rate: null,
currency_source: null,
method,
amount,
currency:
transaction?.iso_currency_code?.toUpperCase() ||
transaction?.unofficial_currency_code?.toUpperCase() ||
"USD",
category: mapTransactionCategory({ transaction, amount, accountType }),
counterparty_name: transaction?.counterparties?.[0]?.name
? capitalCase(transaction.counterparties[0].name)
: null,
merchant_name: transaction?.merchant_name || null,
balance: null,
status: transaction.pending ? "pending" : "posted",
};
};
export const transformAccount = ({
account_id,
name,
balances,
institution,
type,
subtype,
mask,
persistent_account_id,
}: TransformAccount): BaseAccount => {
const accountType = getType(type);
return {
id: account_id,
name,
currency:
balances?.iso_currency_code?.toUpperCase() ||
balances?.unofficial_currency_code?.toUpperCase() ||
"USD",
type: accountType,
enrollment_id: null,
balance: transformAccountBalance({ balances, accountType }),
institution: {
id: institution.id,
name: institution.name,
logo: getLogoURL(institution.id),
provider: Providers.enum.plaid,
},
// Use persistent_account_id (stable across Item resets for TAN institutions)
// Fall back to mask (last 2-4 digits) for other institutions
resource_id: persistent_account_id || mask || null,
expires_at: null,
iban: null, // Plaid (US-focused) doesn't typically provide IBAN
subtype: subtype || null, // checking, savings, credit_card, mortgage, etc.
bic: null, // Plaid doesn't have BIC
// US bank details - requires Auth product, fetched separately
routing_number: null,
wire_routing_number: null,
account_number: null,
sort_code: null,
// Credit account balances - Plaid provides both
available_balance: balances?.available ?? null,
credit_limit: balances?.limit ?? null,
};
};
type TransformAccountBalanceParams = {
balances?: TransformAccountBalance;
accountType?: string;
};
export const transformAccountBalance = ({
balances,
accountType,
}: TransformAccountBalanceParams): GetAccountBalanceResponse => {
// For credit cards, use `current` (amount owed), not `available` (available credit)
// Example: $5000 limit, $1000 owed → available=$4000, current=$1000
// We want to show $1000 (current), not $4000 (available)
const amount =
accountType === "credit"
? (balances?.current ?? 0)
: (balances?.available ?? balances?.current ?? 0);
return {
currency:
balances?.iso_currency_code?.toUpperCase() ||
balances?.unofficial_currency_code?.toUpperCase() ||
"USD",
amount,
available_balance: balances?.available ?? null,
credit_limit: balances?.limit ?? null,
};
};
export const transformInstitution = (institution: TransformInstitution) => ({
id: institution.institution_id,
name: institution.name,
logo: getLogoURL(institution.institution_id),
provider: Providers.enum.plaid,
});