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;