From fa7e6b1fcab7990427a30db8b6eb0e55d01a6300 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 15 Jul 2024 23:18:39 +0200 Subject: [PATCH 1/7] feat: disconnect bank account --- .../Banking/BankAccountsController.ts | 35 +++++++++ .../BankAccounts/BankAccountsApplication.tsx | 21 ++++++ .../BankAccounts/DisconnectBankAccount.tsx | 72 +++++++++++++++++++ packages/server/src/subscribers/events.ts | 5 ++ .../AccountTransactionsActionsBar.tsx | 29 ++++++++ packages/webapp/src/hooks/query/bank-rules.ts | 21 +++++- 6 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx create mode 100644 packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx diff --git a/packages/server/src/api/controllers/Banking/BankAccountsController.ts b/packages/server/src/api/controllers/Banking/BankAccountsController.ts index 4b062768f..c876ba604 100644 --- a/packages/server/src/api/controllers/Banking/BankAccountsController.ts +++ b/packages/server/src/api/controllers/Banking/BankAccountsController.ts @@ -3,12 +3,16 @@ import { NextFunction, Request, Response, Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary'; +import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication'; @Service() export class BankAccountsController extends BaseController { @Inject() private getBankAccountSummaryService: GetBankAccountSummary; + @Inject() + private bankAccountsApp: BankAccountsApplication; + /** * Router constructor. */ @@ -16,6 +20,10 @@ export class BankAccountsController extends BaseController { const router = Router(); router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); + router.post( + '/:bankAccountId/disconnect', + this.discountBankAccount.bind(this) + ); return router; } @@ -46,4 +54,31 @@ export class BankAccountsController extends BaseController { next(error); } } + + /** + * Disonnect the given bank account. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + async disconnectBankAccount( + req: Request<{ bankAccountId: number }>, + res: Response, + next: NextFunction + ) { + const { bankAccountId } = req.params; + const { tenantId } = req; + + try { + await this.bankAccountsApp.disconnectBankAccount(tenantId, bankAccountId); + + return res.status(200).send({ + id: bankAccountId, + message: 'The bank account has been disconnected.', + }); + } catch (error) { + next(error); + } + } } diff --git a/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx new file mode 100644 index 000000000..c375030ef --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx @@ -0,0 +1,21 @@ +import { Inject, Service } from 'typedi'; +import { DisconnectBankAccount } from './DisconnectBankAccount'; + +@Service() +export class BankAccountsApplication { + @Inject() + private disconnectBankAccountService: DisconnectBankAccount; + + /** + * Disconnects the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + async disconnectBankAccount(tenantId: number, bankAccountId: number) { + return this.disconnectBankAccountService.disconnectBankAccount( + tenantId, + bankAccountId + ); + } +} diff --git a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx new file mode 100644 index 000000000..4f9e6106e --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -0,0 +1,72 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; + +@Service() +export class DisconnectBankAccount { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + /** + * Disconnects the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + async disconnectBankAccount(tenantId: number, bankAccountId: number) { + const { Account, PlaidItem } = this.tenancy.models(tenantId); + + const account = await Account.query() + .findById(bankAccountId) + .where('type', ['bank']) + .throwIfNotFound(); + + const plaidItem = await PlaidItem.query().findById(account.plaidAccountId); + + if (!plaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + const request = { + accessToken: plaidItem.plaidAccessToken, + }; + const plaidInstance = new PlaidClientWrapper(); + + // + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, { + tenantId, + bankAccountId, + }); + // Remove the Plaid item. + const data = await plaidInstance.itemRemove(request); + + // Remove the Plaid item from the system. + await PlaidItem.query().findById(account.plaidAccountId).delete(); + + // Remove the plaid item association to the bank account. + await Account.query().findById(bankAccountId).patch({ + plaidAccountId: null, + isFeedsActive: false, + }); + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, { + tenantId, + bankAccountId, + }); + }); + } +} + +const ERRORS = { + BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED', +}; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 711dbce35..b64e398c3 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -651,6 +651,11 @@ export default { onUnexcluded: 'onBankTransactionUnexcluded', }, + bankAccount: { + onDisconnecting: 'onBankAccountDisconnecting', + onDisconnected: 'onBankAccountDisconnected', + }, + // Import files. import: { onImportCommitted: 'onImportFileCommitted', diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index b5db2eb78..850b3fa37 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -11,6 +11,7 @@ import { MenuItem, PopoverInteractionKind, Position, + Intent, } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; import { @@ -18,6 +19,7 @@ import { DashboardActionsBar, DashboardRowsHeightButton, FormattedMessage as T, + AppToaster, } from '@/components'; import { CashFlowMenuItems } from './utils'; @@ -33,6 +35,7 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; +import { useDisconnectBankAccount } from '@/hooks/query/bank-rules'; function AccountTransactionsActionsBar({ // #withDialogActions @@ -50,6 +53,8 @@ function AccountTransactionsActionsBar({ // Refresh cashflow infinity transactions hook. const { refresh } = useRefreshCashflowTransactionsInfinity(); + const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount(); + // Retrieves the money in/out buttons options. const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); @@ -82,6 +87,26 @@ function AccountTransactionsActionsBar({ const handleBankRulesClick = () => { history.push(`/bank-rules?accountId=${accountId}`); }; + + const isConnected = true; + + // Handles the bank account disconnect click. + const handleDisconnectClick = () => { + disconnectBankAccount(accountId) + .then(() => { + AppToaster.show({ + message: 'The bank account has been disconnected.', + intent: Intent.SUCCESS, + }); + }) + .catch((error) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + // Handle the refresh button click. const handleRefreshBtnClick = () => { refresh(); @@ -142,6 +167,10 @@ function AccountTransactionsActionsBar({ content={ + + {isConnected && ( + + )} } > diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index 77a770ff4..a49e4a5b0 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { QueryClient, UseMutationOptions, @@ -61,6 +60,26 @@ export function useCreateBankRule( ); } +interface DisconnectBankAccountRes {} + +export function useDisconnectBankAccount( + options?: UseMutationOptions, +) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (bankAccountId: number) => + apiRequest + .post(`/banking/bank_accounts/${bankAccountId}`) + .then((res) => res.data), + { + ...options, + onSuccess: () => {}, + }, + ); +} + interface EditBankRuleValues { id: number; value: any; From c2815afbe39b15670fde1d51609828737fec200f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 16 Jul 2024 17:09:00 +0200 Subject: [PATCH 2/7] feat: disconnect and update bank account --- .../Banking/BankAccountsController.ts | 30 ++++++++- ...732_add_plaid_item_id_to_accounts_table.js | 11 ++++ packages/server/src/lib/Plaid/Plaid.ts | 1 + packages/server/src/models/Account.ts | 13 ++++ .../BankAccounts/BankAccountsApplication.tsx | 17 +++++ .../BankAccounts/DisconnectBankAccount.tsx | 34 +++++----- .../BankAccounts/RefreshBankAccount.tsx | 39 ++++++++++++ .../services/Banking/BankAccounts/types.ts | 17 +++++ .../AccountTransactionsActionsBar.tsx | 43 ++++++++++++- packages/webapp/src/hooks/query/bank-rules.ts | 63 ++++++++++++++++--- packages/webapp/src/static/json/icons.tsx | 7 +++ 11 files changed, 246 insertions(+), 29 deletions(-) create mode 100644 packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js create mode 100644 packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx create mode 100644 packages/server/src/services/Banking/BankAccounts/types.ts diff --git a/packages/server/src/api/controllers/Banking/BankAccountsController.ts b/packages/server/src/api/controllers/Banking/BankAccountsController.ts index c876ba604..f337c0b38 100644 --- a/packages/server/src/api/controllers/Banking/BankAccountsController.ts +++ b/packages/server/src/api/controllers/Banking/BankAccountsController.ts @@ -22,8 +22,9 @@ export class BankAccountsController extends BaseController { router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); router.post( '/:bankAccountId/disconnect', - this.discountBankAccount.bind(this) + this.disconnectBankAccount.bind(this) ); + router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this)); return router; } @@ -81,4 +82,31 @@ export class BankAccountsController extends BaseController { next(error); } } + + /** + * Refresh the given bank account. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + async refreshBankAccount( + req: Request<{ bankAccountId: number }>, + res: Response, + next: NextFunction + ) { + const { bankAccountId } = req.params; + const { tenantId } = req; + + try { + await this.bankAccountsApp.refreshBankAccount(tenantId, bankAccountId); + + return res.status(200).send({ + id: bankAccountId, + message: 'The bank account has been disconnected.', + }); + } catch (error) { + next(error); + } + } } diff --git a/packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js b/packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js new file mode 100644 index 000000000..ce084dca8 --- /dev/null +++ b/packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('accounts', (table) => { + table.string('plaid_item_id').nullable(); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('accounts', (table) => { + table.dropColumn('plaid_item_id'); + }); +}; diff --git a/packages/server/src/lib/Plaid/Plaid.ts b/packages/server/src/lib/Plaid/Plaid.ts index 532d3cfe8..04cc71888 100644 --- a/packages/server/src/lib/Plaid/Plaid.ts +++ b/packages/server/src/lib/Plaid/Plaid.ts @@ -52,6 +52,7 @@ const noAccessTokenLogger = async ( // Plaid client methods used in this app, mapped to their appropriate logging functions. const clientMethodLoggingFns = { + transactionsRefresh: defaultLogger, accountsGet: defaultLogger, institutionsGet: noAccessTokenLogger, institutionsGetById: noAccessTokenLogger, diff --git a/packages/server/src/models/Account.ts b/packages/server/src/models/Account.ts index 7e0d8d6e4..79100d6d6 100644 --- a/packages/server/src/models/Account.ts +++ b/packages/server/src/models/Account.ts @@ -197,6 +197,7 @@ export default class Account extends mixin(TenantModel, [ const ExpenseEntry = require('models/ExpenseCategory'); const ItemEntry = require('models/ItemEntry'); const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction'); + const PlaidItem = require('models/PlaidItem'); return { /** @@ -321,6 +322,18 @@ export default class Account extends mixin(TenantModel, [ query.where('categorized', false); }, }, + + /** + * Account model may belongs to a Plaid item. + */ + plaidItem: { + relation: Model.BelongsToOneRelation, + modelClass: PlaidItem.default, + join: { + from: 'accounts.plaidItemId', + to: 'plaid_items.id', + }, + }, }; } diff --git a/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx index c375030ef..51c12106e 100644 --- a/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx +++ b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx @@ -1,11 +1,15 @@ import { Inject, Service } from 'typedi'; import { DisconnectBankAccount } from './DisconnectBankAccount'; +import { RefreshBankAccountService } from './RefreshBankAccount'; @Service() export class BankAccountsApplication { @Inject() private disconnectBankAccountService: DisconnectBankAccount; + @Inject() + private refreshBankAccountService: RefreshBankAccountService; + /** * Disconnects the given bank account. * @param {number} tenantId @@ -18,4 +22,17 @@ export class BankAccountsApplication { bankAccountId ); } + + /** + * Refresh the bank transactions of the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + async refreshBankAccount(tenantId: number, bankAccountId: number) { + return this.refreshBankAccountService.refreshBankAccount( + tenantId, + bankAccountId + ); + } } diff --git a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx index 4f9e6106e..d562b31fc 100644 --- a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -6,6 +6,7 @@ import { PlaidClientWrapper } from '@/lib/Plaid'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import UnitOfWork from '@/services/UnitOfWork'; import events from '@/subscribers/events'; +import { ERRORS } from './types'; @Service() export class DisconnectBankAccount { @@ -20,45 +21,46 @@ export class DisconnectBankAccount { /** * Disconnects the given bank account. - * @param {number} tenantId - * @param {number} bankAccountId + * @param {number} tenantId + * @param {number} bankAccountId * @returns {Promise} */ async disconnectBankAccount(tenantId: number, bankAccountId: number) { const { Account, PlaidItem } = this.tenancy.models(tenantId); + // Retrieve the bank account or throw not found error. const account = await Account.query() .findById(bankAccountId) - .where('type', ['bank']) + .whereIn('account_type', ['bank', 'cash']) .throwIfNotFound(); - const plaidItem = await PlaidItem.query().findById(account.plaidAccountId); + const oldPlaidItem = await PlaidItem.query().findById(account.plaidItemId); - if (!plaidItem) { + if (!oldPlaidItem) { throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); } - const request = { - accessToken: plaidItem.plaidAccessToken, - }; const plaidInstance = new PlaidClientWrapper(); - // return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onBankAccountDisconnecting` event. await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, { tenantId, bankAccountId, }); - // Remove the Plaid item. - const data = await plaidInstance.itemRemove(request); - // Remove the Plaid item from the system. - await PlaidItem.query().findById(account.plaidAccountId).delete(); + await PlaidItem.query(trx).findById(account.plaidItemId).delete(); // Remove the plaid item association to the bank account. - await Account.query().findById(bankAccountId).patch({ + await Account.query(trx).findById(bankAccountId).patch({ plaidAccountId: null, + plaidItemId: null, isFeedsActive: false, }); + // Remove the Plaid item. + const data = await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + // Triggers `onBankAccountDisconnected` event. await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, { tenantId, bankAccountId, @@ -66,7 +68,3 @@ export class DisconnectBankAccount { }); } } - -const ERRORS = { - BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED', -}; diff --git a/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx new file mode 100644 index 000000000..814d00c75 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx @@ -0,0 +1,39 @@ +import { ServiceError } from '@/exceptions'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import { Inject } from 'typedi'; +export class RefreshBankAccountService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + /** + * + * @param {number} tenantId + * @param {number} bankAccountId + */ + public async refreshBankAccount(tenantId: number, bankAccountId: number) { + const { Account } = this.tenancy.models(tenantId); + + const bankAccount = await Account.query() + .findById(bankAccountId) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + if (!bankAccount.plaidItem) { + throw new ServiceError(''); + } + const plaidInstance = new PlaidClientWrapper(); + + const data = await plaidInstance.transactionsRefresh({ + access_token: bankAccount.plaidItem.plaidAccessToken, + }); + await Account.query().findById(bankAccountId).patch({ + isFeedsActive: true, + lastFeedsUpdatedAt: new Date(), + }); + } +} diff --git a/packages/server/src/services/Banking/BankAccounts/types.ts b/packages/server/src/services/Banking/BankAccounts/types.ts new file mode 100644 index 000000000..d3198cc5c --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/types.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export interface IBankAccountDisconnectingEventPayload { + tenantId: number; + bankAccountId: number; + trx: Knex.Transaction; +} + +export interface IBankAccountDisconnectedEventPayload { + tenantId: number; + bankAccountId: number; + trx: Knex.Transaction; +} + +export const ERRORS = { + BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED', +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index 850b3fa37..e9635524c 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -12,6 +12,8 @@ import { PopoverInteractionKind, Position, Intent, + Tooltip, + MenuDivider, } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; import { @@ -35,7 +37,10 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; -import { useDisconnectBankAccount } from '@/hooks/query/bank-rules'; +import { + useDisconnectBankAccount, + useUpdateBankAccount, +} from '@/hooks/query/bank-rules'; function AccountTransactionsActionsBar({ // #withDialogActions @@ -54,6 +59,7 @@ function AccountTransactionsActionsBar({ const { refresh } = useRefreshCashflowTransactionsInfinity(); const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount(); + const { mutateAsync: updateBankAccount } = useUpdateBankAccount(); // Retrieves the money in/out buttons options. const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); @@ -92,7 +98,7 @@ function AccountTransactionsActionsBar({ // Handles the bank account disconnect click. const handleDisconnectClick = () => { - disconnectBankAccount(accountId) + disconnectBankAccount({ bankAccountId: accountId }) .then(() => { AppToaster.show({ message: 'The bank account has been disconnected.', @@ -106,7 +112,22 @@ function AccountTransactionsActionsBar({ }); }); }; - + // handles the bank update button click. + const handleBankUpdateClick = () => { + updateBankAccount({ bankAccountId: accountId }) + .then(() => { + AppToaster.show({ + message: 'The transactions of the bank account has been updated.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; // Handle the refresh button click. const handleRefreshBtnClick = () => { refresh(); @@ -154,6 +175,18 @@ function AccountTransactionsActionsBar({ onChange={handleTableRowSizeChange} /> + + +