diff --git a/packages/server/src/api/controllers/Banking/BankAccountsController.ts b/packages/server/src/api/controllers/Banking/BankAccountsController.ts index 4b062768f..f337c0b38 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,11 @@ export class BankAccountsController extends BaseController { const router = Router(); router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); + router.post( + '/:bankAccountId/disconnect', + this.disconnectBankAccount.bind(this) + ); + router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this)); return router; } @@ -46,4 +55,58 @@ 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); + } + } + + /** + * 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/database/migrations/20240729172403_add_is_syncing_owner_to_accounts_table.js b/packages/server/src/database/migrations/20240729172403_add_is_syncing_owner_to_accounts_table.js new file mode 100644 index 000000000..f65eb3ca0 --- /dev/null +++ b/packages/server/src/database/migrations/20240729172403_add_is_syncing_owner_to_accounts_table.js @@ -0,0 +1,19 @@ +exports.up = function (knex) { + return knex.schema + .table('accounts', (table) => { + table + .boolean('is_syncing_owner') + .defaultTo(false) + .after('is_feeds_active'); + }) + .then(() => { + return knex('accounts') + .whereNotNull('plaid_item_id') + .orWhereNotNull('plaid_account_id') + .update('is_syncing_owner', true); + }); +}; + +exports.down = function (knex) { + table.dropColumn('is_syncing_owner'); +}; diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index b1a880b80..03c002c7b 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -15,6 +15,7 @@ export interface IAccountDTO { export interface IAccountCreateDTO extends IAccountDTO { currencyCode?: string; plaidAccountId?: string; + plaidItemId?: string; } export interface IAccountEditDTO extends IAccountDTO {} @@ -37,6 +38,8 @@ export interface IAccount { accountNormal: string; accountParentType: string; bankBalance: string; + plaidItemId: number | null + lastFeedsUpdatedAt: Date; } export enum AccountNormal { diff --git a/packages/server/src/lib/Plaid/Plaid.ts b/packages/server/src/lib/Plaid/Plaid.ts index 532d3cfe8..05875a27c 100644 --- a/packages/server/src/lib/Plaid/Plaid.ts +++ b/packages/server/src/lib/Plaid/Plaid.ts @@ -1,69 +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 = { - 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], @@ -75,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; } } diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 9e2ae276e..f303b4527 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -113,6 +113,7 @@ import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/ import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch'; import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude'; import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize'; +import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted'; import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber'; export default () => { @@ -275,6 +276,7 @@ export const susbcribers = () => { // Plaid RecognizeSyncedBankTranasctions, + DisconnectPlaidItemOnAccountDeleted, // Loops LoopsEventsSubscriber diff --git a/packages/server/src/models/Account.ts b/packages/server/src/models/Account.ts index 7e0d8d6e4..d9972251a 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.plaidItemId', + }, + }, }; } diff --git a/packages/server/src/services/Accounts/AccountTransform.ts b/packages/server/src/services/Accounts/AccountTransform.ts index cb58a9be9..28f3b74a5 100644 --- a/packages/server/src/services/Accounts/AccountTransform.ts +++ b/packages/server/src/services/Accounts/AccountTransform.ts @@ -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[]} diff --git a/packages/server/src/services/Accounts/CreateAccount.ts b/packages/server/src/services/Accounts/CreateAccount.ts index da80d3af4..27ecbf580 100644 --- a/packages/server/src/services/Accounts/CreateAccount.ts +++ b/packages/server/src/services/Accounts/CreateAccount.ts @@ -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 { ); }; } - 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..51c12106e --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx @@ -0,0 +1,38 @@ +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 + * @param {number} bankAccountId + * @returns {Promise} + */ + async disconnectBankAccount(tenantId: number, bankAccountId: number) { + return this.disconnectBankAccountService.disconnectBankAccount( + tenantId, + 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 new file mode 100644 index 000000000..fe43ef1e2 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -0,0 +1,78 @@ +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'; +import { + ERRORS, + IBankAccountDisconnectedEventPayload, + IBankAccountDisconnectingEventPayload, +} from './types'; +import { ACCOUNT_TYPE } from '@/data/AccountTypes'; + +@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} + */ + public 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) + .whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK]) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + const oldPlaidItem = account.plaidItem; + + if (!oldPlaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + const plaidInstance = PlaidClientWrapper.getClient(); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onBankAccountDisconnecting` event. + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, { + tenantId, + bankAccountId, + } as IBankAccountDisconnectingEventPayload); + + // Remove the Plaid item from the system. + await PlaidItem.query(trx).findById(account.plaidItemId).delete(); + + // Remove the plaid item association to the bank account. + await Account.query(trx).findById(bankAccountId).patch({ + plaidAccountId: null, + plaidItemId: null, + isFeedsActive: false, + }); + // Remove the Plaid item. + await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + // Triggers `onBankAccountDisconnected` event. + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, { + tenantId, + bankAccountId, + trx, + } as IBankAccountDisconnectedEventPayload); + }); + } +} 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..8efa5845d --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { ERRORS } from './types'; + +@Service() +export class RefreshBankAccountService { + @Inject() + private tenancy: HasTenancyService; + + /** + * Asks Plaid to trigger syncing the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + public async refreshBankAccount(tenantId: number, bankAccountId: number) { + const { Account } = this.tenancy.models(tenantId); + + const bankAccount = await Account.query() + .findById(bankAccountId) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + // Can't continue if the given account is not linked with Plaid item. + if (!bankAccount.plaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + const plaidInstance = PlaidClientWrapper.getClient(); + + await plaidInstance.transactionsRefresh({ + access_token: bankAccount.plaidItem.plaidAccessToken, + }); + } +} diff --git a/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts new file mode 100644 index 000000000..89f02da57 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts @@ -0,0 +1,63 @@ +import { Inject, Service } from 'typedi'; +import { IAccountEventDeletedPayload } from '@/interfaces'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import events from '@/subscribers/events'; + +@Service() +export class DisconnectPlaidItemOnAccountDeleted { + @Inject() + private tenancy: HasTenancyService; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.accounts.onDeleted, + this.handleDisconnectPlaidItemOnAccountDelete.bind(this) + ); + } + + /** + * Deletes Plaid item from the system and Plaid once the account deleted. + * @param {IAccountEventDeletedPayload} payload + * @returns {Promise} + */ + private async handleDisconnectPlaidItemOnAccountDelete({ + tenantId, + oldAccount, + trx, + }: IAccountEventDeletedPayload) { + const { PlaidItem, Account } = this.tenancy.models(tenantId); + + // Can't continue if the deleted account is not linked to Plaid item. + if (!oldAccount.plaidItemId) return; + + // Retrieves the Plaid item that associated to the deleted account. + 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) + .findOne('plaidItemId', oldAccount.plaidItemId) + .delete(); + + if (oldPlaidItem) { + const plaidInstance = PlaidClientWrapper.getClient(); + + // Remove the Plaid item. + await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + } + } +} 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/server/src/services/Banking/Plaid/PlaidItem.ts b/packages/server/src/services/Banking/Plaid/PlaidItem.ts index 9e83202f9..138d523c6 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidItem.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidItem.ts @@ -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({ diff --git a/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts index 89203df72..003181505 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts @@ -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; diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index 40f30af17..aed2fc945 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -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 { - const transformToPlaidAccounts = - transformPlaidAccountToCreateAccount(institution); - + const transformToPlaidAccounts = transformPlaidAccountToCreateAccount( + item, + institution + ); const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts); await bluebird.map( diff --git a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts index 0b8bb232e..3265cc2ba 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts @@ -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 diff --git a/packages/server/src/services/Banking/Plaid/utils.ts b/packages/server/src/services/Banking/Plaid/utils.ts index 243f9449b..395b4346f 100644 --- a/packages/server/src/services/Banking/Plaid/utils.ts +++ b/packages/server/src/services/Banking/Plaid/utils.ts @@ -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, }; } ); diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index e90aeb309..ae638341f 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -658,6 +658,11 @@ export default { onUnexcluded: 'onBankTransactionUnexcluded', }, + bankAccount: { + onDisconnecting: 'onBankAccountDisconnecting', + onDisconnected: 'onBankAccountDisconnected', + }, + // Import files. import: { onImportCommitted: 'onImportFileCommitted', diff --git a/packages/webapp/src/components/AppToaster/index.tsx b/packages/webapp/src/components/AppToaster/index.tsx index 57dc43d2f..85cdb9b6f 100644 --- a/packages/webapp/src/components/AppToaster/index.tsx +++ b/packages/webapp/src/components/AppToaster/index.tsx @@ -2,6 +2,6 @@ import { Position, Toaster, Intent } from '@blueprintjs/core'; export const AppToaster = Toaster.create({ - position: Position.RIGHT_BOTTOM, + position: Position.TOP, intent: Intent.WARNING, }); diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index 53ef5a786..d6ff1b075 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -12,14 +12,18 @@ import { PopoverInteractionKind, Position, Intent, + Tooltip, + MenuDivider, } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; +import { isEmpty } from 'lodash'; import { Icon, DashboardActionsBar, DashboardRowsHeightButton, FormattedMessage as T, AppToaster, + If, } from '@/components'; import { CashFlowMenuItems } from './utils'; @@ -35,12 +39,13 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; -import { withBanking } from '../withBanking'; -import { isEmpty } from 'lodash'; import { + useDisconnectBankAccount, + useUpdateBankAccount, useExcludeUncategorizedTransactions, useUnexcludeUncategorizedTransactions, } from '@/hooks/query/bank-rules'; +import { withBanking } from '../withBanking'; function AccountTransactionsActionsBar({ // #withDialogActions @@ -57,15 +62,21 @@ function AccountTransactionsActionsBar({ excludedTransactionsIdsSelected, }) { const history = useHistory(); - const { accountId } = useAccountTransactionsContext(); + const { accountId, currentAccount } = useAccountTransactionsContext(); // Refresh cashflow infinity transactions hook. const { refresh } = useRefreshCashflowTransactionsInfinity(); + const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount(); + const { mutateAsync: updateBankAccount } = useUpdateBankAccount(); + // Retrieves the money in/out buttons options. const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); + const isFeedsActive = !!currentAccount.is_feeds_active; + const isSyncingOwner = currentAccount.is_syncing_owner; + // Handle table row size change. const handleTableRowSizeChange = (size) => { addSetting('cashflowTransactions', 'tableSize', size); @@ -94,6 +105,39 @@ function AccountTransactionsActionsBar({ const handleBankRulesClick = () => { history.push(`/bank-rules?accountId=${accountId}`); }; + + // Handles the bank account disconnect click. + const handleDisconnectClick = () => { + disconnectBankAccount({ bankAccountId: 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, + }); + }); + }; + // 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(); @@ -190,6 +234,24 @@ function AccountTransactionsActionsBar({ /> + + +