Merge pull request #587 from bigcapitalhq/big-244-uncategorize-bank-transactions-in-bulk

feat: Uncategorize bank transactions in bulk
This commit is contained in:
Ahmed Bouhuolia
2024-08-12 10:11:55 +02:00
committed by GitHub
11 changed files with 323 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import { ValidationChain, check, param, query } from 'express-validator'; import { ValidationChain, body, check, param, query } from 'express-validator';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { omit } from 'lodash'; import { omit } from 'lodash';
import BaseController from '../BaseController'; import BaseController from '../BaseController';
@@ -43,6 +43,16 @@ export default class NewCashflowTransactionController extends BaseController {
this.asyncMiddleware(this.newCashflowTransaction), this.asyncMiddleware(this.newCashflowTransaction),
this.catchServiceErrors this.catchServiceErrors
); );
router.post(
'/transactions/uncategorize/bulk',
[
body('ids').isArray({ min: 1 }),
body('ids.*').exists().isNumeric().toInt(),
],
this.validationResult,
this.uncategorizeBulkTransactions.bind(this),
this.catchServiceErrors
);
router.post( router.post(
'/transactions/:id/uncategorize', '/transactions/:id/uncategorize',
this.revertCategorizedCashflowTransaction, this.revertCategorizedCashflowTransaction,
@@ -184,6 +194,34 @@ export default class NewCashflowTransactionController extends BaseController {
} }
}; };
/**
* Uncategorize the given transactions in bulk.
* @param {Request<{}>} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
private uncategorizeBulkTransactions = async (
req: Request<{}>,
res: Response,
next: NextFunction
) => {
const { tenantId } = req;
const { ids: uncategorizedTransactionIds } = this.matchedBodyData(req);
try {
await this.cashflowApplication.uncategorizeTransactions(
tenantId,
uncategorizedTransactionIds
);
return res.status(200).send({
message: 'The given transactions have been uncategorized successfully.',
});
} catch (error) {
next(error);
}
};
/** /**
* Categorize the cashflow transaction. * Categorize the cashflow transaction.
* @param {Request} req * @param {Request} req

View File

@@ -21,6 +21,7 @@ import GetCashflowAccountsService from './GetCashflowAccountsService';
import { GetCashflowTransactionService } from './GetCashflowTransactionsService'; import { GetCashflowTransactionService } from './GetCashflowTransactionsService';
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions'; import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
import { GetRecognizedTransactionService } from './GetRecognizedTransaction'; import { GetRecognizedTransactionService } from './GetRecognizedTransaction';
import { UncategorizeCashflowTransactionsBulk } from './UncategorizeCashflowTransactionsBulk';
@Service() @Service()
export class CashflowApplication { export class CashflowApplication {
@@ -39,6 +40,9 @@ export class CashflowApplication {
@Inject() @Inject()
private uncategorizeTransactionService: UncategorizeCashflowTransaction; private uncategorizeTransactionService: UncategorizeCashflowTransaction;
@Inject()
private uncategorizeTransasctionsService: UncategorizeCashflowTransactionsBulk;
@Inject() @Inject()
private categorizeTransactionService: CategorizeCashflowTransaction; private categorizeTransactionService: CategorizeCashflowTransaction;
@@ -155,6 +159,22 @@ export class CashflowApplication {
); );
} }
/**
* Uncategorize the given transactions in bulk.
* @param {number} tenantId
* @param {number | Array<number>} transactionId
* @returns
*/
public uncategorizeTransactions(
tenantId: number,
transactionId: number | Array<number>
) {
return this.uncategorizeTransasctionsService.uncategorizeBulk(
tenantId,
transactionId
);
}
/** /**
* Categorize the given cashflow transaction. * Categorize the given cashflow transaction.
* @param {number} tenantId * @param {number} tenantId

View File

@@ -0,0 +1,37 @@
import PromisePool from '@supercharge/promise-pool';
import { castArray } from 'lodash';
import { Service, Inject } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
@Service()
export class UncategorizeCashflowTransactionsBulk {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uncategorizeTransaction: UncategorizeCashflowTransaction;
/**
* Uncategorize the given bank transactions in bulk.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
*/
public async uncategorizeBulk(
tenantId: number,
uncategorizedTransactionId: number | Array<number>
) {
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
.for(uncategorizedTransactionIds)
.process(async (_uncategorizedTransactionId: number, index, pool) => {
await this.uncategorizeTransaction.uncategorize(
tenantId,
_uncategorizedTransactionId
);
});
}
}
const MIGRATION_CONCURRENCY = 1;

View File

@@ -64,6 +64,7 @@ function AccountTransactionsActionsBar({
uncategorizedTransationsIdsSelected, uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
openMatchingTransactionAside, openMatchingTransactionAside,
categorizedTransactionsSelected,
// #withBankingActions // #withBankingActions
enableMultipleCategorization, enableMultipleCategorization,
@@ -194,7 +195,7 @@ function AccountTransactionsActionsBar({
// Handle multi select transactions for categorization or matching. // Handle multi select transactions for categorization or matching.
const handleMultipleCategorizingSwitch = (event) => { const handleMultipleCategorizingSwitch = (event) => {
enableMultipleCategorization(event.currentTarget.checked); enableMultipleCategorization(event.currentTarget.checked);
} };
// Handle resume bank feeds syncing. // Handle resume bank feeds syncing.
const handleResumeFeedsSyncing = () => { const handleResumeFeedsSyncing = () => {
openAlert('resume-feeds-syncing-bank-accounnt', { openAlert('resume-feeds-syncing-bank-accounnt', {
@@ -208,6 +209,13 @@ function AccountTransactionsActionsBar({
}); });
}; };
// Handles uncategorize the categorized transactions in bulk.
const handleUncategorizeCategorizedBulkBtnClick = () => {
openAlert('uncategorize-transactions-bulk', {
uncategorizeTransactionsIds: categorizedTransactionsSelected,
});
};
return ( return (
<DashboardActionsBar> <DashboardActionsBar>
<NavbarGroup> <NavbarGroup>
@@ -297,6 +305,14 @@ function AccountTransactionsActionsBar({
disabled={isUnexcludingLoading} disabled={isUnexcludingLoading}
/> />
)} )}
{!isEmpty(categorizedTransactionsSelected) && (
<Button
text={'Uncategorize'}
onClick={handleUncategorizeCategorizedBulkBtnClick}
intent={Intent.DANGER}
minimal
/>
)}
</NavbarGroup> </NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}> <NavbarGroup align={Alignment.RIGHT}>
@@ -379,10 +395,12 @@ export default compose(
uncategorizedTransationsIdsSelected, uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
openMatchingTransactionAside, openMatchingTransactionAside,
categorizedTransactionsSelected,
}) => ({ }) => ({
uncategorizedTransationsIdsSelected, uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
openMatchingTransactionAside, openMatchingTransactionAside,
categorizedTransactionsSelected,
}), }),
), ),
withBankingActions, withBankingActions,

View File

@@ -22,10 +22,11 @@ import { useMemorizedColumnsWidths } from '@/hooks';
import { useAccountTransactionsColumns, ActionsMenu } from './components'; import { useAccountTransactionsColumns, ActionsMenu } from './components';
import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot'; import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot';
import { useUnmatchMatchedUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { useUnmatchMatchedUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { useUncategorizeTransaction } from '@/hooks/query';
import { handleCashFlowTransactionType } from './utils'; import { handleCashFlowTransactionType } from './utils';
import { compose } from '@/utils'; import { compose } from '@/utils';
import { useUncategorizeTransaction } from '@/hooks/query'; import { withBankingActions } from '../withBankingActions';
/** /**
* Account transactions data table. * Account transactions data table.
@@ -39,6 +40,9 @@ function AccountTransactionsDataTable({
// #withDrawerActions // #withDrawerActions
openDrawer, openDrawer,
// #withBankingActions
setCategorizedTransactionsSelected,
}) { }) {
// Retrieve table columns. // Retrieve table columns.
const columns = useAccountTransactionsColumns(); const columns = useAccountTransactionsColumns();
@@ -97,6 +101,15 @@ function AccountTransactionsDataTable({
}); });
}; };
// Handle selected rows change.
const handleSelectedRowsChange = (selected) => {
const selectedIds = selected
?.filter((row) => row.original.uncategorized_transaction_id)
?.map((row) => row.original.uncategorized_transaction_id);
setCategorizedTransactionsSelected(selectedIds);
};
return ( return (
<CashflowTransactionsTable <CashflowTransactionsTable
noInitialFetch={true} noInitialFetch={true}
@@ -119,6 +132,8 @@ function AccountTransactionsDataTable({
vListOverscanRowCount={0} vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths} initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing} onColumnResizing={handleColumnResizing}
selectionColumn={true}
onSelectedRowsChange={handleSelectedRowsChange}
noResults={<T id={'cash_flow.account_transactions.no_results'} />} noResults={<T id={'cash_flow.account_transactions.no_results'} />}
className="table-constrant" className="table-constrant"
payload={{ payload={{
@@ -136,6 +151,7 @@ export default compose(
})), })),
withAlertsActions, withAlertsActions,
withDrawerActions, withDrawerActions,
withBankingActions,
)(AccountTransactionsDataTable); )(AccountTransactionsDataTable);
const DashboardConstrantTable = styled(DataTable)` const DashboardConstrantTable = styled(DataTable)`

View File

@@ -0,0 +1,69 @@
// @ts-nocheck
import React from 'react';
import { Intent, Alert } from '@blueprintjs/core';
import { AppToaster, FormattedMessage as T } from '@/components';
import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect';
import withAlertActions from '@/containers/Alert/withAlertActions';
import { useUncategorizeTransactionsBulkAction } from '@/hooks/query/bank-transactions';
import { compose } from '@/utils';
/**
* Uncategorize bank account transactions in build alert.
*/
function UncategorizeBankTransactionsBulkAlert({
name,
// #withAlertStoreConnect
isOpen,
payload: { uncategorizeTransactionsIds },
// #withAlertActions
closeAlert,
}) {
const { mutateAsync: uncategorizeTransactions, isLoading } =
useUncategorizeTransactionsBulkAction();
// Handle activate item alert cancel.
const handleCancelActivateItem = () => {
closeAlert(name);
};
// Handle confirm item activated.
const handleConfirmItemActivate = () => {
uncategorizeTransactions({ ids: uncategorizeTransactionsIds })
.then(() => {
AppToaster.show({
message: 'The bank feeds of the bank account has been resumed.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {})
.finally(() => {
closeAlert(name);
});
};
return (
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={'Uncategorize Transactions'}
intent={Intent.DANGER}
isOpen={isOpen}
onCancel={handleCancelActivateItem}
loading={isLoading}
onConfirm={handleConfirmItemActivate}
>
<p>
Are you sure want to uncategorize the selected bank transactions, this
action is not reversible but you can always categorize them again?
</p>
</Alert>
);
}
export default compose(
withAlertStoreConnect(),
withAlertActions,
)(UncategorizeBankTransactionsBulkAlert);

View File

@@ -9,6 +9,10 @@ const PauseFeedsBankAccountAlert = React.lazy(
() => import('./PauseFeedsBankAccount'), () => import('./PauseFeedsBankAccount'),
); );
const UncategorizeTransactionsBulkAlert = React.lazy(
() => import('./UncategorizeBankTransactionsBulkAlert'),
);
/** /**
* Bank account alerts. * Bank account alerts.
*/ */
@@ -21,4 +25,8 @@ export const BankAccountAlerts = [
name: 'pause-feeds-syncing-bank-accounnt', name: 'pause-feeds-syncing-bank-accounnt',
component: PauseFeedsBankAccountAlert, component: PauseFeedsBankAccountAlert,
}, },
{
name: 'uncategorize-transactions-bulk',
component: UncategorizeTransactionsBulkAlert,
},
]; ];

View File

@@ -22,6 +22,9 @@ export const withBanking = (mapState) => {
transactionsToCategorizeIdsSelected: transactionsToCategorizeIdsSelected:
state.plaid.transactionsToCategorizeSelected, state.plaid.transactionsToCategorizeSelected,
categorizedTransactionsSelected:
state.plaid.categorizedTransactionsSelected,
}; };
return mapState ? mapState(mapped, state, props) : mapped; return mapState ? mapState(mapped, state, props) : mapped;
}; };

View File

@@ -13,6 +13,8 @@ import {
enableMultipleCategorization, enableMultipleCategorization,
addTransactionsToCategorizeSelected, addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected, removeTransactionsToCategorizeSelected,
setCategorizedTransactionsSelected,
resetCategorizedTransactionsSelected,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps { export interface WithBankingActionsProps {
@@ -35,6 +37,9 @@ export interface WithBankingActionsProps {
resetTransactionsToCategorizeSelected: () => void; resetTransactionsToCategorizeSelected: () => void;
enableMultipleCategorization: (enable: boolean) => void; enableMultipleCategorization: (enable: boolean) => void;
setCategorizedTransactionsSelected: (ids: Array<string | number>) => void;
resetCategorizedTransactionsSelected: () => void;
} }
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -120,6 +125,19 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
*/ */
enableMultipleCategorization: (enable: boolean) => enableMultipleCategorization: (enable: boolean) =>
dispatch(enableMultipleCategorization({ enable })), dispatch(enableMultipleCategorization({ enable })),
/**
* Sets the selected ids of the categorized transactions.
* @param {Array<string | number>} ids
*/
setCategorizedTransactionsSelected: (ids: Array<string | number>) =>
dispatch(setCategorizedTransactionsSelected({ ids })),
/**
* Resets the selected categorized transcations.
*/
resetCategorizedTransactionsSelected: () =>
dispatch(resetCategorizedTransactionsSelected()),
}); });
export const withBankingActions = connect< export const withBankingActions = connect<

View File

@@ -0,0 +1,65 @@
// @ts-nocheck
import {
useMutation,
UseMutationOptions,
UseMutationResult,
useQueryClient,
} from 'react-query';
import useApiRequest from '../useRequest';
import { BANK_QUERY_KEY } from '@/constants/query-keys/banking';
import t from './types';
type UncategorizeTransactionsBulkValues = { ids: Array<number> };
interface UncategorizeBankTransactionsBulkResponse {}
/**
* Uncategorize the given categorized transactions in bulk.
* @param {UseMutationResult<PuaseFeedsBankAccountResponse, Error, ExcludeBankTransactionValue>} options
* @returns {UseMutationResult<PuaseFeedsBankAccountResponse, Error, ExcludeBankTransactionValue>}
*/
export function useUncategorizeTransactionsBulkAction(
options?: UseMutationOptions<
UncategorizeBankTransactionsBulkResponse,
Error,
UncategorizeTransactionsBulkValues
>,
): UseMutationResult<
UncategorizeBankTransactionsBulkResponse,
Error,
UncategorizeTransactionsBulkValues
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
UncategorizeBankTransactionsBulkResponse,
Error,
UncategorizeTransactionsBulkValues
>(
(value) =>
apiRequest.post(`/cashflow/transactions/uncategorize/bulk`, {
ids: value.ids,
}),
{
onSuccess: (res, values) => {
// Invalidate the account uncategorized transactions.
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate the account transactions.
queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY);
// Invalidate bank account summary.
queryClient.invalidateQueries(BANK_QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
// Invalidate the recognized transactions.
queryClient.invalidateQueries([
BANK_QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY,
]);
// Invalidate the account.
queryClient.invalidateQueries(t.ACCOUNT);
},
...options,
},
);
}

View File

@@ -12,6 +12,8 @@ interface StorePlaidState {
transactionsToCategorizeSelected: Array<number | string>; transactionsToCategorizeSelected: Array<number | string>;
enableMultipleCategorization: boolean; enableMultipleCategorization: boolean;
categorizedTransactionsSelected: Array<number | string>;
} }
export const PlaidSlice = createSlice({ export const PlaidSlice = createSlice({
@@ -28,6 +30,7 @@ export const PlaidSlice = createSlice({
excludedTransactionsSelected: [], excludedTransactionsSelected: [],
transactionsToCategorizeSelected: [], transactionsToCategorizeSelected: [],
enableMultipleCategorization: false, enableMultipleCategorization: false,
categorizedTransactionsSelected: [],
} as StorePlaidState, } as StorePlaidState,
reducers: { reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => { setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -176,6 +179,26 @@ export const PlaidSlice = createSlice({
) => { ) => {
state.enableMultipleCategorization = action.payload.enable; state.enableMultipleCategorization = action.payload.enable;
}, },
/**
* Sets the selected ids of the categorized transactions.
* @param {StorePlaidState}
* @param {PayloadAction<{ ids: Array<string | number> }>}
*/
setCategorizedTransactionsSelected: (
state: StorePlaidState,
action: PayloadAction<{ ids: Array<string | number> }>,
) => {
state.categorizedTransactionsSelected = action.payload.ids;
},
/**
* Resets the selected categorized transcations.
* @param {StorePlaidState}
*/
resetCategorizedTransactionsSelected: (state: StorePlaidState) => {
state.categorizedTransactionsSelected = [];
},
}, },
}); });
@@ -195,6 +218,8 @@ export const {
removeTransactionsToCategorizeSelected, removeTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected, resetTransactionsToCategorizeSelected,
enableMultipleCategorization, enableMultipleCategorization,
setCategorizedTransactionsSelected,
resetCategorizedTransactionsSelected,
} = PlaidSlice.actions; } = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken; export const getPlaidToken = (state: any) => state.plaid.plaidToken;