BillTracker/services/simplefinService.js

216 lines
7.9 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// SimpleFIN consumer client.
//
// This module handles the protocol-level work: claiming tokens, fetching
// accounts/transactions, and normalizing the raw data into DB row shapes.
//
// Security rules enforced here:
// - Only HTTPS claim URLs are accepted.
// - Only HTTPS Access URLs are accepted.
// - Tokens and Access URLs are never logged.
// - Error messages are sanitized before being returned to callers.
function sanitizeErrorMessage(msg) {
if (typeof msg !== 'string') return 'Provider error';
// Strip embedded HTTP Basic Auth credentials (https://user:pass@host)
return msg.replace(/https?:\/\/[^@\s]+:[^@\s]+@/gi, 'https://[credentials]@');
}
function sanitizeError(err) {
const msg = sanitizeErrorMessage(err?.message || String(err || 'Unknown error'));
const e = new Error(msg);
e.code = err?.code || 'SIMPLEFIN_ERROR';
e.status = err?.status;
return e;
}
function sanitizeRawData(obj) {
if (!obj || typeof obj !== 'object') return null;
const safe = JSON.parse(JSON.stringify(obj));
// Remove org sfin-url which may embed auth
if (safe.org) delete safe.org['sfin-url'];
try {
return JSON.stringify(safe).slice(0, 4096);
} catch {
return null;
}
}
async function claimSetupToken(setupToken) {
if (!setupToken || typeof setupToken !== 'string') {
throw new Error('setupToken is required');
}
const token = setupToken.trim();
// Base64-decode to get the claim URL; handle both raw base64 and data-URLs
let claimUrl;
try {
const decoded = Buffer.from(token, 'base64').toString('utf8').trim();
claimUrl = decoded.startsWith('http') ? decoded : token;
} catch {
claimUrl = token;
}
// Also accept a raw URL pasted directly
if (!claimUrl.startsWith('http')) {
throw new Error('Could not decode setup token into a claim URL');
}
if (!claimUrl.startsWith('https://')) {
throw new Error('Setup token must use a secure HTTPS URL');
}
let accessUrl;
try {
const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) });
if (res.status === 403) {
throw new Error('This setup token has already been claimed or is invalid');
}
if (!res.ok) {
throw new Error(`Token claim failed (HTTP ${res.status})`);
}
accessUrl = (await res.text()).trim();
} catch (err) {
throw sanitizeError(err);
}
if (!accessUrl.startsWith('https://')) {
throw new Error('Provider returned an insecure Access URL — only HTTPS is supported');
}
return accessUrl;
}
const FETCH_RETRY_ATTEMPTS = 3;
const FETCH_RETRY_DELAYS = [1000, 2000]; // ms between attempts 1→2 and 2→3
async function fetchAccountsAndTransactions(accessUrl, sinceEpoch) {
let url;
try {
url = new URL(accessUrl);
} catch {
throw new Error('Invalid Access URL');
}
const basicAuth = Buffer.from(`${url.username}:${url.password}`).toString('base64');
const baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/?$/, '')}`;
// pending=1 asks SimpleFIN to include not-yet-settled transactions (excluded by default).
const endpoint = `${baseUrl}/accounts?start-date=${Math.floor(sinceEpoch)}&pending=1&version=2`;
let lastErr;
for (let attempt = 0; attempt < FETCH_RETRY_ATTEMPTS; attempt++) {
if (attempt > 0) await new Promise(r => setTimeout(r, FETCH_RETRY_DELAYS[attempt - 1]));
let res;
try {
res = await fetch(endpoint, {
headers: { 'Authorization': `Basic ${basicAuth}` },
signal: AbortSignal.timeout(30000),
});
} catch (err) {
// Network error or timeout — retry unless this was the last attempt
if (attempt < FETCH_RETRY_ATTEMPTS - 1) { lastErr = err; continue; }
throw sanitizeError(err);
}
if (res.status === 403) {
throw Object.assign(new Error('SimpleFIN access has been revoked — please reconnect'), { code: 'SIMPLEFIN_REVOKED' });
}
if (!res.ok) {
const err = new Error(`SimpleFIN fetch failed (HTTP ${res.status})`);
// Retry transient server errors; surface client errors immediately
if (attempt < FETCH_RETRY_ATTEMPTS - 1 && res.status >= 500) { lastErr = err; continue; }
throw sanitizeError(err);
}
let data;
try {
data = await res.json();
} catch (err) {
throw sanitizeError(err);
}
// Surface any connection-level errors from the errlist so callers can log them
if (Array.isArray(data.errlist) && data.errlist.length > 0) {
const msgs = data.errlist
.map(e => sanitizeErrorMessage(e.message || e.msg || e.code || 'Unknown error'))
.join('; ');
data._errlistSummary = msgs;
}
return data;
}
throw sanitizeError(lastErr);
}
function normalizeAccount(rawAccount, dataSourceId, userId) {
const balance = rawAccount.balance != null ? Math.round(parseFloat(rawAccount.balance) * 100) : null;
const availableBalance = rawAccount['available-balance'] != null ? Math.round(parseFloat(rawAccount['available-balance']) * 100) : null;
return {
user_id: userId,
data_source_id: dataSourceId,
provider_account_id: String(rawAccount.id),
name: String(rawAccount.name || 'Account').slice(0, 255),
org_name: (rawAccount.org?.name || rawAccount.conn_name) ? String(rawAccount.org?.name || rawAccount.conn_name).slice(0, 255) : null,
account_type: null,
currency: rawAccount.currency ? String(rawAccount.currency).slice(0, 10) : 'USD',
balance: Number.isFinite(balance) ? balance : null,
available_balance: Number.isFinite(availableBalance) ? availableBalance : null,
raw_data: sanitizeRawData(rawAccount),
};
}
// accountCurrency: currency string from the parent account (e.g. "USD", "EUR").
// accountId: raw SimpleFIN account id — used in the stable dedup key so the key
// survives disconnect/reconnect (data_source_id is intentionally omitted).
function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, accountId, accountCurrency) {
const amount = Math.round(parseFloat(rawTx.amount) * 100);
// Pending transactions report posted = 0 (or omit it) until they settle.
const isPending = rawTx.pending === true || rawTx.pending === 1;
// NOTE: deliberately a UTC slice, not localDateString(). SimpleFIN encodes
// the posted *date* as an epoch at UTC midnight, so the UTC calendar day IS
// the bank's posting date; converting to server-local time would shift it
// back a day for any timezone west of UTC.
const postedDate = (rawTx.posted && !isPending)
? new Date(rawTx.posted * 1000).toISOString().slice(0, 10)
: null;
const transactedAt = rawTx['transacted_at']
? new Date(rawTx['transacted_at'] * 1000).toISOString()
: null;
// Format: simplefin:{simplefin_account_id}:{tx.id} (no data_source_id — stable across reconnects)
const providerTxId = `simplefin:${accountId}:${rawTx.id}`;
return {
user_id: userId,
data_source_id: dataSourceId,
account_id: localAccountId,
provider_transaction_id: providerTxId,
source_type: 'provider_sync',
posted_date: postedDate,
transacted_at: transactedAt,
amount: Number.isFinite(amount) ? amount : 0,
currency: accountCurrency ? String(accountCurrency).slice(0, 10) : 'USD',
description: rawTx.description ? String(rawTx.description).slice(0, 500) : null,
payee: rawTx.payee ? String(rawTx.payee).slice(0, 255) : null,
memo: rawTx.memo ? String(rawTx.memo).slice(0, 500) : null,
match_status: 'unmatched',
ignored: 0,
pending: isPending ? 1 : 0,
raw_data: sanitizeRawData(rawTx),
};
}
module.exports = {
claimSetupToken,
fetchAccountsAndTransactions,
normalizeAccount,
normalizeTransaction,
sanitizeError,
sanitizeErrorMessage,
};