dataflow/api/lib/teller.js
Paul Trowbridge c4e7211e6d Add Teller API sync as an alternative to CSV import
Sources with a `teller` block in their config can pull transactions
straight from the bank instead of taking a CSV upload. Only the fetch
differs — dedupe, logging, and transformation reuse the import path.

api/lib/teller.js speaks Teller's mutual-TLS protocol (client cert plus
the access token as the HTTP Basic username) and flattens transactions
into the shallow map the rule engine expects. Access tokens live in .env,
one per enrollment, not in the database that manage.py offers to reset.

Pending transactions are skipped by default: their ids change when they
post, which would import the same charge twice under two keys. Sources
should use ['id'] as constraint_fields — Teller's transaction id makes
overlapping pulls free while keeping genuinely repeated charges distinct.

Untested against the live API — Teller has no self-serve signup at the
moment, so no account to verify against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
2026-08-01 12:43:32 -04:00

168 lines
5.6 KiB
JavaScript

/**
* 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 };