diff --git a/packages/server/package.json b/packages/server/package.json index d359376de..5265e986d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -91,6 +91,7 @@ "pluralize": "^8.0.0", "pug": "^3.0.2", "puppeteer": "^10.2.0", + "plaid": "^10.3.0", "qim": "0.0.52", "ramda": "^0.27.1", "rate-limiter-flexible": "^2.1.14", diff --git a/packages/server/src/api/controllers/Banking/BankingController.ts b/packages/server/src/api/controllers/Banking/BankingController.ts new file mode 100644 index 000000000..27838a285 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -0,0 +1,18 @@ +import Container, { Inject, Service } from 'typedi'; +import { Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { PlaidBankingController } from './PlaidBankingController'; + +@Service() +export class BankingController extends BaseController { + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use('/plaid', Container.get(PlaidBankingController).router()); + + return router; + } +} diff --git a/packages/server/src/api/controllers/Banking/PlaidBankingController.ts b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts new file mode 100644 index 000000000..ceef9383b --- /dev/null +++ b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts @@ -0,0 +1,29 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication'; + +@Service() +export class PlaidBankingController extends BaseController { + @Inject() + private plaidApp: PlaidApplication; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post('/link-token', this.linkToken.bind(this)); + + return router; + } + + private async linkToken(req: Request, res: Response) { + const { tenantId } = req; + + const linkToken = await this.plaidApp.getLinkToken(tenantId); + + return res.status(200).send(linkToken); + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 6a41c8304..fdc7d5e3a 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -56,6 +56,7 @@ import { ProjectsController } from './controllers/Projects/Projects'; import { ProjectTasksController } from './controllers/Projects/Tasks'; import { ProjectTimesController } from './controllers/Projects/Times'; import { TaxRatesController } from './controllers/TaxRates/TaxRates'; +import { BankingController } from './controllers/Banking/BankingController'; export default () => { const app = Router(); @@ -118,6 +119,7 @@ export default () => { Container.get(InventoryItemsCostController).router() ); dashboard.use('/cashflow', Container.get(CashflowController).router()); + dashboard.use('/banking', Container.get(BankingController).router()); dashboard.use('/roles', Container.get(RolesController).router()); dashboard.use( '/transactions-locking', diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 4d096875a..c38472b0c 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -177,6 +177,18 @@ module.exports = { service: 'open-exchange-rate', openExchangeRate: { appId: process.env.OPEN_EXCHANGE_RATE_APP_ID, - } - } + }, + }, + + /** + * Plaid. + */ + plaid: { + env: process.env.PLAID_ENV || 'sandbox', + clientId: process.env.PLAID_CLIENT_ID, + secretDevelopment: process.env.PLAID_SECRET_DEVELOPMENT, + secretSandbox: process.env.PLAID_SECRET_SANDBOX, + redirectSandBox: process.env.PLAID_SANDBOX_REDIRECT_URI, + redirectDevelopment: process.env.PLAID_DEVELOPMENT_REDIRECT_URI, + }, }; diff --git a/packages/server/src/lib/Plaid/Plaid.ts b/packages/server/src/lib/Plaid/Plaid.ts new file mode 100644 index 000000000..ccc2a7f10 --- /dev/null +++ b/packages/server/src/lib/Plaid/Plaid.ts @@ -0,0 +1,103 @@ +import { forEach } from 'lodash'; +import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid'; +import { createPlaidApiEvent } from './PlaidApiEventsDBSync'; +import config from '@/config'; + +const OPTIONS = { clientApp: 'Plaid-Pattern' }; + +// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data +// can be useful for troubleshooting. + +/** + * Logging function for Plaid client methods that use an access_token as an argument. Associates + * the Plaid API event log entry with the item and user the request is for. + * + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. + */ +const defaultLogger = async (clientMethod, clientMethodArgs, response) => { + const accessToken = clientMethodArgs[0].access_token; + // const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken( + // accessToken + // ); + // await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response); + + console.log(response); +}; + +/** + * Logging function for Plaid client methods that do not use access_token as an argument. These + * Plaid API event log entries will not be associated with an item or user. + * + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. + */ +const noAccessTokenLogger = async ( + clientMethod, + clientMethodArgs, + response +) => { + console.log(response); + + // await createPlaidApiEvent( + // undefined, + // undefined, + // clientMethod, + // clientMethodArgs, + // response + // ); +}; + +// Plaid client methods used in this app, mapped to their appropriate logging functions. +const clientMethodLoggingFns = { + accountsGet: defaultLogger, + institutionsGet: noAccessTokenLogger, + institutionsGetById: noAccessTokenLogger, + itemPublicTokenExchange: noAccessTokenLogger, + itemRemove: defaultLogger, + linkTokenCreate: noAccessTokenLogger, + transactionsSync: defaultLogger, + sandboxItemResetLogin: defaultLogger, +}; +// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests. +export class PlaidClientWrapper { + constructor() { + // Initialize the Plaid client. + const configuration = new Configuration({ + basePath: PlaidEnvironments[config.plaid.env], + baseOptions: { + headers: { + 'PLAID-CLIENT-ID': config.plaid.clientId, + 'PLAID-SECRET': + config.plaid.env === 'development' + ? config.plaid.secretDevelopment + : config.plaid.secretSandbox, + 'Plaid-Version': '2020-09-14', + }, + }, + }); + + this.client = new PlaidApi(configuration); + + // Wrap the Plaid client methods to add a logging function. + forEach(clientMethodLoggingFns, (logFn, method) => { + this[method] = this.createWrappedClientMethod(method, logFn); + }); + } + + // Allows us to log API request data for troubleshooting purposes. + createWrappedClientMethod(clientMethod, log) { + return async (...args) => { + try { + const res = await this.client[clientMethod](...args); + await log(clientMethod, args, res); + return res; + } catch (err) { + await log(clientMethod, args, err?.response?.data); + throw err; + } + }; + } +} diff --git a/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts b/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts new file mode 100644 index 000000000..9d257b727 --- /dev/null +++ b/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts @@ -0,0 +1,48 @@ +/** + * Creates a single Plaid api event log entry. + * + * @param {string} itemId the item id in the request. + * @param {string} userId the user id in the request. + * @param {string} plaidMethod the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the Plaid api response object. + */ +export const createPlaidApiEvent = async ( + itemId, + userId, + plaidMethod, + clientMethodArgs, + response +) => { + const { + error_code: errorCode, + error_type: errorType, + request_id: requestId, + } = response; + const query = { + text: ` + INSERT INTO plaid_api_events_table + ( + item_id, + user_id, + plaid_method, + arguments, + request_id, + error_type, + error_code + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7); + `, + values: [ + itemId, + userId, + plaidMethod, + JSON.stringify(clientMethodArgs), + requestId, + errorType, + errorCode, + ], + }; + // await db.query(query); +}; diff --git a/packages/server/src/lib/Plaid/index.ts b/packages/server/src/lib/Plaid/index.ts new file mode 100644 index 000000000..4a580954e --- /dev/null +++ b/packages/server/src/lib/Plaid/index.ts @@ -0,0 +1 @@ +export * from './Plaid'; \ No newline at end of file diff --git a/packages/server/src/services/Banking/Plaid/PlaidApplication.ts b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts new file mode 100644 index 000000000..05d814881 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts @@ -0,0 +1,18 @@ +import { Inject, Service } from 'typedi'; +import { PlaidLinkTokenService } from './PlaidLinkToken'; + +@Service() +export class PlaidApplication { + @Inject() + private getLinkTokenService: PlaidLinkTokenService; + + /** + * + * @param tenantId + * @param itemId + * @returns + */ + public getLinkToken(tenantId: number) { + return this.getLinkTokenService.getLinkToken(tenantId); + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts new file mode 100644 index 000000000..7d67ec8af --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts @@ -0,0 +1,37 @@ +import { PlaidClientWrapper } from '@/lib/Plaid'; +import { Service } from 'typedi'; + +@Service() +export class PlaidLinkTokenService { + /** + * Retrieves the plaid link token. + * @param {number} tenantId + * @returns + */ + async getLinkToken(tenantId: number) { + const accessToken = null; + + // must include transactions in order to receive transactions webhooks + const products = ['transactions']; + const linkTokenParams = { + user: { + // This should correspond to a unique id for the current user. + client_user_id: 'uniqueId' + tenantId, + }, + client_name: 'Pattern', + products, + country_codes: ['US'], + language: 'en', + // webhook: httpTunnel.public_url + '/services/webhook', + access_token: accessToken, + }; + // If user has entered a redirect uri in the .env file + // if (redirect_uri.indexOf('http') === 0) { + // linkTokenParams.redirect_uri = redirect_uri; + // } + const plaidInstance = new PlaidClientWrapper(); + const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams); + + return createResponse.data; + } +}