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
168 lines
5.6 KiB
JavaScript
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 };
|