import { createHash } from "node:crypto"; import type { AccountType } from "@engine/utils/account"; import { getLogoURL } from "@engine/utils/logo"; import { capitalCase } from "change-case"; import type { Account, ConnectionStatus, GetAccountBalanceResponse, Transaction, } from "../types"; import type { GetAccountDetailsResponse, GetBalancesResponse, GetExchangeCodeResponse, GetSessionResponse, GetTransaction, Institution, TransformInstitution, } from "./types"; export function hashInstitutionId(name: string, country?: string): string { const input = `${name}-${country}`; return createHash("md5").update(input).digest("hex").slice(0, 12); } export const transformInstitution = ( institution: Institution, ): TransformInstitution => ({ id: hashInstitutionId(institution.name, institution.country), name: institution.name, logo: getLogoURL(institution.name, "png"), provider: "enablebanking", }); function getAccountName(account: GetAccountDetailsResponse) { if (account.product) { return capitalCase(account.product); } if (account.name) { return capitalCase(account.name); } if (account.details) { return capitalCase(account.details); } return "Account"; } /** * Maps Enable Banking cash_account_type (ISO 20022) to Midday AccountType * - CACC: Current account → depository * - CARD: Card account → credit * - SVGS: Savings account → depository * - LOAN: Loan account → loan * - CASH: Cash account → depository */ function getAccountType(cashAccountType?: string): AccountType { switch (cashAccountType) { case "CARD": return "credit"; case "LOAN": return "loan"; default: return "depository"; } } /** * Get available balance from EnableBanking balance. * EnableBanking returns balance_type which can indicate available balance. * * ISO 20022 balance types include: * - closingAvailable, interimAvailable, openingAvailable, forwardAvailable → available funds * - closingBooked, interimBooked, openingBooked → posted/booked balance (NOT available) * * Only "available" types represent available funds. The "interim" prefix indicates * intraday snapshot, NOT available balance. */ const getAvailableBalance = ( balance: GetAccountDetailsResponse["balance"], ): number | null => { // Only match balance types containing "available" (e.g., interimAvailable, closingAvailable) // Do NOT match "interim" alone as interimBooked is a booked balance, not available if (balance?.balance_type?.toLowerCase().includes("available")) { return +balance.balance_amount.amount; } return null; }; export const transformAccount = ( account: GetAccountDetailsResponse, ): Account => { const accountType = getAccountType(account.cash_account_type); const rawAmount = +account.balance.balance_amount.amount; // Normalize credit card balances to positive (amount owed) for consistency // Enable Banking typically returns positive values, but this ensures consistency const amount = accountType === "credit" && rawAmount < 0 ? Math.abs(rawAmount) : rawAmount; return { id: account.uid, name: getAccountName(account), currency: account.currency, type: accountType, institution: { id: hashInstitutionId( account.institution.name, account.institution.country, ), name: account.institution.name, logo: getLogoURL(account.institution.name, "png"), provider: "enablebanking", }, balance: { amount, currency: account.currency, }, enrollment_id: null, resource_id: account.identification_hash, expires_at: account.valid_until, iban: account.account_id?.iban || null, subtype: account.cash_account_type?.toLowerCase() || null, // CACC, CARD, SVGS, LOAN, CASH bic: account.account_servicer?.bic_fi || null, // US bank details not available for EnableBanking (EU/UK provider) routing_number: null, wire_routing_number: null, account_number: null, sort_code: null, // Credit account balances - EnableBanking provides credit_limit directly available_balance: getAvailableBalance(account.balance), credit_limit: account.credit_limit?.amount ? +account.credit_limit.amount : null, }; }; export const transformSessionData = (session: GetExchangeCodeResponse) => { return { session_id: session.session_id, expires_at: session.access.valid_until, access: session.access, accounts: session.accounts.map((account) => ({ account_reference: account.identification_hash, account_id: account.uid, })), }; }; type TransformBalanceParams = { balance: GetBalancesResponse["balances"][0]; creditLimit?: { currency: string; amount: string } | null; accountType?: string; }; /** * Transform Enable Banking balance to internal format. * * Enable Banking typically returns positive values for credit card debt. * Normalization is added for safety and consistency with other providers. */ export const transformBalance = ({ balance, creditLimit, accountType, }: TransformBalanceParams): GetAccountBalanceResponse => { const rawAmount = +balance.balance_amount.amount; // Normalize credit card balances to positive (amount owed) for consistency const amount = accountType === "credit" && rawAmount < 0 ? Math.abs(rawAmount) : rawAmount; // Check if balance_type indicates available balance // Only match "available" types (e.g., interimAvailable, closingAvailable) // Apply same normalization as amount for credit accounts const availableBalance = balance.balance_type ?.toLowerCase() .includes("available") ? accountType === "credit" && rawAmount < 0 ? Math.abs(rawAmount) : rawAmount : null; return { amount, currency: balance.balance_amount.currency, available_balance: availableBalance, credit_limit: creditLimit?.amount ? +creditLimit.amount : null, }; }; export const transformConnectionStatus = ( session: GetSessionResponse, ): ConnectionStatus => ({ status: session.status === "AUTHORIZED" ? "connected" : "disconnected", }); export const transformTransactionName = ( transaction: GetTransaction, ): string => { // Try to get name from remittance information first if ( transaction.remittance_information?.length && transaction.remittance_information[0] !== "" ) { return transaction.remittance_information[0]; } // Try creditor/debtor name if ( transaction.credit_debit_indicator === "CRDT" && transaction.debtor?.name ) { return transaction.debtor.name; } if ( transaction.credit_debit_indicator === "DBIT" && transaction.creditor?.name ) { return transaction.creditor.name; } // Fall back to bank transaction description if available if (transaction.bank_transaction_code?.description) { return transaction.bank_transaction_code.description; } // Use reference number as last resort if (transaction.reference_number) { return transaction.reference_number; } // Default fallback return "No information"; }; type TransformTransactionCategory = { transaction: GetTransaction; accountType: AccountType; }; export const transformTransactionCategory = ({ transaction, accountType, }: TransformTransactionCategory) => { const amount = +transaction.transaction_amount.amount; const isCredit = transaction.credit_debit_indicator === "CRDT"; // For credit (money IN) if (isCredit && amount > 0) { // For credit card accounts, money IN is usually a payment, not income if (accountType === "credit") { // Check if it's a transfer/payment type const description = transaction.bank_transaction_code?.description; if (description === "Transfer" || description === "Payment") { return "credit-card-payment"; } // Otherwise it's likely a refund - don't auto-categorize return null; } return "income"; } return null; }; export const transformTransactionMethod = (transaction: GetTransaction) => { if (transaction.credit_debit_indicator === "CRDT") { return "payment"; } // Transfer if (transaction.bank_transaction_code?.description === "Transfer") { return "transfer"; } return "other"; }; type TransactionDescription = { transaction: GetTransaction; name: string; }; const transformDescription = ({ transaction, name, }: TransactionDescription) => { if ( transaction?.remittance_information?.length && transaction.remittance_information.some( (info) => info && info.trim() !== "", ) ) { const text = transaction.remittance_information .filter((info) => info && info.trim() !== "") .join(" "); const description = capitalCase(text); // NOTE: Sometimes the description is the same as name // Let's skip that and just save if they are not the same if (description !== name) { return description; } } return null; }; const formatAmount = (transaction: GetTransaction): number => { const amount = +transaction.transaction_amount.amount; return transaction.credit_debit_indicator === "CRDT" ? amount : -amount; }; const transformCounterpartyName = (transaction: GetTransaction) => { const { credit_debit_indicator, debtor, creditor } = transaction; if (credit_debit_indicator === "CRDT" && debtor?.name) { return capitalCase(debtor.name); } if (credit_debit_indicator === "DBIT" && creditor?.name) { return capitalCase(creditor.name); } return null; }; type TransformTransactionPayload = { transaction: GetTransaction; accountType: AccountType; }; /** * Generate a stable transaction ID when entry_reference is missing. * Uses EnableBanking's recommended "fundamental values" for matching. * @see https://enablebanking.com/blog/2024/10/29/how-to-sync-account-transactions-from-open-banking-apis-without-unique-transaction-ids */ function generateTransactionId(transaction: GetTransaction): string { if (transaction.entry_reference) { return transaction.entry_reference; } // Use transaction_id if available (bank-specific ID) if (transaction.transaction_id) { return transaction.transaction_id; } // Use fundamental values + additional discriminators for stable ID // balance_after_transaction is particularly useful as it's unique per transaction // in a sequence (running balance changes with each transaction) // Use empty string for null/undefined to preserve positional information // (filtering would cause collisions when different nullable fields have same value) const input = [ transaction.booking_date, transaction.value_date, transaction.transaction_amount.amount, transaction.transaction_amount.currency, transaction.credit_debit_indicator, transaction.reference_number, transaction.remittance_information?.join("|"), transaction.balance_after_transaction?.amount, ] .map((v) => v ?? "") .join("-"); return createHash("md5").update(input).digest("hex"); } export const transformTransaction = ({ transaction, accountType, }: TransformTransactionPayload): Transaction => { const name = capitalCase(transformTransactionName(transaction)); const description = transformDescription({ transaction, name }); return { id: generateTransactionId(transaction), amount: formatAmount(transaction), currency: transaction.transaction_amount.currency, date: transaction.booking_date, status: "posted", balance: transaction.balance_after_transaction ? +transaction.balance_after_transaction.amount : null, category: transformTransactionCategory({ transaction, accountType }), counterparty_name: transformCounterpartyName(transaction), merchant_name: null, method: transformTransactionMethod(transaction), name, description, currency_rate: null, currency_source: null, }; };