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=\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`); } }