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} /> + + +