feat(server): listen to Plaid webhooks

This commit is contained in:
Ahmed Bouhuolia
2024-02-14 17:11:07 +02:00
parent 706a324121
commit 1fd8a53ed1
8 changed files with 71 additions and 51 deletions

View File

@@ -75,6 +75,8 @@ PLAID_CLIENT_ID=
PLAID_SECRET_DEVELOPMENT= PLAID_SECRET_DEVELOPMENT=
PLAID_SECRET_SANDBOX= PLAID_SECRET_SANDBOX=
PLAID_LINK_WEBHOOK=
# (Optional) Redirect URI settings section # (Optional) Redirect URI settings section
# Only required for OAuth redirect URI testing (not common on desktop): # Only required for OAuth redirect URI testing (not common on desktop):
# Sandbox Mode: # Sandbox Mode:

View File

@@ -16,7 +16,6 @@ export class PlaidBankingController extends BaseController {
router.post('/link-token', this.linkToken.bind(this)); router.post('/link-token', this.linkToken.bind(this));
router.post('/exchange-token', this.exchangeToken.bind(this)); router.post('/exchange-token', this.exchangeToken.bind(this));
router.post('/webhooks', this.webhooks.bind(this));
return router; return router;
} }
@@ -36,7 +35,7 @@ export class PlaidBankingController extends BaseController {
} }
/** /**
* * Exchanges the given public token.
* @param {Request} req * @param {Request} req
* @param {response} res * @param {response} res
* @returns {Response} * @returns {Response}
@@ -51,21 +50,4 @@ export class PlaidBankingController extends BaseController {
}); });
return res.status(200).send({}); return res.status(200).send({});
} }
public async webhooks(req: Request, res: Response) {
const { tenantId } = req;
const {
webhook_type: webhookType,
webhook_code: webhookCode,
item_id: plaidItemId,
} = req.body;
await this.plaidApp.webhooks(
tenantId,
webhookType,
plaidItemId,
webhookCode
);
return res.status(200).send({ code: 200, message: 'ok' });
}
} }

View File

@@ -0,0 +1,47 @@
import { Router } from 'express';
import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
import { Request, Response } from 'express';
import { Inject, Service } from 'typedi';
import BaseController from '../BaseController';
@Service()
export class Webhooks extends BaseController {
@Inject()
private plaidApp: PlaidApplication;
/**
* Router constructor.
*/
router() {
const router = Router();
router.post('/plaid', this.plaidWebhooks.bind(this));
return router;
}
/**
* Listens to Plaid webhooks.
* @param {Request} req
* @param {Response} res
* @returns {Response}
*/
public async plaidWebhooks(req: Request, res: Response) {
const { tenantId } = req;
const {
webhook_type: webhookType,
webhook_code: webhookCode,
item_id: plaidItemId,
} = req.body;
console.log(req.body, 'triggered');
await this.plaidApp.webhooks(
tenantId,
plaidItemId,
webhookType,
webhookCode
);
return res.status(200).send({ code: 200, message: 'ok' });
}
}

View File

@@ -57,6 +57,7 @@ import { ProjectTasksController } from './controllers/Projects/Tasks';
import { ProjectTimesController } from './controllers/Projects/Times'; import { ProjectTimesController } from './controllers/Projects/Times';
import { TaxRatesController } from './controllers/TaxRates/TaxRates'; import { TaxRatesController } from './controllers/TaxRates/TaxRates';
import { BankingController } from './controllers/Banking/BankingController'; import { BankingController } from './controllers/Banking/BankingController';
import { Webhooks } from './controllers/Webhooks/Webhooks';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -72,6 +73,7 @@ export default () => {
app.use('/ping', Container.get(Ping).router()); app.use('/ping', Container.get(Ping).router());
app.use('/jobs', Container.get(Jobs).router()); app.use('/jobs', Container.get(Jobs).router());
app.use('/account', Container.get(Account).router()); app.use('/account', Container.get(Account).router());
app.use('/webhooks', Container.get(Webhooks).router());
// - Dashboard routes. // - Dashboard routes.
// --------------------------- // ---------------------------

View File

@@ -190,5 +190,6 @@ module.exports = {
secretSandbox: process.env.PLAID_SECRET_SANDBOX, secretSandbox: process.env.PLAID_SECRET_SANDBOX,
redirectSandBox: process.env.PLAID_SANDBOX_REDIRECT_URI, redirectSandBox: process.env.PLAID_SANDBOX_REDIRECT_URI,
redirectDevelopment: process.env.PLAID_DEVELOPMENT_REDIRECT_URI, redirectDevelopment: process.env.PLAID_DEVELOPMENT_REDIRECT_URI,
linkWebhook: process.env.PLAID_LINK_WEBHOOK
}, },
}; };

View File

@@ -45,14 +45,14 @@ export class PlaidApplication {
*/ */
public webhooks( public webhooks(
tenantId: number, tenantId: number,
webhookType: string,
plaidItemId: string, plaidItemId: string,
webhookType: string,
webhookCode: string webhookCode: string
) { ) {
return this.plaidWebhooks.webhooks( return this.plaidWebhooks.webhooks(
tenantId, tenantId,
webhookType,
plaidItemId, plaidItemId,
webhookType,
webhookCode webhookCode
); );
} }

View File

@@ -1,5 +1,6 @@
import { PlaidClientWrapper } from '@/lib/Plaid'; import { PlaidClientWrapper } from '@/lib/Plaid';
import { Service } from 'typedi'; import { Service } from 'typedi';
import config from '@/config';
@Service() @Service()
export class PlaidLinkTokenService { export class PlaidLinkTokenService {
@@ -11,7 +12,7 @@ export class PlaidLinkTokenService {
async getLinkToken(tenantId: number) { async getLinkToken(tenantId: number) {
const accessToken = null; const accessToken = null;
// must include transactions in order to receive transactions webhooks // Must include transactions in order to receive transactions webhooks
const products = ['transactions']; const products = ['transactions'];
const linkTokenParams = { const linkTokenParams = {
user: { user: {
@@ -22,13 +23,9 @@ export class PlaidLinkTokenService {
products, products,
country_codes: ['US'], country_codes: ['US'],
language: 'en', language: 'en',
// webhook: httpTunnel.public_url + '/services/webhook', webhook: config.plaid.linkWebhook,
access_token: accessToken, 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 plaidInstance = new PlaidClientWrapper();
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams); const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);

View File

@@ -13,22 +13,24 @@ export class PlaidWebooks {
* @param {string} plaidItemId * @param {string} plaidItemId
* @param {string} webhookCode * @param {string} webhookCode
*/ */
async webhooks( public async webhooks(
tenantId: number, tenantId: number,
webhookType: string,
plaidItemId: string, plaidItemId: string,
webhookType: string,
webhookCode: string webhookCode: string
) { ) {
const _webhookType = webhookType.toLowerCase();
// There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS. // There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS.
// @TODO implement handling for remaining webhook types. // @TODO implement handling for remaining webhook types.
const webhookHandlerMap = { const webhookHandlerMap = {
transactions: this.handleTransactionsWebooks, transactions: this.handleTransactionsWebooks.bind(this),
item: this.itemsHandler, item: this.itemsHandler.bind(this),
}; };
const webhookHandler = const webhookHandler =
webhookHandlerMap[webhookType] || this.unhandledWebhook; webhookHandlerMap[_webhookType] || this.unhandledWebhook;
await webhookHandler(tenantId, webhookCode, plaidItemId); await webhookHandler(tenantId, plaidItemId, webhookCode);
} }
/** /**
@@ -37,7 +39,7 @@ export class PlaidWebooks {
* @param {string} webhookCode * @param {string} webhookCode
* @param {string} plaidItemId * @param {string} plaidItemId
*/ */
async unhandledWebhook( private async unhandledWebhook(
webhookType: string, webhookType: string,
webhookCode: string, webhookCode: string,
plaidItemId: string plaidItemId: string
@@ -114,37 +116,24 @@ export class PlaidWebooks {
*/ */
public async itemsHandler( public async itemsHandler(
tenantId: number, tenantId: number,
webhookCode: string, plaidItemId: string,
plaidItemId: string webhookCode: string
): Promise<void> { ): Promise<void> {
switch (webhookCode) { switch (webhookCode) {
case 'WEBHOOK_UPDATE_ACKNOWLEDGED': case 'WEBHOOK_UPDATE_ACKNOWLEDGED':
this.serverLogAndEmitSocket('is updated', plaidItemId, error); this.serverLogAndEmitSocket('is updated', plaidItemId, error);
break; break;
case 'ERROR': { case 'ERROR': {
this.serverLogAndEmitSocket(
`ERROR: ${error.error_code}: ${error.error_message}`,
itemId,
error.error_code
);
break; break;
} }
case 'PENDING_EXPIRATION': { case 'PENDING_EXPIRATION': {
const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId);
await updateItemStatus(itemId, 'bad');
this.serverLogAndEmitSocket(
`user needs to re-enter login credentials`,
itemId,
error
);
break; break;
} }
default: default:
this.serverLogAndEmitSocket( this.serverLogAndEmitSocket(
'unhandled webhook type received.', 'unhandled webhook type received.',
plaidItemId, webhookCode,
error plaidItemId
); );
} }
} }