BillTracker/services/simplefinService.js

182 lines
6.2 KiB
JavaScript

'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' });
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;
}
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(/\/?$/, '')}`;
const endpoint = `${baseUrl}/accounts?start-date=${Math.floor(sinceEpoch)}&version=2`;
let data;
try {
const res = await fetch(endpoint, {
headers: { 'Authorization': `Basic ${basicAuth}` },
});
if (res.status === 403) {
throw Object.assign(new Error('SimpleFIN access has been revoked — please reconnect'), { code: 'SIMPLEFIN_REVOKED' });
}
if (!res.ok) {
throw new Error(`SimpleFIN fetch failed (HTTP ${res.status})`);
}
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;
}
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),
};
}
function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connId, accountId) {
const amount = Math.round(parseFloat(rawTx.amount) * 100);
const postedDate = rawTx.posted
? 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:{data_source_id}:{simplefin_account_id}:{tx.id}
const providerTxId = `simplefin:${connId}:${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: '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,
raw_data: sanitizeRawData(rawTx),
};
}
module.exports = {
claimSetupToken,
fetchAccountsAndTransactions,
normalizeAccount,
normalizeTransaction,
sanitizeError,
sanitizeErrorMessage,
};