feat: improvement in Plaid accounts disconnecting

This commit is contained in:
Ahmed Bouhuolia
2024-07-29 19:49:20 +02:00
parent f6d4ec504f
commit 894c899847
15 changed files with 118 additions and 132 deletions

View File

@@ -0,0 +1,9 @@
exports.up = function (knex) {
return knex.schema.table('accounts', (table) => {
table.boolean('is_syncing_owner').defaultTo(false).after('is_feeds_active');
});
};
exports.down = function (knex) {
table.dropColumn('is_syncing_owner');
};

View File

@@ -15,6 +15,7 @@ export interface IAccountDTO {
export interface IAccountCreateDTO extends IAccountDTO { export interface IAccountCreateDTO extends IAccountDTO {
currencyCode?: string; currencyCode?: string;
plaidAccountId?: string; plaidAccountId?: string;
plaidItemId?: string;
} }
export interface IAccountEditDTO extends IAccountDTO {} export interface IAccountEditDTO extends IAccountDTO {}
@@ -38,6 +39,7 @@ export interface IAccount {
accountParentType: string; accountParentType: string;
bankBalance: string; bankBalance: string;
plaidItemId: number | null plaidItemId: number | null
lastFeedsUpdatedAt: Date;
} }
export enum AccountNormal { export enum AccountNormal {

View File

@@ -1,70 +1,12 @@
import { forEach } from 'lodash';
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid'; import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';
import { createPlaidApiEvent } from './PlaidApiEventsDBSync';
import config from '@/config'; 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 = {
transactionsRefresh: defaultLogger,
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. // Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests.
export class PlaidClientWrapper { export class PlaidClientWrapper {
constructor() { private static instance: PlaidClientWrapper;
private client: PlaidApi;
private constructor() {
// Initialize the Plaid client. // Initialize the Plaid client.
const configuration = new Configuration({ const configuration = new Configuration({
basePath: PlaidEnvironments[config.plaid.env], basePath: PlaidEnvironments[config.plaid.env],
@@ -76,26 +18,13 @@ export class PlaidClientWrapper {
}, },
}, },
}); });
this.client = new PlaidApi(configuration); 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. public static getClient(): PlaidApi {
createWrappedClientMethod(clientMethod, log) { if (!PlaidClientWrapper.instance) {
return async (...args) => { PlaidClientWrapper.instance = new PlaidClientWrapper();
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;
} }
}; return PlaidClientWrapper.instance.client;
} }
} }

View File

@@ -331,7 +331,7 @@ export default class Account extends mixin(TenantModel, [
modelClass: PlaidItem.default, modelClass: PlaidItem.default,
join: { join: {
from: 'accounts.plaidItemId', from: 'accounts.plaidItemId',
to: 'plaid_items.id', to: 'plaid_items.plaidItemId',
}, },
}, },
}; };

View File

@@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer {
* @returns {Array} * @returns {Array}
*/ */
public includeAttributes = (): string[] => { public includeAttributes = (): string[] => {
return ['formattedAmount', 'flattenName', 'bankBalanceFormatted']; return [
'formattedAmount',
'flattenName',
'bankBalanceFormatted',
'lastFeedsUpdatedAtFormatted',
];
}; };
/** /**
@@ -52,6 +57,15 @@ export class AccountTransformer extends Transformer {
}); });
}; };
/**
* Retrieves the formatted last feeds update at.
* @param {IAccount} account
* @returns {string}
*/
protected lastFeedsUpdatedAtFormatted = (account: IAccount): string => {
return this.formatDate(account.lastFeedsUpdatedAt);
};
/** /**
* Transformes the accounts collection to flat or nested array. * Transformes the accounts collection to flat or nested array.
* @param {IAccount[]} * @param {IAccount[]}

View File

@@ -96,6 +96,11 @@ export class CreateAccount {
...createAccountDTO, ...createAccountDTO,
slug: kebabCase(createAccountDTO.name), slug: kebabCase(createAccountDTO.name),
currencyCode: createAccountDTO.currencyCode || baseCurrency, currencyCode: createAccountDTO.currencyCode || baseCurrency,
// Mark the account is Plaid owner since Plaid item/account is defined on creating.
isSyncingOwner: Boolean(
createAccountDTO.plaidAccountId || createAccountDTO.plaidItemId
),
}; };
}; };
@@ -117,12 +122,7 @@ export class CreateAccount {
const tenantMeta = await TenantMetadata.query().findOne({ tenantId }); const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Authorize the account creation. // Authorize the account creation.
await this.authorize( await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params);
tenantId,
accountDTO,
tenantMeta.baseCurrency,
params
);
// Transformes the DTO to model. // Transformes the DTO to model.
const accountInputModel = this.transformDTOToModel( const accountInputModel = this.transformDTOToModel(
accountDTO, accountDTO,
@@ -157,4 +157,3 @@ export class CreateAccount {
); );
}; };
} }

View File

@@ -33,14 +33,15 @@ export class DisconnectBankAccount {
const account = await Account.query() const account = await Account.query()
.findById(bankAccountId) .findById(bankAccountId)
.whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK]) .whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK])
.withGraphFetched('plaidItem')
.throwIfNotFound(); .throwIfNotFound();
const oldPlaidItem = await PlaidItem.query().findById(account.plaidItemId); const oldPlaidItem = account.plaidItem;
if (!oldPlaidItem) { if (!oldPlaidItem) {
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
} }
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onBankAccountDisconnecting` event. // Triggers `onBankAccountDisconnecting` event.

View File

@@ -27,7 +27,7 @@ export class RefreshBankAccountService {
if (!bankAccount.plaidItem) { if (!bankAccount.plaidItem) {
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
} }
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
await plaidInstance.transactionsRefresh({ await plaidInstance.transactionsRefresh({
access_token: bankAccount.plaidItem.plaidAccessToken, access_token: bankAccount.plaidItem.plaidAccessToken,

View File

@@ -35,20 +35,24 @@ export class DisconnectPlaidItemOnAccountDeleted {
if (!oldAccount.plaidItemId) return; if (!oldAccount.plaidItemId) return;
// Retrieves the Plaid item that associated to the deleted account. // Retrieves the Plaid item that associated to the deleted account.
const oldPlaidItem = await PlaidItem.query(trx).findById( const oldPlaidItem = await PlaidItem.query(trx).findOne(
'plaidItemId',
oldAccount.plaidItemId oldAccount.plaidItemId
); );
// Unlink the Plaid item from all account before deleting it. // Unlink the Plaid item from all account before deleting it.
await Account.query(trx) await Account.query(trx)
.where('plaidItemId', oldAccount.plaidItemId) .where('plaidItemId', oldAccount.plaidItemId)
.patch({ .patch({
plaidAccountId: null,
plaidItemId: null, plaidItemId: null,
}); });
// Remove the Plaid item from the system. // Remove the Plaid item from the system.
await PlaidItem.query(trx).findById(oldAccount.plaidItemId).delete(); await PlaidItem.query(trx)
.findOne('plaidItemId', oldAccount.plaidItemId)
.delete();
if (oldPlaidItem) { if (oldPlaidItem) {
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
// Remove the Plaid item. // Remove the Plaid item.
await plaidInstance.itemRemove({ await plaidInstance.itemRemove({

View File

@@ -28,7 +28,7 @@ export class PlaidItemService {
const { PlaidItem } = this.tenancy.models(tenantId); const { PlaidItem } = this.tenancy.models(tenantId);
const { publicToken, institutionId } = itemDTO; const { publicToken, institutionId } = itemDTO;
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
// Exchange the public token for a private access token and store with the item. // Exchange the public token for a private access token and store with the item.
const response = await plaidInstance.itemPublicTokenExchange({ const response = await plaidInstance.itemPublicTokenExchange({

View File

@@ -26,7 +26,7 @@ export class PlaidLinkTokenService {
webhook: config.plaid.linkWebhook, webhook: config.plaid.linkWebhook,
access_token: accessToken, access_token: accessToken,
}; };
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams); const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);
return createResponse.data; return createResponse.data;

View File

@@ -2,6 +2,11 @@ import * as R from 'ramda';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import bluebird from 'bluebird'; import bluebird from 'bluebird';
import { entries, groupBy } from 'lodash'; import { entries, groupBy } from 'lodash';
import {
AccountBase as PlaidAccountBase,
Item as PlaidItem,
Institution as PlaidInstitution,
} from 'plaid';
import { CreateAccount } from '@/services/Accounts/CreateAccount'; import { CreateAccount } from '@/services/Accounts/CreateAccount';
import { import {
IAccountCreateDTO, IAccountCreateDTO,
@@ -53,6 +58,7 @@ export class PlaidSyncDb {
trx?: Knex.Transaction trx?: Knex.Transaction
) { ) {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
const plaidAccount = await Account.query().findOne( const plaidAccount = await Account.query().findOne(
'plaidAccountId', 'plaidAccountId',
createBankAccountDTO.plaidAccountId createBankAccountDTO.plaidAccountId
@@ -77,13 +83,15 @@ export class PlaidSyncDb {
*/ */
public async syncBankAccounts( public async syncBankAccounts(
tenantId: number, tenantId: number,
plaidAccounts: PlaidAccount[], plaidAccounts: PlaidAccountBase[],
institution: any, institution: PlaidInstitution,
item: PlaidItem,
trx?: Knex.Transaction trx?: Knex.Transaction
): Promise<void> { ): Promise<void> {
const transformToPlaidAccounts = const transformToPlaidAccounts = transformPlaidAccountToCreateAccount(
transformPlaidAccountToCreateAccount(institution); item,
institution
);
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts); const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
await bluebird.map( await bluebird.map(

View File

@@ -53,7 +53,7 @@ export class PlaidUpdateTransactions {
await this.fetchTransactionUpdates(tenantId, plaidItemId); await this.fetchTransactionUpdates(tenantId, plaidItemId);
const request = { access_token: accessToken }; const request = { access_token: accessToken };
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
const { const {
data: { accounts, item }, data: { accounts, item },
} = await plaidInstance.accountsGet(request); } = await plaidInstance.accountsGet(request);
@@ -66,7 +66,13 @@ export class PlaidUpdateTransactions {
country_codes: ['US', 'UK'], country_codes: ['US', 'UK'],
}); });
// Sync bank accounts. // Sync bank accounts.
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx); await this.plaidSync.syncBankAccounts(
tenantId,
accounts,
institution,
item,
trx
);
// Sync bank account transactions. // Sync bank account transactions.
await this.plaidSync.syncAccountsTransactions( await this.plaidSync.syncAccountsTransactions(
tenantId, tenantId,
@@ -141,7 +147,7 @@ export class PlaidUpdateTransactions {
cursor: cursor, cursor: cursor,
count: batchSize, count: batchSize,
}; };
const plaidInstance = new PlaidClientWrapper(); const plaidInstance = PlaidClientWrapper.getClient();
const response = await plaidInstance.transactionsSync(request); const response = await plaidInstance.transactionsSync(request);
const data = response.data; const data = response.data;
// Add this page of results // Add this page of results

View File

@@ -1,18 +1,28 @@
import * as R from 'ramda'; import * as R from 'ramda';
import {
Item as PlaidItem,
Institution as PlaidInstitution,
AccountBase as PlaidAccount,
} from 'plaid';
import { import {
CreateUncategorizedTransactionDTO, CreateUncategorizedTransactionDTO,
IAccountCreateDTO, IAccountCreateDTO,
PlaidAccount,
PlaidTransaction, PlaidTransaction,
} from '@/interfaces'; } from '@/interfaces';
/** /**
* Transformes the Plaid account to create cashflow account DTO. * Transformes the Plaid account to create cashflow account DTO.
* @param {PlaidAccount} plaidAccount * @param {PlaidItem} item -
* @param {PlaidInstitution} institution -
* @param {PlaidAccount} plaidAccount -
* @returns {IAccountCreateDTO} * @returns {IAccountCreateDTO}
*/ */
export const transformPlaidAccountToCreateAccount = R.curry( export const transformPlaidAccountToCreateAccount = R.curry(
(institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => { (
item: PlaidItem,
institution: PlaidInstitution,
plaidAccount: PlaidAccount
): IAccountCreateDTO => {
return { return {
name: `${institution.name} - ${plaidAccount.name}`, name: `${institution.name} - ${plaidAccount.name}`,
code: '', code: '',
@@ -20,9 +30,10 @@ export const transformPlaidAccountToCreateAccount = R.curry(
currencyCode: plaidAccount.balances.iso_currency_code, currencyCode: plaidAccount.balances.iso_currency_code,
accountType: 'cash', accountType: 'cash',
active: true, active: true,
plaidAccountId: plaidAccount.account_id,
bankBalance: plaidAccount.balances.current, bankBalance: plaidAccount.balances.current,
accountMask: plaidAccount.mask, accountMask: plaidAccount.mask,
plaidAccountId: plaidAccount.account_id,
plaidItemId: item.item_id,
}; };
} }
); );

View File

@@ -22,6 +22,7 @@ import {
DashboardRowsHeightButton, DashboardRowsHeightButton,
FormattedMessage as T, FormattedMessage as T,
AppToaster, AppToaster,
If,
} from '@/components'; } from '@/components';
import { CashFlowMenuItems } from './utils'; import { CashFlowMenuItems } from './utils';
@@ -41,6 +42,7 @@ import {
useDisconnectBankAccount, useDisconnectBankAccount,
useUpdateBankAccount, useUpdateBankAccount,
} from '@/hooks/query/bank-rules'; } from '@/hooks/query/bank-rules';
import { current } from '@reduxjs/toolkit';
function AccountTransactionsActionsBar({ function AccountTransactionsActionsBar({
// #withDialogActions // #withDialogActions
@@ -66,6 +68,7 @@ function AccountTransactionsActionsBar({
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
const isFeedsActive = !!currentAccount.is_feeds_active; const isFeedsActive = !!currentAccount.is_feeds_active;
const isSyncingOwner = currentAccount.is_syncing_owner;
// Handle table row size change. // Handle table row size change.
const handleTableRowSizeChange = (size) => { const handleTableRowSizeChange = (size) => {
@@ -176,6 +179,7 @@ function AccountTransactionsActionsBar({
/> />
<NavbarDivider /> <NavbarDivider />
<If condition={isSyncingOwner}>
<Tooltip <Tooltip
content={ content={
isFeedsActive isFeedsActive
@@ -191,6 +195,7 @@ function AccountTransactionsActionsBar({
intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER} intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER}
/> />
</Tooltip> </Tooltip>
</If>
</NavbarGroup> </NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}> <NavbarGroup align={Alignment.RIGHT}>
@@ -203,17 +208,15 @@ function AccountTransactionsActionsBar({
}} }}
content={ content={
<Menu> <Menu>
{isFeedsActive && ( <If condition={isSyncingOwner && isFeedsActive}>
<>
<MenuItem onClick={handleBankUpdateClick} text={'Update'} /> <MenuItem onClick={handleBankUpdateClick} text={'Update'} />
<MenuDivider /> <MenuDivider />
</> </If>
)}
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} /> <MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
{isFeedsActive && ( <If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} /> <MenuItem onClick={handleDisconnectClick} text={'Disconnect'} />
)} </If>
</Menu> </Menu>
} }
> >