Midday / apps /engine /src /providers /teller /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 {
Account as BaseAccount,
Transaction as BaseTransaction,
GetAccountBalanceResponse,
} from "../types";
import type {
FormatAmount,
Transaction,
TransformAccount,
TransformAccountBalance,
TransformInstitution,
TransformTransaction,
} from "./types";
export const mapTransactionMethod = (type?: string) => {
switch (type) {
case "payment":
case "bill_payment":
case "digital_payment":
return "payment";
case "card_payment":
return "card_purchase";
case "atm":
return "card_atm";
case "transfer":
return "transfer";
case "ach":
return "ach";
case "interest":
return "interest";
case "deposit":
return "deposit";
case "wire":
return "wire";
case "fee":
return "fee";
default:
return "other";
}
};
type MapTransactionCategory = {
transaction: Transaction;
amount: number;
accountType: string;
};
export const mapTransactionCategory = ({
transaction,
amount,
accountType,
}: MapTransactionCategory) => {
if (transaction.type === "fee") {
return "fees";
}
if (amount > 0) {
// For credit accounts, positive amount after transformation means money came IN
// (e.g., payment, refund, cashback). Determine category based on transaction type/category.
if (accountType === "credit") {
// If Teller categorizes it as income, it might be cashback/rewards
if (transaction.details?.category === "income") {
return "income";
}
// Payment types indicate a credit card payment
if (
transaction.type === "payment" ||
transaction.type === "bill_payment" ||
transaction.type === "digital_payment" ||
transaction.type === "ach" ||
transaction.type === "transfer"
) {
return "credit-card-payment";
}
// Otherwise it's likely a refund - don't auto-categorize, let user decide
return null;
}
return "income";
}
switch (transaction?.details.category) {
case "bar":
case "dining":
case "groceries":
return "meals";
case "transport":
case "transportation":
return "travel";
case "tax":
return "taxes";
case "office":
return "office-supplies";
case "phone":
return "internet-and-telephone";
case "software":
return "software";
case "entertainment":
case "sport":
return "activity";
case "utilities":
return "utilities"; // Updated to use new utilities category
case "electronics":
return "equipment";
case "accommodation":
return "travel"; // Hotel stays should be travel
case "advertising":
return "advertising"; // Use new advertising category
case "charity":
return "charitable-donations"; // Use new charitable donations category
case "education":
return "training"; // Use new training category
case "health":
return "benefits"; // Health-related could be benefits
case "insurance":
return "insurance"; // Use new insurance category
case "fuel":
return "travel"; // Business fuel is typically travel-related
case "home":
return "facilities-expenses"; // Home office expenses
case "service":
return "professional-services-fees"; // General services
default:
return null;
}
};
export const transformDescription = (transaction: Transaction) => {
const description =
transaction?.details?.counterparty?.name &&
capitalCase(transaction.details.counterparty.name);
if (transaction.description !== description && description) {
return capitalCase(description);
}
return null;
};
const formatAmout = ({ amount, accountType }: FormatAmount) => {
// NOTE: For account credit positive values when money moves out of the account; negative values when money moves in.
if (accountType === "credit") {
return +(amount * -1);
}
return +amount;
};
export const transformTransaction = ({
transaction,
accountType,
}: TransformTransaction): BaseTransaction => {
const method = mapTransactionMethod(transaction.type);
const description = transformDescription(transaction);
const amount = formatAmout({
amount: +transaction.amount,
accountType,
});
return {
id: transaction.id,
date: transaction.date,
name: transaction.description && capitalCase(transaction.description),
description: description ?? null,
currency_rate: null,
currency_source: null,
method,
amount,
currency: "USD",
category: mapTransactionCategory({ transaction, amount, accountType }),
balance: transaction?.running_balance ? +transaction.running_balance : null,
counterparty_name: transaction?.details?.counterparty?.name
? capitalCase(transaction.details.counterparty.name)
: null,
merchant_name: null,
status: transaction?.status === "posted" ? "posted" : "pending",
};
};
type TransformAccountParams = TransformAccount & {
accountDetails?: {
account_number: string;
routing_numbers: {
ach: string | null;
wire: string | null;
bacs: string | null;
};
} | null;
};
export const transformAccount = ({
id,
name,
currency,
enrollment_id,
type,
subtype,
institution,
balance,
last_four,
accountDetails,
}: TransformAccountParams): BaseAccount => {
const accountType = getType(type);
return {
id,
name,
currency: currency.toUpperCase(),
enrollment_id: enrollment_id,
institution: transformInstitution(institution),
type: accountType,
balance: transformAccountBalance({ balance, accountType }),
// Use last_four as stable identifier for account matching during reconnect
resource_id: last_four,
expires_at: null,
iban: null, // Teller (US-only) doesn't have IBAN
subtype: subtype || null, // checking, savings, money_market, credit_card, etc.
bic: null, // Teller doesn't have BIC
// US bank account details from /accounts/:id/details
routing_number: accountDetails?.routing_numbers?.ach || null,
wire_routing_number: accountDetails?.routing_numbers?.wire || null,
account_number: accountDetails?.account_number || null,
sort_code: accountDetails?.routing_numbers?.bacs || null,
available_balance: null, // Not available without expensive /balances endpoint
credit_limit: null, // Teller doesn't provide credit limit
};
};
type TransformAccountBalanceParams = {
balance: TransformAccountBalance;
accountType?: string;
};
/**
* Transform Teller balance to internal format.
* Balance is derived from running_balance in transactions (free API call).
*
* Teller typically returns positive values for credit card debt.
* Normalization is added for safety and consistency with other providers.
*/
export const transformAccountBalance = ({
balance,
accountType,
}: TransformAccountBalanceParams): GetAccountBalanceResponse => {
const rawAmount = +balance.amount;
// Normalize credit card balances to positive (amount owed) for consistency
const amount =
accountType === "credit" && rawAmount < 0 ? Math.abs(rawAmount) : rawAmount;
return {
currency: balance.currency.toUpperCase(),
amount,
available_balance: null, // Not available without paid /balances endpoint
credit_limit: null, // Teller doesn't provide credit limit
};
};
export const transformInstitution = (institution: TransformInstitution) => ({
id: institution.id,
name: institution.name,
logo: getLogoURL(institution.id),
provider: Providers.enum.teller,
});