feat: bulk categorizing bank transactions

This commit is contained in:
Ahmed Bouhuolia
2024-07-18 17:00:23 +02:00
parent 51471ed000
commit 449390143d
18 changed files with 335 additions and 126 deletions

View File

@@ -1,12 +1,8 @@
import { Inject, Service } from 'typedi';
import { body, param } from 'express-validator';
import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController';
import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication';
import { body, param } from 'express-validator';
import {
GetMatchedTransactionsFilter,
IMatchTransactionsDTO,
} from '@/services/Banking/Matching/types';
@Service()
export class BankTransactionsMatchingController extends BaseController {
@@ -20,9 +16,17 @@ export class BankTransactionsMatchingController extends BaseController {
const router = Router();
router.post(
'/:transactionId',
'/unmatch/:transactionId',
[param('transactionId').exists()],
this.validationResult,
this.unmatchMatchedBankTransaction.bind(this)
);
router.post(
'/match',
[
param('transactionId').exists(),
body('uncategorizedTransactions').exists().isArray({ min: 1 }),
body('uncategorizedTransactions.*').isNumeric().toInt(),
body('matchedTransactions').isArray({ min: 1 }),
body('matchedTransactions.*.reference_type').exists(),
body('matchedTransactions.*.reference_id').isNumeric().toInt(),
@@ -30,12 +34,6 @@ export class BankTransactionsMatchingController extends BaseController {
this.validationResult,
this.matchBankTransaction.bind(this)
);
router.post(
'/unmatch/:transactionId',
[param('transactionId').exists()],
this.validationResult,
this.unmatchMatchedBankTransaction.bind(this)
);
return router;
}
@@ -50,21 +48,21 @@ export class BankTransactionsMatchingController extends BaseController {
req: Request<{ transactionId: number }>,
res: Response,
next: NextFunction
) {
): Promise<Response | null> {
const { tenantId } = req;
const { transactionId } = req.params;
const matchTransactionDTO = this.matchedBodyData(
req
) as IMatchTransactionsDTO;
const bodyData = this.matchedBodyData(req);
const uncategorizedTransactions = bodyData?.uncategorizedTransactions;
const matchedTransactions = bodyData?.matchedTransactions;
try {
await this.bankTransactionsMatchingApp.matchTransaction(
tenantId,
transactionId,
matchTransactionDTO
uncategorizedTransactions,
matchedTransactions
);
return res.status(200).send({
id: transactionId,
ids: uncategorizedTransactions,
message: 'The bank transaction has been matched.',
});
} catch (error) {

View File

@@ -1,10 +1,15 @@
import { Service, Inject } from 'typedi';
import { ValidationChain, check, param, query } from 'express-validator';
import { Router, Request, Response, NextFunction } from 'express';
import { omit } from 'lodash';
import BaseController from '../BaseController';
import { ServiceError } from '@/exceptions';
import CheckPolicies from '@/api/middleware/CheckPolicies';
import { AbilitySubject, CashflowAction } from '@/interfaces';
import {
AbilitySubject,
CashflowAction,
ICategorizeCashflowTransactioDTO,
} from '@/interfaces';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
@Service()
@@ -44,7 +49,7 @@ export default class NewCashflowTransactionController extends BaseController {
this.catchServiceErrors
);
router.post(
'/transactions/:id/categorize',
'/transactions/categorize',
this.categorizeCashflowTransactionValidationSchema,
this.validationResult,
this.categorizeCashflowTransaction,
@@ -89,6 +94,7 @@ export default class NewCashflowTransactionController extends BaseController {
*/
public get categorizeCashflowTransactionValidationSchema() {
return [
check('uncategorized_transaction_ids').exists().isArray({ min: 1 }),
check('date').exists().isISO8601().toDate(),
check('credit_account_id').exists().isInt().toInt(),
check('transaction_number').optional(),
@@ -191,14 +197,18 @@ export default class NewCashflowTransactionController extends BaseController {
next: NextFunction
) => {
const { tenantId } = req;
const { id: cashflowTransactionId } = req.params;
const cashflowTransaction = this.matchedBodyData(req);
const matchedObject = this.matchedBodyData(req);
const categorizeDTO = omit(matchedObject, [
'uncategorizedTransactionIds',
]) as ICategorizeCashflowTransactioDTO;
const uncategorizedTransactionIds =
matchedObject.uncategorizedTransactionIds;
try {
await this.cashflowApplication.categorizeTransaction(
tenantId,
cashflowTransactionId,
cashflowTransaction
uncategorizedTransactionIds,
categorizeDTO
);
return res.status(200).send({
message: 'The cashflow transaction has been created successfully.',

View File

@@ -236,6 +236,7 @@ export interface ICashflowTransactionSchema {
export interface ICashflowTransactionInput extends ICashflowTransactionSchema {}
export interface ICategorizeCashflowTransactioDTO {
date: Date;
creditAccountId: number;
referenceNo: string;
transactionNumber: string;

View File

@@ -130,14 +130,15 @@ export interface ICommandCashflowDeletedPayload {
export interface ICashflowTransactionCategorizedPayload {
tenantId: number;
uncategorizedTransaction: any;
uncategorizedTransactions: any;
cashflowTransaction: ICashflowTransaction;
oldUncategorizedTransactions: Array<IUncategorizedCashflowTransaction>;
categorizeDTO: any;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizingPayload {
tenantId: number;
uncategorizedTransaction: IUncategorizedCashflowTransaction;
oldUncategorizedTransactions: Array<IUncategorizedCashflowTransaction>;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizedPayload {

View File

@@ -7,6 +7,7 @@ import {
MatchedTransactionsPOJO,
} from './types';
import { Inject, Service } from 'typedi';
import PromisePool from '@supercharge/promise-pool';
export abstract class GetMatchedTransactionsByType {
@Inject()
@@ -45,22 +46,26 @@ export abstract class GetMatchedTransactionsByType {
/**
*
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @param {Array<number>} uncategorizedTransactionIds
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx
*/
public async createMatchedTransaction(
tenantId: number,
uncategorizedTransactionId: number,
uncategorizedTransactionIds: Array<number>,
matchTransactionDTO: IMatchTransactionDTO,
trx?: Knex.Transaction
) {
const { MatchedBankTransaction } = this.tenancy.models(tenantId);
await MatchedBankTransaction.query(trx).insert({
uncategorizedTransactionId,
referenceType: matchTransactionDTO.referenceType,
referenceId: matchTransactionDTO.referenceId,
});
await PromisePool.withConcurrency(2)
.for(uncategorizedTransactionIds)
.process(async (uncategorizedTransactionId) => {
await MatchedBankTransaction.query(trx).insert({
uncategorizedTransactionId,
referenceType: matchTransactionDTO.referenceType,
referenceId: matchTransactionDTO.referenceId,
});
});
}
}

View File

@@ -2,7 +2,7 @@ import { Inject, Service } from 'typedi';
import { GetMatchedTransactions } from './GetMatchedTransactions';
import { MatchBankTransactions } from './MatchTransactions';
import { UnmatchMatchedBankTransaction } from './UnmatchMatchedTransaction';
import { GetMatchedTransactionsFilter, IMatchTransactionsDTO } from './types';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
@Service()
export class MatchBankTransactionsApplication {
@@ -42,13 +42,13 @@ export class MatchBankTransactionsApplication {
*/
public matchTransaction(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: Array<IMatchTransactionDTO>
): Promise<void> {
return this.matchTransactionService.matchTransaction(
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO
matchedTransactions
);
}

View File

@@ -1,4 +1,4 @@
import { isEmpty } from 'lodash';
import { castArray } from 'lodash';
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
@@ -10,11 +10,15 @@ import {
ERRORS,
IBankTransactionMatchedEventPayload,
IBankTransactionMatchingEventPayload,
IMatchTransactionsDTO,
IMatchTransactionDTO,
} from './types';
import { MatchTransactionsTypes } from './MatchTransactionsTypes';
import { ServiceError } from '@/exceptions';
import { sumMatchTranasctions } from './_utils';
import {
sumMatchTranasctions,
validateUncategorizedTransactionsExcluded,
validateUncategorizedTransactionsNotMatched,
} from './_utils';
@Service()
export class MatchBankTransactions {
@@ -39,27 +43,24 @@ export class MatchBankTransactions {
*/
async validate(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: Array<IMatchTransactionDTO>
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const { matchedTransactions } = matchTransactionsDTO;
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
// Validates the uncategorized transaction existance.
const uncategorizedTransaction =
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.withGraphFetched('matchedBankTransactions')
.throwIfNotFound();
.whereIn('id', uncategorizedTransactionIds)
.withGraphFetched('matchedBankTransactions');
// Validates the uncategorized transaction is not already matched.
if (!isEmpty(uncategorizedTransaction.matchedBankTransactions)) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED);
}
validateUncategorizedTransactionsNotMatched(uncategorizedTransactions);
// Validate the uncategorized transaction is not excluded.
if (uncategorizedTransaction.excluded) {
throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION);
}
validateUncategorizedTransactionsExcluded(uncategorizedTransactions);
// Validates the given matched transaction.
const validateMatchedTransaction = async (matchedTransaction) => {
const getMatchedTransactionsService =
@@ -96,9 +97,9 @@ export class MatchBankTransactions {
);
// Validates the total given matching transcations whether is not equal
// uncategorized transaction amount.
if (totalMatchedTranasctions !== uncategorizedTransaction.amount) {
throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID);
}
// if (totalMatchedTranasctions !== uncategorizedTransaction.amount) {
// throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID);
// }
}
/**
@@ -109,23 +110,23 @@ export class MatchBankTransactions {
*/
public async matchTransaction(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: Array<IMatchTransactionDTO>
): Promise<void> {
const { matchedTransactions } = matchTransactionsDTO;
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
// Validates the given matching transactions DTO.
await this.validate(
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO
uncategorizedTransactionIds,
matchedTransactions
);
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers the event `onBankTransactionMatching`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatching, {
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO,
uncategorizedTransactionIds,
matchedTransactions,
trx,
} as IBankTransactionMatchingEventPayload);
@@ -139,17 +140,16 @@ export class MatchBankTransactions {
);
await getMatchedTransactionsService.createMatchedTransaction(
tenantId,
uncategorizedTransactionId,
uncategorizedTransactionIds,
matchedTransaction,
trx
);
});
// Triggers the event `onBankTransactionMatched`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatched, {
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO,
uncategorizedTransactionIds,
matchedTransactions,
trx,
} as IBankTransactionMatchedEventPayload);
});

View File

@@ -1,7 +1,9 @@
import moment from 'moment';
import * as R from 'ramda';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import { MatchedTransactionPOJO } from './types';
import { ERRORS, MatchedTransactionPOJO } from './types';
import { isEmpty } from 'lodash';
import { ServiceError } from '@/exceptions';
export const sortClosestMatchTransactions = (
uncategorizedTransaction: UncategorizedCashflowTransaction,
@@ -29,3 +31,26 @@ export const sumMatchTranasctions = (transactions: Array<any>) => {
0
);
};
export const validateUncategorizedTransactionsNotMatched = (
uncategorizedTransactions: any
) => {
const isMatchedTransactions = uncategorizedTransactions.filter(
(trans) => !isEmpty(trans.matchedBankTransactions)
);
//
if (isMatchedTransactions.length > 0) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED);
}
};
export const validateUncategorizedTransactionsExcluded = (
uncategorizedTransactions: any
) => {
const excludedTransactions = uncategorizedTransactions.filter(
(trans) => trans.excluded
);
if (excludedTransactions.length > 0) {
throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION);
}
};

View File

@@ -5,6 +5,7 @@ import {
IBankTransactionUnmatchedEventPayload,
} from '../types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import PromisePool from '@supercharge/promise-pool';
@Service()
export class DecrementUncategorizedTransactionOnMatching {
@@ -30,18 +31,23 @@ export class DecrementUncategorizedTransactionOnMatching {
*/
public async decrementUnCategorizedTransactionsOnMatching({
tenantId,
uncategorizedTransactionId,
uncategorizedTransactionIds,
trx,
}: IBankTransactionMatchedEventPayload) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
const transactions = await UncategorizedCashflowTransaction.query().whereIn(
'id',
uncategorizedTransactionIds
);
await Account.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
await PromisePool.withConcurrency(1)
.for(transactions)
.process(async (transaction) => {
await Account.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
});
}
/**

View File

@@ -2,15 +2,15 @@ import { Knex } from 'knex';
export interface IBankTransactionMatchingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
matchTransactionsDTO: IMatchTransactionsDTO;
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction;
}
export interface IBankTransactionMatchedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
matchTransactionsDTO: IMatchTransactionsDTO;
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction;
}
@@ -32,6 +32,7 @@ export interface IMatchTransactionDTO {
}
export interface IMatchTransactionsDTO {
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
}

View File

@@ -164,12 +164,12 @@ export class CashflowApplication {
*/
public categorizeTransaction(
tenantId: number,
cashflowTransactionId: number,
uncategorizeTransactionIds: Array<number>,
categorizeDTO: ICategorizeCashflowTransactioDTO
) {
return this.categorizeTransactionService.categorize(
tenantId,
cashflowTransactionId,
uncategorizeTransactionIds,
categorizeDTO
);
}

View File

@@ -1,4 +1,6 @@
import { Inject, Service } from 'typedi';
import { castArray } from 'lodash';
import { Knex } from 'knex';
import HasTenancyService from '../Tenancy/TenancyService';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
@@ -8,12 +10,12 @@ import {
ICashflowTransactionUncategorizingPayload,
ICategorizeCashflowTransactioDTO,
} from '@/interfaces';
import { Knex } from 'knex';
import { transformCategorizeTransToCashflow } from './utils';
import {
transformCategorizeTransToCashflow,
validateUncategorizedTransactionsNotExcluded,
} from './utils';
import { CommandCashflowValidator } from './CommandCasflowValidator';
import NewCashflowTransactionService from './NewCashflowTransactionService';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
@Service()
export class CategorizeCashflowTransaction {
@@ -39,29 +41,31 @@ export class CategorizeCashflowTransaction {
*/
public async categorize(
tenantId: number,
uncategorizedTransactionId: number,
uncategorizedTransactionId: number | Array<number>,
categorizeDTO: ICategorizeCashflowTransactioDTO
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
// Retrieves the uncategorized transaction or throw an error.
const transaction = await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
const oldUncategorizedTransactions =
await UncategorizedCashflowTransaction.query()
.whereIn('id', uncategorizedTransactionIds)
.throwIfNotFound();
// Validate cannot categorize excluded transaction.
if (transaction.excluded) {
throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION);
}
// Validates the transaction shouldn't be categorized before.
this.commandValidators.validateTransactionShouldNotCategorized(transaction);
validateUncategorizedTransactionsNotExcluded(oldUncategorizedTransactions);
// Validates the transaction shouldn't be categorized before.
this.commandValidators.validateTransactionsShouldNotCategorized(
oldIncategorizedTransactions
);
// Validate the uncateogirzed transaction if it's deposit the transaction direction
// should `IN` and the same thing if it's withdrawal the direction should be OUT.
this.commandValidators.validateUncategorizeTransactionType(
transaction,
categorizeDTO.transactionType
);
// this.commandValidators.validateUncategorizeTransactionType(
// uncategorizedTransactions,
// categorizeDTO.transactionType
// );
// Edits the cashflow transaction under UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onTransactionCategorizing` event.
@@ -69,12 +73,13 @@ export class CategorizeCashflowTransaction {
events.cashflow.onTransactionCategorizing,
{
tenantId,
oldUncategorizedTransactions,
trx,
} as ICashflowTransactionUncategorizingPayload
);
// Transformes the categorize DTO to the cashflow transaction.
const cashflowTransactionDTO = transformCategorizeTransToCashflow(
transaction,
oldUncategorizedTransactions,
categorizeDTO
);
// Creates a new cashflow transaction.
@@ -84,22 +89,28 @@ export class CategorizeCashflowTransaction {
cashflowTransactionDTO
);
// Updates the uncategorized transaction as categorized.
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).patchAndFetchById(
uncategorizedTransactionId,
{
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
}
await UncategorizedCashflowTransaction.query(trx)
.whereIn('id', uncategorizedTransactionIds)
.patch({
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
});
// Fetch the new updated uncategorized transactions.
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query(trx).whereIn(
'id',
uncategorizedTransactionIds
);
// Triggers `onCashflowTransactionCategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorized,
{
tenantId,
cashflowTransaction,
uncategorizedTransaction,
uncategorizedTransactions,
oldUncategorizedTransactions,
categorizeDTO,
trx,
} as ICashflowTransactionCategorizedPayload

View File

@@ -68,10 +68,12 @@ export class CommandCashflowValidator {
* Validate the given transcation shouldn't be categorized.
* @param {CashflowTransaction} cashflowTransaction
*/
public validateTransactionShouldNotCategorized(
cashflowTransaction: CashflowTransaction
public validateTransactionsShouldNotCategorized(
cashflowTransactions: Array<IUncategorizedCashflowTransaction>
) {
if (cashflowTransaction.uncategorize) {
const categorized = cashflowTransactions.filter((t) => t.categorized);
if (categorized?.length > 0) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
}
}
@@ -87,7 +89,7 @@ export class CommandCashflowValidator {
transactionType: string
) {
const type = getCashflowTransactionType(
upperFirst(camelCase(transactionType)) as CASHFLOW_TRANSACTION_TYPE
transactionType as CASHFLOW_TRANSACTION_TYPE
);
if (
(type.direction === CASHFLOW_DIRECTION.IN &&

View File

@@ -1,7 +1,9 @@
import { upperFirst, camelCase } from 'lodash';
import { upperFirst, camelCase, first, sum, sumBy } from 'lodash';
import {
CASHFLOW_DIRECTION,
CASHFLOW_TRANSACTION_TYPE,
CASHFLOW_TRANSACTION_TYPE_META,
ERRORS,
ICashflowTransactionTypeMeta,
} from './constants';
import {
@@ -9,6 +11,8 @@ import {
ICategorizeCashflowTransactioDTO,
IUncategorizedCashflowTransaction,
} from '@/interfaces';
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
import { ServiceError } from '@/exceptions';
/**
* Ensures the given transaction type to transformed to appropriate format.
@@ -27,7 +31,9 @@ export const transformCashflowTransactionType = (type) => {
export function getCashflowTransactionType(
transactionType: CASHFLOW_TRANSACTION_TYPE
): ICashflowTransactionTypeMeta {
return CASHFLOW_TRANSACTION_TYPE_META[transactionType];
const _transactionType = transformCashflowTransactionType(transactionType);
return CASHFLOW_TRANSACTION_TYPE_META[_transactionType];
}
/**
@@ -46,22 +52,35 @@ export const getCashflowAccountTransactionsTypes = () => {
* @returns {ICashflowNewCommandDTO}
*/
export const transformCategorizeTransToCashflow = (
uncategorizeModel: IUncategorizedCashflowTransaction,
uncategorizeTransactions: Array<IUncategorizedCashflowTransaction>,
categorizeDTO: ICategorizeCashflowTransactioDTO
): ICashflowNewCommandDTO => {
const uncategorizeTransaction = first(uncategorizeTransactions);
const amount = sumBy(uncategorizeTransactions, 'amount');
const amountAbs = Math.abs(amount);
return {
date: uncategorizeModel.date,
referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo,
description: categorizeDTO.description || uncategorizeModel.description,
cashflowAccountId: uncategorizeModel.accountId,
date: categorizeDTO.date,
referenceNo: categorizeDTO.referenceNo,
description: categorizeDTO.description,
cashflowAccountId: uncategorizeTransaction.accountId,
creditAccountId: categorizeDTO.creditAccountId,
exchangeRate: categorizeDTO.exchangeRate || 1,
currencyCode: uncategorizeModel.currencyCode,
amount: uncategorizeModel.amount,
currencyCode: categorizeDTO.currencyCode,
amount: amountAbs,
transactionNumber: categorizeDTO.transactionNumber,
transactionType: categorizeDTO.transactionType,
uncategorizedTransactionId: uncategorizeModel.id,
branchId: categorizeDTO?.branchId,
publish: true,
};
};
export const validateUncategorizedTransactionsNotExcluded = (
transactions: Array<UncategorizeCashflowTransaction>
) => {
const excluded = transactions.filter((tran) => tran.excluded);
if (excluded?.length > 0) {
throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION);
}
};

View File

@@ -9,10 +9,15 @@ import {
PopoverInteractionKind,
Position,
Tooltip,
Checkbox,
} from '@blueprintjs/core';
import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { safeCallback } from '@/utils';
import {
useAddTransactionsToCategorizeSelected,
useRemoveTransactionsToCategorizeSelected,
} from '@/hooks/state/banking';
export function ActionsMenu({
payload: { onUncategorize, onUnmatch },
@@ -183,6 +188,20 @@ function statusAccessor(transaction) {
* Retrieve account uncategorized transctions table columns.
*/
export function useAccountUncategorizedTransactionsColumns() {
const addTransactionsToCategorizeSelected =
useAddTransactionsToCategorizeSelected();
const removeTransactionsToCategorizeSelected =
useRemoveTransactionsToCategorizeSelected();
const handleChange = (value) => (event) => {
if (event.currentTarget.checked) {
addTransactionsToCategorizeSelected(value.id);
} else {
removeTransactionsToCategorizeSelected(value.id);
}
};
return React.useMemo(
() => [
{
@@ -242,6 +261,15 @@ export function useAccountUncategorizedTransactionsColumns() {
align: 'right',
clickable: true,
},
{
id: 'categorize_include',
Header: 'Include',
accessor: (value) => <Checkbox large onChange={handleChange(value)} />,
width: 10,
minWidth: 10,
maxWidth: 10,
align: 'right',
},
],
[],
);

View File

@@ -8,6 +8,8 @@ import {
resetUncategorizedTransactionsSelected,
resetExcludedTransactionsSelected,
setExcludedTransactionsSelected,
resetTransactionsToCategorizeSelected,
setTransactionsToCategorizeSelected,
} from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps {
@@ -23,6 +25,9 @@ export interface WithBankingActionsProps {
setExcludedTransactionsSelected: (ids: Array<string | number>) => void;
resetExcludedTransactionsSelected: () => void;
setTransactionsToCategorizeSelected: (ids: Array<string | number>) => void;
resetTransactionsToCategorizeSelected: () => void;
}
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -56,6 +61,11 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
),
resetExcludedTransactionsSelected: () =>
dispatch(resetExcludedTransactionsSelected()),
setTransactionsToCategorizeSelected: (ids: Array<string | number>) =>
dispatch(setTransactionsToCategorizeSelected({ ids })),
resetTransactionsToCategorizeSelected: () =>
dispatch(resetTransactionsToCategorizeSelected()),
});
export const withBankingActions = connect<

View File

@@ -1,10 +1,15 @@
import { useDispatch, useSelector } from 'react-redux';
import { useCallback, useMemo } from 'react';
import {
getPlaidToken,
setPlaidId,
resetPlaidId,
setTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected,
getTransactionsToCategorizeSelected,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
} from '@/store/banking/banking.reducer';
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
export const useSetBankingPlaidToken = () => {
const dispatch = useDispatch();
@@ -30,3 +35,50 @@ export const useResetBankingPlaidToken = () => {
dispatch(resetPlaidId());
}, [dispatch]);
};
export const useGetTransactionsToCategorizeSelected = () => {
const selectedTransactions = useSelector(getTransactionsToCategorizeSelected);
return useMemo(() => selectedTransactions, [selectedTransactions]);
};
export const useSetTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(
(ids: Array<string | number>) => {
return dispatch(setTransactionsToCategorizeSelected({ ids }));
},
[dispatch],
);
};
export const useAddTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(
(id: string | number) => {
return dispatch(addTransactionsToCategorizeSelected({ id }));
},
[dispatch],
);
};
export const useRemoveTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(
(id: string | number) => {
return dispatch(removeTransactionsToCategorizeSelected({ id }));
},
[dispatch],
);
};
export const useResetTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(() => {
dispatch(resetTransactionsToCategorizeSelected());
}, [dispatch]);
};

View File

@@ -1,3 +1,4 @@
import { uniq } from 'lodash';
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
interface StorePlaidState {
@@ -8,6 +9,7 @@ interface StorePlaidState {
uncategorizedTransactionsSelected: Array<number | string>;
excludedTransactionsSelected: Array<number | string>;
transactionsToCategorizeSelected: Array<number | string>;
}
export const PlaidSlice = createSlice({
@@ -22,6 +24,7 @@ export const PlaidSlice = createSlice({
},
uncategorizedTransactionsSelected: [],
excludedTransactionsSelected: [],
transactionsToCategorizeSelected: [],
} as StorePlaidState,
reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -79,6 +82,37 @@ export const PlaidSlice = createSlice({
resetExcludedTransactionsSelected: (state: StorePlaidState) => {
state.excludedTransactionsSelected = [];
},
setTransactionsToCategorizeSelected: (
state: StorePlaidState,
action: PayloadAction<{ ids: Array<string | number> }>,
) => {
state.transactionsToCategorizeSelected = action.payload.ids;
},
addTransactionsToCategorizeSelected: (
state: StorePlaidState,
action: PayloadAction<{ id: string | number }>,
) => {
state.transactionsToCategorizeSelected = uniq([
...state.transactionsToCategorizeSelected,
action.payload.id,
]);
},
removeTransactionsToCategorizeSelected: (
state: StorePlaidState,
action: PayloadAction<{ id: string | number }>,
) => {
state.transactionsToCategorizeSelected =
state.transactionsToCategorizeSelected.filter(
(t) => t !== action.payload.id,
);
},
resetTransactionsToCategorizeSelected: (state: StorePlaidState) => {
state.transactionsToCategorizeSelected = [];
},
},
});
@@ -93,6 +127,12 @@ export const {
resetUncategorizedTransactionsSelected,
setExcludedTransactionsSelected,
resetExcludedTransactionsSelected,
setTransactionsToCategorizeSelected,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected,
} = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken;
export const getTransactionsToCategorizeSelected = (state: any) =>
state.plaid.transactionsToCategorizeSelected;