diff --git a/src/app-pluggyai/app-pluggyai.js b/src/app-pluggyai/app-pluggyai.js new file mode 100644 index 00000000..e4e441a3 --- /dev/null +++ b/src/app-pluggyai/app-pluggyai.js @@ -0,0 +1,200 @@ +import express from 'express'; +import { SecretName, secretsService } from '../services/secrets-service.js'; +import { handleError } from '../app-gocardless/util/handle-error.js'; +import { requestLoggerMiddleware } from '../util/middlewares.js'; +import { pluggyaiService } from './pluggyai-service.js'; + +const app = express(); +export { app as handlers }; +app.use(express.json()); +app.use(requestLoggerMiddleware); + +app.post( + '/status', + handleError(async (req, res) => { + const clientId = secretsService.get(SecretName.pluggyai_clientId); + const configured = clientId != null; + + res.send({ + status: 'ok', + data: { + configured: configured, + }, + }); + }), +); + +app.post( + '/accounts', + handleError(async (req, res) => { + await pluggyaiService.setToken(); + const itemIds = secretsService + .get(SecretName.pluggyai_itemIds) + .split(',') + .map((item) => item.trim()); + + let accounts = []; + + for (const item of itemIds) { + const partial = await pluggyaiService.getAccountsByItemId(item); + accounts = accounts.concat(partial.results); + } + + res.send({ + status: 'ok', + data: { + accounts: accounts, + }, + }); + }), +); + +app.post( + '/transactions', + handleError(async (req, res) => { + const { accountId, startDate, endDate } = req.body; + + let transactions = []; + await pluggyaiService.setToken(); + let result = await pluggyaiService.getTransactionsByAccountId( + accountId, + startDate, + endDate, + 500, + 1, + ); + transactions = transactions.concat(result.results); + const totalPages = result.totalPages; + while (result.page != totalPages) { + result = await pluggyaiService.getTransactionsByAccountId( + accountId, + startDate, + endDate, + 500, + result.page + 1, + ); + transactions = transactions.concat(result.results); + } + + const account = await pluggyaiService.getAccountById(accountId); + + let startingBalance = parseInt( + Math.trunc(account.balance * 100).toString(), + ); + if (account.type === 'CREDIT') { + startingBalance = -startingBalance; + } + const date = getDate(new Date(account.updatedAt)); + + const balances = [ + { + balanceAmount: { + amount: startingBalance, + currency: account.currencyCode, + }, + balanceType: 'expected', + referenceDate: date, + }, + { + balanceAmount: { + amount: startingBalance, + currency: account.currencyCode, + }, + balanceType: 'interimAvailable', + referenceDate: date, + }, + ]; + + const all = []; + const booked = []; + const pending = []; + + for (const trans of transactions) { + const newTrans = {}; + + let dateToUse = 0; + + if (trans.status === 'PENDING') { + newTrans.booked = false; + } else { + newTrans.booked = true; + } + dateToUse = trans.date; + + const transactionDate = new Date(dateToUse); + + if (transactionDate < startDate) { + continue; + } + + newTrans.date = getDate(transactionDate); + + newTrans.payeeName = ''; + if ( + trans.merchant && + (trans.merchant.name || trans.merchant.businessName) + ) { + newTrans.payeeName = trans.merchant.name || trans.merchant.businessName; + } else if ( + trans.type === 'DEBIT' && + trans.paymentData && + trans.paymentData.receiver && + trans.paymentData.receiver.name + ) { + newTrans.payeeName = trans.paymentData.receiver.name; + } else if ( + trans.type === 'CREDIT' && + trans.paymentData && + trans.paymentData.payer && + trans.paymentData.payer.name + ) { + newTrans.payeeName = trans.paymentData.payer.name; + } else if ( + trans.type === 'DEBIT' && + trans.paymentData && + trans.paymentData.receiver && + trans.paymentData.receiver.documentNumber && + trans.paymentData.receiver.documentNumber.value + ) { + newTrans.payeeName = trans.paymentData.receiver.documentNumber.value; + } else if ( + trans.type === 'CREDIT' && + trans.paymentData && + trans.paymentData.payer && + trans.paymentData.payer.documentNumber && + trans.paymentData.payer.documentNumber.value + ) { + newTrans.payeeName = trans.paymentData.receiver.documentNumber.value; + } + + newTrans.remittanceInformationUnstructured = trans.descriptionRaw; + newTrans.transactionAmount = { + amount: account.type === 'BANK' ? trans.amount : -trans.amount, + currency: trans.currencyCode, + }; + newTrans.transactionId = trans.id; + newTrans.valueDate = newTrans.bookingDate; //always undefined? + + if (newTrans.booked) { + booked.push(newTrans); + } else { + pending.push(newTrans); + } + all.push(newTrans); + } + + res.send({ + status: 'ok', + data: { + balances, + startingBalance, + transactions: { all, booked, pending }, + }, + }); + return; + }), +); + +function getDate(date) { + return date.toISOString().split('T')[0]; +} diff --git a/src/app-pluggyai/pluggyai-service.js b/src/app-pluggyai/pluggyai-service.js new file mode 100644 index 00000000..1d59bc28 --- /dev/null +++ b/src/app-pluggyai/pluggyai-service.js @@ -0,0 +1,226 @@ +import https from 'https'; +import jwt from 'jws'; +import { SecretName, secretsService } from '../services/secrets-service.js'; + +let pluggyApiKey = null; + +export const pluggyaiService = { + /** + * Check if the PluggyAi service is configured to be used. + * @returns {boolean} + */ + isConfigured: () => { + return !!( + secretsService.get(SecretName.pluggyai_clientId) && + secretsService.get(SecretName.pluggyai_clientSecret) && + secretsService.get(SecretName.pluggyai_itemIds) + ); + }, + + /** + * + * @returns {Promise} + */ + setToken: async () => { + const generateApiKey = async () => { + const clientId = await secretsService.get(SecretName.pluggyai_clientId); + const clientSecret = await secretsService.get( + SecretName.pluggyai_clientSecret, + ); + + const body = JSON.stringify({ clientId, clientSecret }); + + return new Promise((resolve, reject) => { + const options = { + method: 'POST', + port: 443, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + + const req = https.request( + new URL(`https://api.pluggy.ai/auth`), + options, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode === 403) { + reject(new Error('Forbidden')); + } else { + try { + const results = JSON.parse(data); + results.sferrors = results.errors; + results.hasError = false; + results.errors = {}; + resolve(results); + } catch (e) { + console.log(`Error parsing JSON response: ${data}`); + reject(e); + } + } + }); + }, + ); + + req.on('error', (e) => { + reject(e); + }); + + req.write(body); + + req.end(); + }); + }; + const isExpiredJwtToken = (token) => { + if (!token) return true; + + const decodedToken = jwt.decode(token); + if (!decodedToken) { + return true; + } + const payload = decodedToken.payload; + const clockTimestamp = Math.floor(Date.now() / 1000); + return clockTimestamp >= payload.exp; + }; + + if (!pluggyApiKey) { + pluggyApiKey = secretsService.get(SecretName.pluggyai_apiKey); + } + + if (isExpiredJwtToken(pluggyApiKey)) { + try { + pluggyApiKey = (await generateApiKey())?.apiKey; + secretsService.set(SecretName.pluggyai_apiKey, pluggyApiKey); + } catch (error) { + console.error(`Error getting apiKey for Pluggy.ai account: ${error}`); + } + } + }, + getAccountsByItemId: (itemId) => { + const options = { + method: 'GET', + port: 443, + headers: { 'Content-Length': 0, 'X-API-KEY': pluggyApiKey }, + }; + return new Promise((resolve, reject) => { + const req = https.request( + new URL(`https://api.pluggy.ai/accounts?itemId=${itemId}`), + options, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode === 403) { + reject(new Error('Forbidden')); + } else { + try { + const results = JSON.parse(data); + results.sferrors = results.errors; + results.hasError = false; + results.errors = {}; + resolve(results); + } catch (e) { + console.log(`Error parsing JSON response: ${data}`); + reject(e); + } + } + }); + }, + ); + req.on('error', (e) => { + reject(e); + }); + req.end(); + }); + }, + getAccountById: (accountId) => { + const options = { + method: 'GET', + port: 443, + headers: { 'Content-Length': 0, 'X-API-KEY': pluggyApiKey }, + }; + return new Promise((resolve, reject) => { + const req = https.request( + new URL(`https://api.pluggy.ai/accounts/${accountId}`), + options, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode === 403) { + reject(new Error('Forbidden')); + } else { + try { + const results = JSON.parse(data); + results.sferrors = results.errors; + results.hasError = false; + results.errors = {}; + resolve(results); + } catch (e) { + console.log(`Error parsing JSON response: ${data}`); + reject(e); + } + } + }); + }, + ); + req.on('error', (e) => { + reject(e); + }); + req.end(); + }); + }, + getTransactionsByAccountId: ( + accountId, + startDate, + endDate, + pageSize, + page, + ) => { + const options = { + method: 'GET', + port: 443, + headers: { 'Content-Length': 0, 'X-API-KEY': pluggyApiKey }, + }; + return new Promise((resolve, reject) => { + const req = https.request( + new URL( + `https://api.pluggy.ai/transactions?accountId=${accountId}&from=${startDate}&to=${endDate}&pageSize=${pageSize}&page=${page}`, + ), + options, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', async () => { + if (res.statusCode === 403) { + reject(new Error('Forbidden')); + } else { + try { + const results = JSON.parse(data); + resolve(results); + } catch (e) { + console.log(`Error parsing JSON response: ${data}`); + reject(e); + } + } + }); + }, + ); + req.on('error', (e) => { + reject(e); + }); + req.end(); + }); + }, +}; diff --git a/src/app.js b/src/app.js index 80504f14..72db69fd 100644 --- a/src/app.js +++ b/src/app.js @@ -13,6 +13,7 @@ import * as simpleFinApp from './app-simplefin/app-simplefin.js'; import * as secretApp from './app-secrets.js'; import * as adminApp from './app-admin.js'; import * as openidApp from './app-openid.js'; +import * as pluggai from './app-pluggyai/app-pluggyai.js'; const app = express(); @@ -48,6 +49,7 @@ app.use('/sync', syncApp.handlers); app.use('/account', accountApp.handlers); app.use('/gocardless', goCardlessApp.handlers); app.use('/simplefin', simpleFinApp.handlers); +app.use('/pluggyai', pluggai.handlers); app.use('/secret', secretApp.handlers); app.use('/admin', adminApp.handlers); diff --git a/src/services/secrets-service.js b/src/services/secrets-service.js index fb56825f..47fc3921 100644 --- a/src/services/secrets-service.js +++ b/src/services/secrets-service.js @@ -11,6 +11,10 @@ export const SecretName = { gocardless_secretKey: 'gocardless_secretKey', simplefin_token: 'simplefin_token', simplefin_accessKey: 'simplefin_accessKey', + pluggyai_clientId: 'pluggyai_clientId', + pluggyai_clientSecret: 'pluggyai_clientSecret', + pluggyai_itemIds: 'pluggyai_itemIds', + pluggyai_apiKey: 'pluggyai_apiKey', }; class SecretsDb {