phase-6: typescript introduction
This commit is contained in:
+60
-62
@@ -1,22 +1,48 @@
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import type { Logger } from '../../types.js';
|
||||
|
||||
// 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
|
||||
interface SimpleFINOptions {
|
||||
logger?: Logger;
|
||||
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface GetAccountsOptions {
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
balance: number;
|
||||
balanceDate: string;
|
||||
org: string;
|
||||
type: string;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
interface SimpleFINData {
|
||||
accounts: Account[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
private accessUrl: string | null;
|
||||
private logger: Logger;
|
||||
private onAccessUrlClaimed: ((url: string) => Promise<void> | void) | null;
|
||||
|
||||
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
|
||||
this.accessUrl = null;
|
||||
this.logger = logger ?? {
|
||||
write: (msg) => process.stdout.write(msg),
|
||||
@@ -26,37 +52,28 @@ export class SimpleFINClient {
|
||||
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
async init(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
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.',
|
||||
'SimpleFIN not configured.\nAdd to .env:\n SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\nThe Access URL will be saved automatically on first run.',
|
||||
);
|
||||
}
|
||||
|
||||
async getAccounts(options = {}) {
|
||||
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
|
||||
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 parsed = new URL(this.accessUrl!);
|
||||
const auth = parsed.username
|
||||
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
|
||||
: null;
|
||||
@@ -65,43 +82,34 @@ export class SimpleFINClient {
|
||||
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 } : {},
|
||||
});
|
||||
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();
|
||||
|
||||
const data = (await response.json()) as { accounts?: unknown[]; errors?: string[] };
|
||||
if (data.errors?.length) {
|
||||
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
|
||||
}
|
||||
|
||||
return this._normalise(data);
|
||||
return this._normalise(data as { accounts: unknown[]; errors: string[] });
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async _claimAccessUrl(setupToken) {
|
||||
private async _claimAccessUrl(setupToken: string): Promise<string> {
|
||||
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',
|
||||
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup 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) {
|
||||
private _post(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
@@ -112,30 +120,23 @@ export class SimpleFINClient {
|
||||
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) => {
|
||||
res.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(body.trim());
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
|
||||
}
|
||||
if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 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) => ({
|
||||
private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
|
||||
const accounts = (data.accounts ?? []).map((acc: any) => ({
|
||||
id: acc.id,
|
||||
name: acc.name,
|
||||
currency: acc.currency ?? 'USD',
|
||||
@@ -143,7 +144,7 @@ export class SimpleFINClient {
|
||||
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) => ({
|
||||
transactions: (acc.transactions ?? []).map((tx: any) => ({
|
||||
id: tx.id,
|
||||
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
|
||||
amount: parseFloat(tx.amount) ?? 0,
|
||||
@@ -151,11 +152,10 @@ export class SimpleFINClient {
|
||||
category: this._categorise(tx.description ?? ''),
|
||||
})),
|
||||
}));
|
||||
|
||||
return { accounts, errors: data.errors ?? [] };
|
||||
}
|
||||
|
||||
_classifyAccount(name) {
|
||||
private _classifyAccount(name: string): string {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
|
||||
if (n.includes('saving')) return 'SAVINGS';
|
||||
@@ -166,7 +166,7 @@ export class SimpleFINClient {
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
_categorise(description) {
|
||||
private _categorise(description: string): string {
|
||||
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';
|
||||
@@ -181,14 +181,12 @@ export class SimpleFINClient {
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
_daysAgo(n) {
|
||||
private _daysAgo(n: number): number {
|
||||
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) {
|
||||
export function saveAccessUrlToEnv(accessUrl: string): void {
|
||||
try {
|
||||
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
|
||||
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
|
||||
Reference in New Issue
Block a user