Merge pull request #614 from bigcapitalhq/delete-bank-account-with-uncategorized-transactions

fix: Delete bank account with uncategorized transactions
This commit is contained in:
Ahmed Bouhuolia
2024-08-18 19:55:15 +02:00
committed by GitHub
12 changed files with 193 additions and 35 deletions

View File

@@ -115,6 +115,7 @@ import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/E
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize'; import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted'; import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted';
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber'; import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
export default () => { export default () => {
return new EventPublisher(); return new EventPublisher();
@@ -277,6 +278,7 @@ export const susbcribers = () => {
// Plaid // Plaid
RecognizeSyncedBankTranasctions, RecognizeSyncedBankTranasctions,
DisconnectPlaidItemOnAccountDeleted, DisconnectPlaidItemOnAccountDeleted,
DeleteUncategorizedTransactionsOnAccountDeleting,
// Loops // Loops
LoopsEventsSubscriber LoopsEventsSubscriber

View File

@@ -1,4 +1,5 @@
import { Model } from 'objection'; import { Model } from 'objection';
import { castArray, omit, pick } from 'lodash';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
@@ -16,7 +17,15 @@ 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 model = this.modelClass();
const modelRelations = Object.keys(model.relationMappings).filter( const modelRelations = Object.keys(model.relationMappings).filter(
(relation) => (relation) =>
@@ -25,9 +34,20 @@ export default class PaginationQueryBuilder extends Model.QueryBuilder {
) !== -1 ) !== -1
); );
const relations = model.secureDeleteRelations || modelRelations; const relations = model.secureDeleteRelations || modelRelations;
const filteredByIncluded = relations.filter((r) =>
_includedRelations.includes(r)
);
const filteredByExcluded = relations.filter(
(r) => !excludeRelations.includes(r)
);
const filteredRelations = !isEmpty(_includedRelations)
? filteredByIncluded
: !isEmpty(_excludeRelations)
? filteredByExcluded
: relations;
this.runAfter((model, query) => { this.runAfter((model, query) => {
const nonEmptyRelations = relations.filter( const nonEmptyRelations = filteredRelations.filter(
(relation) => !isEmpty(model[relation]) (relation) => !isEmpty(model[relation])
); );
if (nonEmptyRelations.length > 0) { if (nonEmptyRelations.length > 0) {
@@ -36,7 +56,7 @@ export default class PaginationQueryBuilder extends Model.QueryBuilder {
return model; return model;
}); });
return this.onBuild((query) => { return this.onBuild((query) => {
relations.forEach((relation) => { filteredRelations.forEach((relation) => {
query.withGraphFetched(`${relation}(selectId)`).modifiers({ query.withGraphFetched(`${relation}(selectId)`).modifiers({
selectId(builder) { selectId(builder) {
builder.select('id'); builder.select('id');

View File

@@ -1,6 +1,5 @@
/* eslint-disable global-require */ /* eslint-disable global-require */
import * as R from 'ramda'; import { Model, mixin } from 'objection';
import { Model, ModelOptions, QueryContext, mixin } from 'objection';
import TenantModel from 'models/TenantModel'; import TenantModel from 'models/TenantModel';
import ModelSettings from './ModelSetting'; import ModelSettings from './ModelSetting';
import Account from './Account'; import Account from './Account';

View File

@@ -73,6 +73,7 @@ export class DeleteAccount {
.throwIfNotFound() .throwIfNotFound()
.queryAndThrowIfHasRelations({ .queryAndThrowIfHasRelations({
type: ERRORS.ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS, type: ERRORS.ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS,
excludeRelations: ['uncategorizedTransactions', 'plaidItem']
}); });
// Authorize before delete account. // Authorize before delete account.
await this.authorize(tenantId, accountId, oldAccount); await this.authorize(tenantId, accountId, oldAccount);

View File

@@ -0,0 +1,78 @@
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)
);
}
/**
* 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,
]);
// Revert the recognized transactions of the given bank rules.
await this.revertRecognizedTransactins.revertRecognizedTransactions(
tenantId,
foundAssociatedRulesIds,
null,
trx
);
// Delete the associated uncategorized transactions.
await UncategorizedCashflowTransaction.query(trx)
.where('accountId', oldAccount.id)
.delete();
// Delete the given bank rules.
await this.deleteBankRules.deleteBankRules(
tenantId,
foundAssociatedRulesIds,
trx
);
}
}

View File

@@ -51,6 +51,7 @@ export class DisconnectPlaidItemOnAccountDeleted {
.findOne('plaidItemId', oldAccount.plaidItemId) .findOne('plaidItemId', oldAccount.plaidItemId)
.delete(); .delete();
// Remove Plaid item once the transaction resolve.
if (oldPlaidItem) { if (oldPlaidItem) {
const plaidInstance = PlaidClientWrapper.getClient(); const plaidInstance = PlaidClientWrapper.getClient();

View File

@@ -1,10 +1,10 @@
import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Knex } from 'knex';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { PlaidClientWrapper } from '@/lib/Plaid/Plaid'; import { PlaidClientWrapper } from '@/lib/Plaid/Plaid';
import { PlaidSyncDb } from './PlaidSyncDB'; import { PlaidSyncDb } from './PlaidSyncDB';
import { PlaidFetchedTransactionsUpdates } from '@/interfaces'; import { PlaidFetchedTransactionsUpdates } from '@/interfaces';
import UnitOfWork from '@/services/UnitOfWork'; import UnitOfWork from '@/services/UnitOfWork';
import { Knex } from 'knex';
@Service() @Service()
export class PlaidUpdateTransactions { export class PlaidUpdateTransactions {
@@ -19,8 +19,8 @@ export class PlaidUpdateTransactions {
/** /**
* Handles sync the Plaid item to Bigcaptial under UOW. * Handles sync the Plaid item to Bigcaptial under UOW.
* @param {number} tenantId * @param {number} tenantId - Tenant id.
* @param {number} plaidItemId * @param {number} plaidItemId - Plaid item id.
* @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>} * @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>}
*/ */
public async updateTransactions(tenantId: number, plaidItemId: string) { public async updateTransactions(tenantId: number, plaidItemId: string) {

View File

@@ -26,14 +26,20 @@ export class DeleteBankRuleSerivce {
* @param {number} ruleId * @param {number} ruleId
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public async deleteBankRule(tenantId: number, ruleId: number): Promise<void> { public async deleteBankRule(
tenantId: number,
ruleId: number,
trx?: Knex.Transaction
): Promise<void> {
const { BankRule, BankRuleCondition } = this.tenancy.models(tenantId); const { BankRule, BankRuleCondition } = this.tenancy.models(tenantId);
const oldBankRule = await BankRule.query() const oldBankRule = await BankRule.query()
.findById(ruleId) .findById(ruleId)
.throwIfNotFound(); .throwIfNotFound();
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Triggers `onBankRuleDeleting` event. // Triggers `onBankRuleDeleting` event.
await this.eventPublisher.emitAsync(events.bankRules.onDeleting, { await this.eventPublisher.emitAsync(events.bankRules.onDeleting, {
tenantId, tenantId,
@@ -42,7 +48,7 @@ export class DeleteBankRuleSerivce {
trx, trx,
} as IBankRuleEventDeletingPayload); } as IBankRuleEventDeletingPayload);
await BankRuleCondition.query(trx).where('ruleId', ruleId).delete(); await BankRuleCondition.query(trx).where('ruleId', ruleId).delete()
await BankRule.query(trx).findById(ruleId).delete(); await BankRule.query(trx).findById(ruleId).delete();
// Triggers `onBankRuleDeleted` event. // Triggers `onBankRuleDeleted` event.
@@ -51,6 +57,8 @@ export class DeleteBankRuleSerivce {
ruleId, ruleId,
trx, trx,
} as IBankRuleEventDeletedPayload); } as IBankRuleEventDeletedPayload);
}); },
trx
);
} }
} }

View File

@@ -0,0 +1,34 @@
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<number>} bankRuleId
*/
async deleteBankRules(
tenantId: number,
bankRuleId: number | Array<number>,
trx?: Knex.Transaction
) {
const bankRulesIds = uniq(castArray(bankRuleId));
const results = await PromisePool.withConcurrency(1)
.for(bankRulesIds)
.process(async (bankRuleId: number) => {
await this.deleteBankRuleService.deleteBankRule(
tenantId,
bankRuleId,
trx
);
});
}
}

View File

@@ -208,13 +208,18 @@ function AccountTransactionsActionsBar({
bankAccountId: accountId, bankAccountId: accountId,
}); });
}; };
// Handles uncategorize the categorized transactions in bulk. // Handles uncategorize the categorized transactions in bulk.
const handleUncategorizeCategorizedBulkBtnClick = () => { const handleUncategorizeCategorizedBulkBtnClick = () => {
openAlert('uncategorize-transactions-bulk', { openAlert('uncategorize-transactions-bulk', {
uncategorizeTransactionsIds: categorizedTransactionsSelected, uncategorizeTransactionsIds: categorizedTransactionsSelected,
}); });
}; };
// Handles the delete account button click.
const handleDeleteAccountClick = () => {
openAlert('account-delete', {
accountId,
});
};
return ( return (
<DashboardActionsBar> <DashboardActionsBar>
@@ -364,9 +369,19 @@ function AccountTransactionsActionsBar({
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} /> <MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
<MenuDivider />
<If condition={isSyncingOwner && isFeedsActive}> <If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} /> <MenuItem
intent={Intent.DANGER}
onClick={handleDisconnectClick}
text={'Disconnect'}
/>
</If> </If>
<MenuItem
intent={Intent.DANGER}
onClick={handleDeleteAccountClick}
text={'Delete'}
/>
</Menu> </Menu>
} }
> >

View File

@@ -17,6 +17,7 @@ import { TABLES } from '@/constants/tables';
import withSettings from '@/containers/Settings/withSettings'; import withSettings from '@/containers/Settings/withSettings';
import withAlertsActions from '@/containers/Alert/withAlertActions'; import withAlertsActions from '@/containers/Alert/withAlertActions';
import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { withBankingActions } from '../withBankingActions';
import { useMemorizedColumnsWidths } from '@/hooks'; import { useMemorizedColumnsWidths } from '@/hooks';
import { useAccountTransactionsColumns, ActionsMenu } from './components'; import { useAccountTransactionsColumns, ActionsMenu } from './components';
@@ -26,7 +27,6 @@ import { useUncategorizeTransaction } from '@/hooks/query';
import { handleCashFlowTransactionType } from './utils'; import { handleCashFlowTransactionType } from './utils';
import { compose } from '@/utils'; import { compose } from '@/utils';
import { withBankingActions } from '../withBankingActions';
/** /**
* Account transactions data table. * Account transactions data table.