phase-2: extract shared utils

This commit is contained in:
Sai Kiran Vella
2026-06-04 11:06:30 -04:00
committed by saikiranvella
parent 5a4b4aa6d1
commit d5cf3fc31f
49 changed files with 299 additions and 120 deletions
+201
View File
@@ -0,0 +1,201 @@
import fs from 'fs';
import https from 'https';
import http from 'http';
// SimpleFIN auth flow:
// 1. You get a Setup Token from https://beta-bridge.simplefin.org
// 2. This client decodes it, POSTs once to claim an Access URL
// 3. The CLI saves it to .env; a server would store it in its own secret store.
// 4. All subsequent requests use the Access URL directly.
//
// .env configuration:
// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8...
// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
export class SimpleFINClient {
// logger: object with .write() / .log() / .warn() — defaults to console.
// onAccessUrlClaimed(url): optional callback so the caller can persist the URL
// (CLI uses it to write .env; a server would store it elsewhere).
constructor({ logger, onAccessUrlClaimed } = {}) {
this.accessUrl = null;
this.logger = logger ?? {
write: (msg) => process.stdout.write(msg),
log: (...args) => console.log(...args),
warn: (...args) => console.warn(...args),
};
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
}
async init() {
if (process.env.SIMPLEFIN_ACCESS_URL) {
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
return;
}
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
if (this.onAccessUrlClaimed) {
await this.onAccessUrlClaimed(this.accessUrl);
}
return;
}
throw new Error(
'SimpleFIN not configured.\n' +
'Add to .env:\n' +
' SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\n' +
'The Access URL will be saved automatically on first run.',
);
}
async getAccounts(options = {}) {
if (!this.accessUrl) await this.init();
const startDate = options.startDate ?? this._daysAgo(30);
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
// fetch() rejects URLs with embedded credentials (user:pass@host).
// Extract them and send as a Basic Auth header instead.
const parsed = new URL(this.accessUrl);
const auth = parsed.username
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
: null;
parsed.username = '';
parsed.password = '';
const cleanBase = parsed.toString().replace(/\/$/, '');
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
const response = await fetch(url, {
headers: auth ? { Authorization: auth } : {},
});
if (!response.ok) {
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
}
const data = await response.json();
if (data.errors?.length) {
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
}
return this._normalise(data);
}
// ── Auth ─────────────────────────────────────────────────────────────────────
async _claimAccessUrl(setupToken) {
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n${claimUrl}\n`);
const accessUrl = await this._post(claimUrl);
if (!accessUrl || !accessUrl.startsWith('http')) {
throw new Error(
`Unexpected response from SimpleFIN: "${accessUrl}"\n` +
'Setup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org',
);
}
this.logger.write('✅ Access URL received\n');
return accessUrl.trim();
}
_post(url) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const options = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(body.trim());
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
}
});
});
req.on('error', reject);
req.end();
});
}
// ── Normalise ────────────────────────────────────────────────────────────────
_normalise(data) {
const accounts = (data.accounts ?? []).map((acc) => ({
id: acc.id,
name: acc.name,
currency: acc.currency ?? 'USD',
balance: parseFloat(acc.balance) ?? 0,
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
org: acc.org?.name ?? 'Unknown',
type: this._classifyAccount(acc.name),
transactions: (acc.transactions ?? []).map((tx) => ({
id: tx.id,
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
amount: parseFloat(tx.amount) ?? 0,
description: tx.description ?? '',
category: this._categorise(tx.description ?? ''),
})),
}));
return { accounts, errors: data.errors ?? [] };
}
_classifyAccount(name) {
const n = name.toLowerCase();
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
if (n.includes('saving')) return 'SAVINGS';
if (n.includes('credit') || n.includes('card')) return 'CREDIT';
if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira'))
return 'INVESTMENT';
if (n.includes('loan') || n.includes('mortgage')) return 'LOAN';
return 'OTHER';
}
_categorise(description) {
const d = description.toLowerCase();
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions';
if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining';
if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas';
if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport';
if (d.match(/rent|mortgage|hoa|property/)) return 'Housing';
if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities';
if (d.match(/payroll|salary|direct deposit/)) return 'Income';
if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer';
return 'Other';
}
_daysAgo(n) {
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
}
}
// CLI helper — saves the access URL to .env after the setup token is claimed.
// Pass this as `onAccessUrlClaimed` when constructing SimpleFINClient in CLI context.
export function saveAccessUrlToEnv(accessUrl) {
try {
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n');
}
} catch {
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
}
}