From fb8118bea8c934361a79a347ac9498452568f0b6 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 18 Aug 2024 14:20:23 +0200 Subject: [PATCH] fix: Delete bank account with uncategorized transactions --- packages/server/src/loaders/eventEmitter.ts | 2 + packages/server/src/models/Pagination.ts | 14 +++- .../src/services/Accounts/DeleteAccount.ts | 1 + ...ategorizedTransactionsOnAccountDeleting.ts | 68 +++++++++++++++++++ .../DisconnectPlaidItemOnAccountDeleted.ts | 19 ++++-- .../services/Banking/Rules/DeleteBankRule.ts | 4 +- .../services/Banking/Rules/DeleteBankRules.ts | 26 +++++++ .../AccountTransactionsActionsBar.tsx | 10 ++- 8 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting.ts create mode 100644 packages/server/src/services/Banking/Rules/DeleteBankRules.ts diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index f303b4527..5a5b02d03 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -115,6 +115,7 @@ import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/E import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize'; import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted'; import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber'; +import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting'; export default () => { return new EventPublisher(); @@ -277,6 +278,7 @@ export const susbcribers = () => { // Plaid RecognizeSyncedBankTranasctions, DisconnectPlaidItemOnAccountDeleted, + DeleteUncategorizedTransactionsOnAccountDeleting, // Loops LoopsEventsSubscriber diff --git a/packages/server/src/models/Pagination.ts b/packages/server/src/models/Pagination.ts index 7d1b89921..378839eb7 100644 --- a/packages/server/src/models/Pagination.ts +++ b/packages/server/src/models/Pagination.ts @@ -1,4 +1,5 @@ import { Model } from 'objection'; +import { castArray, omit, pick } from 'lodash'; import { isEmpty } from 'lodash'; import { ServiceError } from '@/exceptions'; @@ -16,7 +17,10 @@ export default class PaginationQueryBuilder extends Model.QueryBuilder { }); } - queryAndThrowIfHasRelations = ({ type, message }) => { + queryAndThrowIfHasRelations = ({ type, message, excludeRelations = [], includedRelations = [] }) => { + const _excludeRelations = castArray(excludeRelations); + const _includedRelations = castArray(includedRelations); + const model = this.modelClass(); const modelRelations = Object.keys(model.relationMappings).filter( (relation) => @@ -25,9 +29,13 @@ export default class PaginationQueryBuilder extends Model.QueryBuilder { ) !== -1 ); const relations = model.secureDeleteRelations || modelRelations; + const filteredRelations = !isEmpty(_includedRelations) ? + relations.filter(r => _includedRelations.includes(r)) : + !isEmpty(_excludeRelations) ? relations.filter(r => !excludeRelations.includes(r)) : relations; + this.runAfter((model, query) => { - const nonEmptyRelations = relations.filter( + const nonEmptyRelations = filteredRelations.filter( (relation) => !isEmpty(model[relation]) ); if (nonEmptyRelations.length > 0) { @@ -36,7 +44,7 @@ export default class PaginationQueryBuilder extends Model.QueryBuilder { return model; }); return this.onBuild((query) => { - relations.forEach((relation) => { + filteredRelations.forEach((relation) => { query.withGraphFetched(`${relation}(selectId)`).modifiers({ selectId(builder) { builder.select('id'); diff --git a/packages/server/src/services/Accounts/DeleteAccount.ts b/packages/server/src/services/Accounts/DeleteAccount.ts index d8d499c58..632c78f62 100644 --- a/packages/server/src/services/Accounts/DeleteAccount.ts +++ b/packages/server/src/services/Accounts/DeleteAccount.ts @@ -73,6 +73,7 @@ export class DeleteAccount { .throwIfNotFound() .queryAndThrowIfHasRelations({ type: ERRORS.ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS, + excludeRelations: ['uncategorizedTransactions', 'plaidItem'] }); // Authorize before delete account. await this.authorize(tenantId, accountId, oldAccount); diff --git a/packages/server/src/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting.ts b/packages/server/src/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting.ts new file mode 100644 index 000000000..2acefa3c4 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting.ts @@ -0,0 +1,68 @@ +import { Inject, Service } from 'typedi'; +import { initialize } from 'objection'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import events from '@/subscribers/events'; +import { IAccountEventDeletePayload } from '@/interfaces'; +import { DeleteBankRulesService } from '../../Rules/DeleteBankRules'; +import { RevertRecognizedTransactions } from '../../RegonizeTranasctions/RevertRecognizedTransactions'; + +@Service() +export class DeleteUncategorizedTransactionsOnAccountDeleting { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private deleteBankRules: DeleteBankRulesService; + + @Inject() + private revertRecognizedTransactins: RevertRecognizedTransactions; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.accounts.onDelete, + this.handleDeleteBankRulesOnAccountDeleting.bind(this), + ) + bus.subscribe( + events.accounts.onDelete, + this.handleDeleteUncategorizedTransactions.bind(this) + ); + } + + /** + * Handles delete the uncategorized transactions. + * @param {IAccountEventDeletePayload} payload - + */ + private async handleDeleteUncategorizedTransactions({ tenantId, oldAccount, trx }: IAccountEventDeletePayload) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + await UncategorizedCashflowTransaction.query(trx) + .where('accountId', oldAccount.id) + .delete(); + } + + /** + * Handles revert the recognized transactions and delete all the bank rules + * associated to the deleted bank account. + * @param {IAccountEventDeletePayload} + */ + private async handleDeleteBankRulesOnAccountDeleting({ tenantId, oldAccount, trx }: IAccountEventDeletePayload) { + const knex = this.tenancy.knex(tenantId); + const { BankRule, UncategorizedCashflowTransaction, MatchedBankTransaction, RecognizedBankTransaction } = this.tenancy.models(tenantId); + + const foundAssociatedRules = await BankRule.query(trx).where('applyIfAccountId', oldAccount.id); + const foundAssociatedRulesIds = foundAssociatedRules.map(rule => rule.id); + + await initialize(knex, [ + UncategorizedCashflowTransaction, + RecognizedBankTransaction, + MatchedBankTransaction, + ]); + + await this.revertRecognizedTransactins.revertRecognizedTransactions(tenantId, foundAssociatedRulesIds, null, trx) + + await this.deleteBankRules.deleteBankRules(tenantId, foundAssociatedRulesIds); + } +} \ No newline at end of file diff --git a/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts index 89f02da57..e0db91294 100644 --- a/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts +++ b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts @@ -3,6 +3,7 @@ import { IAccountEventDeletedPayload } from '@/interfaces'; import { PlaidClientWrapper } from '@/lib/Plaid'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import events from '@/subscribers/events'; +import { runAfterTransaction } from '@/services/UnitOfWork/TransactionsHooks'; @Service() export class DisconnectPlaidItemOnAccountDeleted { @@ -51,13 +52,17 @@ export class DisconnectPlaidItemOnAccountDeleted { .findOne('plaidItemId', oldAccount.plaidItemId) .delete(); - if (oldPlaidItem) { - const plaidInstance = PlaidClientWrapper.getClient(); + // Remove Plaid item once the transaction resolve. + runAfterTransaction(trx, async () => { + if (oldPlaidItem) { + const plaidInstance = PlaidClientWrapper.getClient(); + + // Remove the Plaid item. + await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + } + }) - // Remove the Plaid item. - await plaidInstance.itemRemove({ - access_token: oldPlaidItem.plaidAccessToken, - }); - } } } diff --git a/packages/server/src/services/Banking/Rules/DeleteBankRule.ts b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts index c02ab6686..b93385d4a 100644 --- a/packages/server/src/services/Banking/Rules/DeleteBankRule.ts +++ b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts @@ -26,7 +26,7 @@ export class DeleteBankRuleSerivce { * @param {number} ruleId * @returns {Promise} */ - public async deleteBankRule(tenantId: number, ruleId: number): Promise { + public async deleteBankRule(tenantId: number, ruleId: number, trx?: Knex.Transaction): Promise { const { BankRule, BankRuleCondition } = this.tenancy.models(tenantId); const oldBankRule = await BankRule.query() @@ -51,6 +51,6 @@ export class DeleteBankRuleSerivce { ruleId, trx, } as IBankRuleEventDeletedPayload); - }); + }, trx); } } diff --git a/packages/server/src/services/Banking/Rules/DeleteBankRules.ts b/packages/server/src/services/Banking/Rules/DeleteBankRules.ts new file mode 100644 index 000000000..f31c9316c --- /dev/null +++ b/packages/server/src/services/Banking/Rules/DeleteBankRules.ts @@ -0,0 +1,26 @@ +import { Knex } from 'knex'; +import { Inject, Service } from "typedi"; +import PromisePool from "@supercharge/promise-pool"; +import { castArray, uniq } from "lodash"; +import { DeleteBankRuleSerivce } from "./DeleteBankRule"; + +@Service() +export class DeleteBankRulesService { + @Inject() + private deleteBankRuleService: DeleteBankRuleSerivce; + + /** + * Delete bank rules. + * @param {number} tenantId + * @param {number | Array} bankRuleId + */ + async deleteBankRules(tenantId: number, bankRuleId: number | Array, trx?: Knex.Transaction) { + const bankRulesIds = uniq(castArray(bankRuleId)); + + await PromisePool.withConcurrency(1) + .for(bankRulesIds) + .process(async (bankRuleId: number) => { + await this.deleteBankRuleService.deleteBankRule(tenantId, bankRuleId, trx); + }); + } +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index 5048d2b54..84544f332 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -215,6 +215,12 @@ function AccountTransactionsActionsBar({ uncategorizeTransactionsIds: categorizedTransactionsSelected, }); }; + // Handles the delete account button click. + const handleDeleteAccountClick = () => { + openAlert('account-delete', { + accountId + }) + } return ( @@ -364,9 +370,11 @@ function AccountTransactionsActionsBar({ + - + + } >