/** * Teller API client * * Read-only access to enrolled bank accounts. Teller uses mutual TLS: every * request presents the client certificate downloaded from the Teller dashboard, * and the enrollment's access token is sent as the HTTP Basic *username* with an * empty password. * * Uses https.request rather than fetch — Node's global fetch is undici, which * ignores an https.Agent and needs its own dispatcher type for client certs. */ const fs = require('fs'); const https = require('https'); const API_HOST = 'api.teller.io'; // Teller returns transactions newest-first with no date filter — only a count // and a cursor. We over-fetch and trim by date on our side. const DEFAULT_COUNT = 200; const DEFAULT_DAYS = 10; class TellerError extends Error { constructor(message, status) { super(message); this.name = 'TellerError'; this.status = status; } } let _agent = null; // The mTLS agent is built once and reused; the cert never changes at runtime. function getAgent() { if (_agent) return _agent; const certPath = process.env.TELLER_CERT_PATH; const keyPath = process.env.TELLER_KEY_PATH; if (!certPath || !keyPath) { throw new TellerError('TELLER_CERT_PATH and TELLER_KEY_PATH must be set in .env', 500); } let cert, key; try { cert = fs.readFileSync(certPath); key = fs.readFileSync(keyPath); } catch (err) { throw new TellerError(`Cannot read Teller certificate: ${err.message}`, 500); } _agent = new https.Agent({ cert, key, keepAlive: true }); return _agent; } // Resolve an enrollment's access token from the environment. Sources name their // own variable via config.teller.token_env so several banks can coexist. function getToken(tokenEnv) { const name = tokenEnv || 'TELLER_TOKEN'; const token = process.env[name]; if (!token) { throw new TellerError(`Teller access token not found — set ${name} in .env`, 500); } return token; } function get(path, token) { return new Promise((resolve, reject) => { const req = https.request({ host: API_HOST, path, method: 'GET', agent: getAgent(), auth: `${token}:`, headers: { Accept: 'application/json' }, }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', chunk => { body += chunk; }); res.on('end', () => { if (res.statusCode === 401) { return reject(new TellerError( 'Teller rejected the access token (401) — the enrollment likely needs to be reconnected through Teller Connect', 401)); } if (res.statusCode >= 400) { let detail = body.slice(0, 200); try { detail = JSON.parse(body).error?.message || detail; } catch { /* keep raw body */ } return reject(new TellerError(`Teller returned ${res.statusCode}: ${detail}`, res.statusCode)); } try { resolve(JSON.parse(body)); } catch { reject(new TellerError('Teller returned a non-JSON response', 502)); } }); }); req.on('error', err => reject(new TellerError(`Teller request failed: ${err.message}`, 502))); req.end(); }); } // Flatten a Teller transaction into the shallow string map the rule engine // expects. Nested `details` is hoisted; `links` is dropped. function flatten(txn) { const details = txn.details || {}; const counterparty = details.counterparty || {}; return { id: txn.id, date: txn.date, description: txn.description, amount: txn.amount, status: txn.status, type: txn.type, running_balance: txn.running_balance, account_id: txn.account_id, category: details.category, processing_status: details.processing_status, counterparty_name: counterparty.name, counterparty_type: counterparty.type, }; } async function listAccounts(tokenEnv) { const accounts = await get('/accounts', getToken(tokenEnv)); return accounts.map(a => ({ id: a.id, name: a.name, type: a.type, subtype: a.subtype, last_four: a.last_four, status: a.status, institution: a.institution?.name, })); } /** * Fetch transactions for one account. * * days — keep transactions on or after today minus this many days; 0 fetches everything returned * includePending — pending transaction ids change when they post, so they are excluded by default */ async function fetchTransactions({ accountId, tokenEnv, count, days, includePending = false }) { // Callers pass values straight off the query string, so anything unparseable // falls back to the default rather than becoming NaN. count = Number.isFinite(count) ? count : DEFAULT_COUNT; days = Number.isFinite(days) ? days : DEFAULT_DAYS; const token = getToken(tokenEnv); const txns = await get(`/accounts/${encodeURIComponent(accountId)}/transactions?count=${count}`, token); let kept = txns; if (!includePending) { kept = kept.filter(t => t.status === 'posted'); } if (days > 0) { const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); kept = kept.filter(t => t.date >= cutoff); // ISO dates compare correctly as strings } return { fetched: txns.length, records: kept.map(flatten), }; } module.exports = { listAccounts, fetchTransactions, TellerError, DEFAULT_COUNT, DEFAULT_DAYS };