From 565a36cc48d2dcef056cb2b7fc9ecf99cd911625 Mon Sep 17 00:00:00 2001 From: Zach Whelchel Date: Sat, 20 Jan 2024 17:35:30 -0500 Subject: [PATCH] SimpleFin (#296) * Initial support for SimpleFin. * Added release notes and lint cleanup. * Changed some notes for better context. * Fixed logic. * Changes per requests on PR. * More cleanup of null checks. --- src/app-simplefin/app-simplefin.js | 227 +++++++++++++++++++++++++++++ src/app.js | 2 + src/services/secrets-service.js | 2 + upcoming-release-notes/296.md | 6 + 4 files changed, 237 insertions(+) create mode 100644 src/app-simplefin/app-simplefin.js create mode 100644 upcoming-release-notes/296.md diff --git a/src/app-simplefin/app-simplefin.js b/src/app-simplefin/app-simplefin.js new file mode 100644 index 000000000..82f001011 --- /dev/null +++ b/src/app-simplefin/app-simplefin.js @@ -0,0 +1,227 @@ +import express from 'express'; +import { inspect } from 'util'; +import https from 'https'; +import { SecretName, secretsService } from '../services/secrets-service.js'; + +const app = express(); +export { app as handlers }; +app.use(express.json()); + +app.post('/status', async (req, res) => { + let configured = false; + + let token = secretsService.get(SecretName.simplefin_token); + if (token != null && token !== 'Forbidden') { + configured = true; + } + + res.send({ + status: 'ok', + data: { + configured: configured, + }, + }); +}); + +app.post('/accounts', async (req, res) => { + let accessKey = secretsService.get(SecretName.simplefin_accessKey); + + if (accessKey == null || accessKey === 'Forbidden') { + let token = secretsService.get(SecretName.simplefin_token); + if (token == null || token === 'Forbidden') { + return; + } else { + accessKey = await getAccessKey(token); + secretsService.set(SecretName.simplefin_accessKey, accessKey); + } + } + + const now = new Date(); + let startDate = new Date(now.getFullYear(), now.getMonth(), 1); + let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); + + let accounts = await getAccounts(accessKey, startDate, endDate); + + res.send({ + status: 'ok', + data: { + accounts: accounts.accounts, + }, + }); +}); + +app.post('/transactions', async (req, res) => { + const { accountId, startDate } = req.body; + + let accessKey = secretsService.get(SecretName.simplefin_accessKey); + + if (accessKey == null || accessKey === 'Forbidden') { + return; + } + + try { + let results = await getTransactions(accessKey, new Date(startDate)); + + let account = results.accounts.find((a) => a.id === accountId); + + let response = {}; + + let balance = parseInt(account.balance.replace('.', '')); + let date = new Date(account['balance-date'] * 1000) + .toISOString() + .split('T')[0]; + + response.balances = [ + { + balanceAmount: { amount: account.balance, currency: account.currency }, + balanceType: 'expected', + referenceDate: date, + }, + { + balanceAmount: { amount: account.balance, currency: account.currency }, + balanceType: 'interimAvailable', + referenceDate: date, + }, + ]; + //response.iban = don't have compared to GoCardless + //response.institutionId = don't have compared to GoCardless + response.startingBalance = balance; // could be named differently in this use case. + + let allTransactions = []; + + for (let trans of account.transactions) { + let newTrans = {}; + + //newTrans.bankTransactionCode = don't have compared to GoCardless + newTrans.booked = true; + newTrans.bookingDate = new Date(trans.posted * 1000) + .toISOString() + .split('T')[0]; + newTrans.date = new Date(trans.posted * 1000).toISOString().split('T')[0]; + newTrans.debtorName = trans.payee; + //newTrans.debtorAccount = don't have compared to GoCardless + newTrans.remittanceInformationUnstructured = trans.description; + newTrans.transactionAmount = { amount: trans.amount, currency: 'USD' }; + newTrans.transactionId = trans.id; + newTrans.valueDate = new Date(trans.posted * 1000) + .toISOString() + .split('T')[0]; + + allTransactions.push(newTrans); + } + + response.transactions = { + all: allTransactions, + booked: allTransactions, + pending: [], + }; + + res.send({ + status: 'ok', + data: response, + }); + } catch (error) { + const sendErrorResponse = (data) => + res.send({ status: 'ok', data: { ...data, details: error.details } }); + console.log( + 'Something went wrong', + inspect(error, { depth: null }), + sendErrorResponse, + ); + } +}); + +function parseAccessKey(accessKey) { + let scheme = null; + let rest = null; + let auth = null; + let username = null; + let password = null; + let baseUrl = null; + [scheme, rest] = accessKey.split('//'); + [auth, rest] = rest.split('@'); + [username, password] = auth.split(':'); + baseUrl = `${scheme}//${rest}`; + return { + baseUrl: baseUrl, + username: username, + password: password, + }; +} + +async function getAccessKey(base64Token) { + const token = Buffer.from(base64Token, 'base64').toString(); + const options = { + method: 'POST', + port: 443, + headers: { 'Content-Length': 0 }, + }; + return new Promise((resolve, reject) => { + const req = https.request(new URL(token), options, (res) => { + res.on('data', (d) => { + resolve(d.toString()); + }); + }); + req.on('error', (e) => { + reject(e); + }); + req.end(); + }); +} + +async function getTransactions(accessKey, startDate, endDate) { + const now = new Date(); + startDate = startDate || new Date(now.getFullYear(), now.getMonth(), 1); + endDate = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 1); + console.log( + `${startDate.toISOString().split('T')[0]} - ${ + endDate.toISOString().split('T')[0] + }`, + ); + return await getAccounts(accessKey, startDate, endDate); +} + +function normalizeDate(date) { + return (date.valueOf() - date.getTimezoneOffset() * 60 * 1000) / 1000; +} + +async function getAccounts(accessKey, startDate, endDate) { + const sfin = parseAccessKey(accessKey); + const options = { + headers: { + Authorization: `Basic ${Buffer.from( + `${sfin.username}:${sfin.password}`, + ).toString('base64')}`, + }, + }; + const params = []; + let queryString = ''; + if (startDate) { + params.push(`start-date=${normalizeDate(startDate)}`); + } + if (endDate) { + params.push(`end-date=${normalizeDate(endDate)}`); + } + if (params.length > 0) { + queryString += '?' + params.join('&'); + } + return new Promise((resolve, reject) => { + const req = https.request( + new URL(`${sfin.baseUrl}/accounts${queryString}`), + options, + (res) => { + let data = ''; + res.on('data', (d) => { + data += d; + }); + res.on('end', () => { + resolve(JSON.parse(data)); + }); + }, + ); + req.on('error', (e) => { + reject(e); + }); + req.end(); + }); +} diff --git a/src/app.js b/src/app.js index cf5227585..6dbf3506d 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,7 @@ import rateLimit from 'express-rate-limit'; import * as accountApp from './app-account.js'; import * as syncApp from './app-sync.js'; import * as goCardlessApp from './app-gocardless/app-gocardless.js'; +import * as simpleFinApp from './app-simplefin/app-simplefin.js'; import * as secretApp from './app-secrets.js'; const app = express(); @@ -44,6 +45,7 @@ app.use( app.use('/sync', syncApp.handlers); app.use('/account', accountApp.handlers); app.use('/gocardless', goCardlessApp.handlers); +app.use('/simplefin', simpleFinApp.handlers); app.use('/secret', secretApp.handlers); app.get('/mode', (req, res) => { diff --git a/src/services/secrets-service.js b/src/services/secrets-service.js index 7794060c6..fb56825fe 100644 --- a/src/services/secrets-service.js +++ b/src/services/secrets-service.js @@ -9,6 +9,8 @@ import getAccountDb from '../account-db.js'; export const SecretName = { gocardless_secretId: 'gocardless_secretId', gocardless_secretKey: 'gocardless_secretKey', + simplefin_token: 'simplefin_token', + simplefin_accessKey: 'simplefin_accessKey', }; class SecretsDb { diff --git a/upcoming-release-notes/296.md b/upcoming-release-notes/296.md new file mode 100644 index 000000000..538a35173 --- /dev/null +++ b/upcoming-release-notes/296.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [zachwhelchel,duplaja,lancepick,latetedemelon] +--- + +Add option to link an account to SimpleFIN for syncing transactions. \ No newline at end of file