mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 05:10:31 +00:00
feat: improvement in Plaid accounts disconnecting
This commit is contained in:
@@ -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');
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export interface IAccountDTO {
|
||||
export interface IAccountCreateDTO extends IAccountDTO {
|
||||
currencyCode?: string;
|
||||
plaidAccountId?: string;
|
||||
plaidItemId?: string;
|
||||
}
|
||||
|
||||
export interface IAccountEditDTO extends IAccountDTO {}
|
||||
@@ -38,6 +39,7 @@ export interface IAccount {
|
||||
accountParentType: string;
|
||||
bankBalance: string;
|
||||
plaidItemId: number | null
|
||||
lastFeedsUpdatedAt: Date;
|
||||
}
|
||||
|
||||
export enum AccountNormal {
|
||||
|
||||
@@ -1,70 +1,12 @@
|
||||
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 = {
|
||||
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.
|
||||
export class PlaidClientWrapper {
|
||||
constructor() {
|
||||
private static instance: PlaidClientWrapper;
|
||||
private client: PlaidApi;
|
||||
|
||||
private constructor() {
|
||||
// Initialize the Plaid client.
|
||||
const configuration = new Configuration({
|
||||
basePath: PlaidEnvironments[config.plaid.env],
|
||||
@@ -76,26 +18,13 @@ export class PlaidClientWrapper {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
public static getClient(): PlaidApi {
|
||||
if (!PlaidClientWrapper.instance) {
|
||||
PlaidClientWrapper.instance = new PlaidClientWrapper();
|
||||
}
|
||||
return PlaidClientWrapper.instance.client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ export default class Account extends mixin(TenantModel, [
|
||||
modelClass: PlaidItem.default,
|
||||
join: {
|
||||
from: 'accounts.plaidItemId',
|
||||
to: 'plaid_items.id',
|
||||
to: 'plaid_items.plaidItemId',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer {
|
||||
* @returns {Array}
|
||||
*/
|
||||
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.
|
||||
* @param {IAccount[]}
|
||||
|
||||
@@ -96,6 +96,11 @@ export class CreateAccount {
|
||||
...createAccountDTO,
|
||||
slug: kebabCase(createAccountDTO.name),
|
||||
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 });
|
||||
|
||||
// Authorize the account creation.
|
||||
await this.authorize(
|
||||
tenantId,
|
||||
accountDTO,
|
||||
tenantMeta.baseCurrency,
|
||||
params
|
||||
);
|
||||
await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params);
|
||||
// Transformes the DTO to model.
|
||||
const accountInputModel = this.transformDTOToModel(
|
||||
accountDTO,
|
||||
@@ -157,4 +157,3 @@ export class CreateAccount {
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,15 @@ export class DisconnectBankAccount {
|
||||
const account = await Account.query()
|
||||
.findById(bankAccountId)
|
||||
.whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK])
|
||||
.withGraphFetched('plaidItem')
|
||||
.throwIfNotFound();
|
||||
|
||||
const oldPlaidItem = await PlaidItem.query().findById(account.plaidItemId);
|
||||
const oldPlaidItem = account.plaidItem;
|
||||
|
||||
if (!oldPlaidItem) {
|
||||
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) => {
|
||||
// Triggers `onBankAccountDisconnecting` event.
|
||||
|
||||
@@ -27,7 +27,7 @@ export class RefreshBankAccountService {
|
||||
if (!bankAccount.plaidItem) {
|
||||
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
|
||||
}
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
|
||||
await plaidInstance.transactionsRefresh({
|
||||
access_token: bankAccount.plaidItem.plaidAccessToken,
|
||||
|
||||
@@ -35,20 +35,24 @@ export class DisconnectPlaidItemOnAccountDeleted {
|
||||
if (!oldAccount.plaidItemId) return;
|
||||
|
||||
// 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
|
||||
);
|
||||
// Unlink the Plaid item from all account before deleting it.
|
||||
await Account.query(trx)
|
||||
.where('plaidItemId', oldAccount.plaidItemId)
|
||||
.patch({
|
||||
plaidAccountId: null,
|
||||
plaidItemId: null,
|
||||
});
|
||||
// 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) {
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
|
||||
// Remove the Plaid item.
|
||||
await plaidInstance.itemRemove({
|
||||
|
||||
@@ -28,7 +28,7 @@ export class PlaidItemService {
|
||||
const { PlaidItem } = this.tenancy.models(tenantId);
|
||||
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.
|
||||
const response = await plaidInstance.itemPublicTokenExchange({
|
||||
|
||||
@@ -26,7 +26,7 @@ export class PlaidLinkTokenService {
|
||||
webhook: config.plaid.linkWebhook,
|
||||
access_token: accessToken,
|
||||
};
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);
|
||||
|
||||
return createResponse.data;
|
||||
|
||||
@@ -2,6 +2,11 @@ import * as R from 'ramda';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import bluebird from 'bluebird';
|
||||
import { entries, groupBy } from 'lodash';
|
||||
import {
|
||||
AccountBase as PlaidAccountBase,
|
||||
Item as PlaidItem,
|
||||
Institution as PlaidInstitution,
|
||||
} from 'plaid';
|
||||
import { CreateAccount } from '@/services/Accounts/CreateAccount';
|
||||
import {
|
||||
IAccountCreateDTO,
|
||||
@@ -53,6 +58,7 @@ export class PlaidSyncDb {
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
|
||||
const plaidAccount = await Account.query().findOne(
|
||||
'plaidAccountId',
|
||||
createBankAccountDTO.plaidAccountId
|
||||
@@ -77,13 +83,15 @@ export class PlaidSyncDb {
|
||||
*/
|
||||
public async syncBankAccounts(
|
||||
tenantId: number,
|
||||
plaidAccounts: PlaidAccount[],
|
||||
institution: any,
|
||||
plaidAccounts: PlaidAccountBase[],
|
||||
institution: PlaidInstitution,
|
||||
item: PlaidItem,
|
||||
trx?: Knex.Transaction
|
||||
): Promise<void> {
|
||||
const transformToPlaidAccounts =
|
||||
transformPlaidAccountToCreateAccount(institution);
|
||||
|
||||
const transformToPlaidAccounts = transformPlaidAccountToCreateAccount(
|
||||
item,
|
||||
institution
|
||||
);
|
||||
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
|
||||
|
||||
await bluebird.map(
|
||||
|
||||
@@ -53,7 +53,7 @@ export class PlaidUpdateTransactions {
|
||||
await this.fetchTransactionUpdates(tenantId, plaidItemId);
|
||||
|
||||
const request = { access_token: accessToken };
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
const {
|
||||
data: { accounts, item },
|
||||
} = await plaidInstance.accountsGet(request);
|
||||
@@ -66,7 +66,13 @@ export class PlaidUpdateTransactions {
|
||||
country_codes: ['US', 'UK'],
|
||||
});
|
||||
// Sync bank accounts.
|
||||
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx);
|
||||
await this.plaidSync.syncBankAccounts(
|
||||
tenantId,
|
||||
accounts,
|
||||
institution,
|
||||
item,
|
||||
trx
|
||||
);
|
||||
// Sync bank account transactions.
|
||||
await this.plaidSync.syncAccountsTransactions(
|
||||
tenantId,
|
||||
@@ -141,7 +147,7 @@ export class PlaidUpdateTransactions {
|
||||
cursor: cursor,
|
||||
count: batchSize,
|
||||
};
|
||||
const plaidInstance = new PlaidClientWrapper();
|
||||
const plaidInstance = PlaidClientWrapper.getClient();
|
||||
const response = await plaidInstance.transactionsSync(request);
|
||||
const data = response.data;
|
||||
// Add this page of results
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import * as R from 'ramda';
|
||||
import {
|
||||
Item as PlaidItem,
|
||||
Institution as PlaidInstitution,
|
||||
AccountBase as PlaidAccount,
|
||||
} from 'plaid';
|
||||
import {
|
||||
CreateUncategorizedTransactionDTO,
|
||||
IAccountCreateDTO,
|
||||
PlaidAccount,
|
||||
PlaidTransaction,
|
||||
} from '@/interfaces';
|
||||
|
||||
/**
|
||||
* Transformes the Plaid account to create cashflow account DTO.
|
||||
* @param {PlaidAccount} plaidAccount
|
||||
* @param {PlaidItem} item -
|
||||
* @param {PlaidInstitution} institution -
|
||||
* @param {PlaidAccount} plaidAccount -
|
||||
* @returns {IAccountCreateDTO}
|
||||
*/
|
||||
export const transformPlaidAccountToCreateAccount = R.curry(
|
||||
(institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => {
|
||||
(
|
||||
item: PlaidItem,
|
||||
institution: PlaidInstitution,
|
||||
plaidAccount: PlaidAccount
|
||||
): IAccountCreateDTO => {
|
||||
return {
|
||||
name: `${institution.name} - ${plaidAccount.name}`,
|
||||
code: '',
|
||||
@@ -20,9 +30,10 @@ export const transformPlaidAccountToCreateAccount = R.curry(
|
||||
currencyCode: plaidAccount.balances.iso_currency_code,
|
||||
accountType: 'cash',
|
||||
active: true,
|
||||
plaidAccountId: plaidAccount.account_id,
|
||||
bankBalance: plaidAccount.balances.current,
|
||||
accountMask: plaidAccount.mask,
|
||||
plaidAccountId: plaidAccount.account_id,
|
||||
plaidItemId: item.item_id,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DashboardRowsHeightButton,
|
||||
FormattedMessage as T,
|
||||
AppToaster,
|
||||
If,
|
||||
} from '@/components';
|
||||
|
||||
import { CashFlowMenuItems } from './utils';
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
useDisconnectBankAccount,
|
||||
useUpdateBankAccount,
|
||||
} from '@/hooks/query/bank-rules';
|
||||
import { current } from '@reduxjs/toolkit';
|
||||
|
||||
function AccountTransactionsActionsBar({
|
||||
// #withDialogActions
|
||||
@@ -66,6 +68,7 @@ function AccountTransactionsActionsBar({
|
||||
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
|
||||
|
||||
const isFeedsActive = !!currentAccount.is_feeds_active;
|
||||
const isSyncingOwner = currentAccount.is_syncing_owner;
|
||||
|
||||
// Handle table row size change.
|
||||
const handleTableRowSizeChange = (size) => {
|
||||
@@ -176,21 +179,23 @@ function AccountTransactionsActionsBar({
|
||||
/>
|
||||
<NavbarDivider />
|
||||
|
||||
<Tooltip
|
||||
content={
|
||||
isFeedsActive
|
||||
? 'The bank syncing is active'
|
||||
: 'The bank syncing is disconnected'
|
||||
}
|
||||
minimal={true}
|
||||
position={Position.BOTTOM}
|
||||
>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon="feed" iconSize={16} />}
|
||||
intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER}
|
||||
/>
|
||||
</Tooltip>
|
||||
<If condition={isSyncingOwner}>
|
||||
<Tooltip
|
||||
content={
|
||||
isFeedsActive
|
||||
? 'The bank syncing is active'
|
||||
: 'The bank syncing is disconnected'
|
||||
}
|
||||
minimal={true}
|
||||
position={Position.BOTTOM}
|
||||
>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon="feed" iconSize={16} />}
|
||||
intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER}
|
||||
/>
|
||||
</Tooltip>
|
||||
</If>
|
||||
</NavbarGroup>
|
||||
|
||||
<NavbarGroup align={Alignment.RIGHT}>
|
||||
@@ -203,17 +208,15 @@ function AccountTransactionsActionsBar({
|
||||
}}
|
||||
content={
|
||||
<Menu>
|
||||
{isFeedsActive && (
|
||||
<>
|
||||
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
|
||||
<MenuDivider />
|
||||
</>
|
||||
)}
|
||||
<If condition={isSyncingOwner && isFeedsActive}>
|
||||
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
|
||||
<MenuDivider />
|
||||
</If>
|
||||
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
|
||||
|
||||
{isFeedsActive && (
|
||||
<If condition={isSyncingOwner && isFeedsActive}>
|
||||
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} />
|
||||
)}
|
||||
</If>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user