211 lines
7.7 KiB
JavaScript
211 lines
7.7 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', 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;
|
|
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,
|
|
};
|