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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import moment from 'moment'; import moment from 'moment';
import * as R from 'ramda'; import * as R from 'ramda';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; 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 = ( export const sortClosestMatchTransactions = (
uncategorizedTransaction: UncategorizedCashflowTransaction, uncategorizedTransaction: UncategorizedCashflowTransaction,
@@ -29,3 +31,26 @@ export const sumMatchTranasctions = (transactions: Array<any>) => {
0 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, IBankTransactionUnmatchedEventPayload,
} from '../types'; } from '../types';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import PromisePool from '@supercharge/promise-pool';
@Service() @Service()
export class DecrementUncategorizedTransactionOnMatching { export class DecrementUncategorizedTransactionOnMatching {
@@ -30,18 +31,23 @@ export class DecrementUncategorizedTransactionOnMatching {
*/ */
public async decrementUnCategorizedTransactionsOnMatching({ public async decrementUnCategorizedTransactionsOnMatching({
tenantId, tenantId,
uncategorizedTransactionId, uncategorizedTransactionIds,
trx, trx,
}: IBankTransactionMatchedEventPayload) { }: IBankTransactionMatchedEventPayload) {
const { UncategorizedCashflowTransaction, Account } = const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId); this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query().findById( const transactions = await UncategorizedCashflowTransaction.query().whereIn(
uncategorizedTransactionId 'id',
uncategorizedTransactionIds
); );
await PromisePool.withConcurrency(1)
.for(transactions)
.process(async (transaction) => {
await Account.query(trx) await Account.query(trx)
.findById(transaction.accountId) .findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1); .decrement('uncategorizedTransactions', 1);
});
} }
/** /**

View File

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

View File

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

View File

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

View File

@@ -68,10 +68,12 @@ export class CommandCashflowValidator {
* Validate the given transcation shouldn't be categorized. * Validate the given transcation shouldn't be categorized.
* @param {CashflowTransaction} cashflowTransaction * @param {CashflowTransaction} cashflowTransaction
*/ */
public validateTransactionShouldNotCategorized( public validateTransactionsShouldNotCategorized(
cashflowTransaction: CashflowTransaction cashflowTransactions: Array<IUncategorizedCashflowTransaction>
) { ) {
if (cashflowTransaction.uncategorize) { const categorized = cashflowTransactions.filter((t) => t.categorized);
if (categorized?.length > 0) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
} }
} }
@@ -87,7 +89,7 @@ export class CommandCashflowValidator {
transactionType: string transactionType: string
) { ) {
const type = getCashflowTransactionType( const type = getCashflowTransactionType(
upperFirst(camelCase(transactionType)) as CASHFLOW_TRANSACTION_TYPE transactionType as CASHFLOW_TRANSACTION_TYPE
); );
if ( if (
(type.direction === CASHFLOW_DIRECTION.IN && (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 { import {
CASHFLOW_DIRECTION,
CASHFLOW_TRANSACTION_TYPE, CASHFLOW_TRANSACTION_TYPE,
CASHFLOW_TRANSACTION_TYPE_META, CASHFLOW_TRANSACTION_TYPE_META,
ERRORS,
ICashflowTransactionTypeMeta, ICashflowTransactionTypeMeta,
} from './constants'; } from './constants';
import { import {
@@ -9,6 +11,8 @@ import {
ICategorizeCashflowTransactioDTO, ICategorizeCashflowTransactioDTO,
IUncategorizedCashflowTransaction, IUncategorizedCashflowTransaction,
} from '@/interfaces'; } from '@/interfaces';
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
import { ServiceError } from '@/exceptions';
/** /**
* Ensures the given transaction type to transformed to appropriate format. * Ensures the given transaction type to transformed to appropriate format.
@@ -27,7 +31,9 @@ export const transformCashflowTransactionType = (type) => {
export function getCashflowTransactionType( export function getCashflowTransactionType(
transactionType: CASHFLOW_TRANSACTION_TYPE transactionType: CASHFLOW_TRANSACTION_TYPE
): ICashflowTransactionTypeMeta { ): 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} * @returns {ICashflowNewCommandDTO}
*/ */
export const transformCategorizeTransToCashflow = ( export const transformCategorizeTransToCashflow = (
uncategorizeModel: IUncategorizedCashflowTransaction, uncategorizeTransactions: Array<IUncategorizedCashflowTransaction>,
categorizeDTO: ICategorizeCashflowTransactioDTO categorizeDTO: ICategorizeCashflowTransactioDTO
): ICashflowNewCommandDTO => { ): ICashflowNewCommandDTO => {
const uncategorizeTransaction = first(uncategorizeTransactions);
const amount = sumBy(uncategorizeTransactions, 'amount');
const amountAbs = Math.abs(amount);
return { return {
date: uncategorizeModel.date, date: categorizeDTO.date,
referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo, referenceNo: categorizeDTO.referenceNo,
description: categorizeDTO.description || uncategorizeModel.description, description: categorizeDTO.description,
cashflowAccountId: uncategorizeModel.accountId, cashflowAccountId: uncategorizeTransaction.accountId,
creditAccountId: categorizeDTO.creditAccountId, creditAccountId: categorizeDTO.creditAccountId,
exchangeRate: categorizeDTO.exchangeRate || 1, exchangeRate: categorizeDTO.exchangeRate || 1,
currencyCode: uncategorizeModel.currencyCode, currencyCode: categorizeDTO.currencyCode,
amount: uncategorizeModel.amount, amount: amountAbs,
transactionNumber: categorizeDTO.transactionNumber, transactionNumber: categorizeDTO.transactionNumber,
transactionType: categorizeDTO.transactionType, transactionType: categorizeDTO.transactionType,
uncategorizedTransactionId: uncategorizeModel.id,
branchId: categorizeDTO?.branchId, branchId: categorizeDTO?.branchId,
publish: true, 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, PopoverInteractionKind,
Position, Position,
Tooltip, Tooltip,
Checkbox,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components'; import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { safeCallback } from '@/utils'; import { safeCallback } from '@/utils';
import {
useAddTransactionsToCategorizeSelected,
useRemoveTransactionsToCategorizeSelected,
} from '@/hooks/state/banking';
export function ActionsMenu({ export function ActionsMenu({
payload: { onUncategorize, onUnmatch }, payload: { onUncategorize, onUnmatch },
@@ -183,6 +188,20 @@ function statusAccessor(transaction) {
* Retrieve account uncategorized transctions table columns. * Retrieve account uncategorized transctions table columns.
*/ */
export function useAccountUncategorizedTransactionsColumns() { 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( return React.useMemo(
() => [ () => [
{ {
@@ -242,6 +261,15 @@ export function useAccountUncategorizedTransactionsColumns() {
align: 'right', align: 'right',
clickable: true, 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, resetUncategorizedTransactionsSelected,
resetExcludedTransactionsSelected, resetExcludedTransactionsSelected,
setExcludedTransactionsSelected, setExcludedTransactionsSelected,
resetTransactionsToCategorizeSelected,
setTransactionsToCategorizeSelected,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps { export interface WithBankingActionsProps {
@@ -23,6 +25,9 @@ export interface WithBankingActionsProps {
setExcludedTransactionsSelected: (ids: Array<string | number>) => void; setExcludedTransactionsSelected: (ids: Array<string | number>) => void;
resetExcludedTransactionsSelected: () => void; resetExcludedTransactionsSelected: () => void;
setTransactionsToCategorizeSelected: (ids: Array<string | number>) => void;
resetTransactionsToCategorizeSelected: () => void;
} }
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -56,6 +61,11 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
), ),
resetExcludedTransactionsSelected: () => resetExcludedTransactionsSelected: () =>
dispatch(resetExcludedTransactionsSelected()), dispatch(resetExcludedTransactionsSelected()),
setTransactionsToCategorizeSelected: (ids: Array<string | number>) =>
dispatch(setTransactionsToCategorizeSelected({ ids })),
resetTransactionsToCategorizeSelected: () =>
dispatch(resetTransactionsToCategorizeSelected()),
}); });
export const withBankingActions = connect< export const withBankingActions = connect<

View File

@@ -1,10 +1,15 @@
import { useDispatch, useSelector } from 'react-redux';
import { useCallback, useMemo } from 'react';
import { import {
getPlaidToken, getPlaidToken,
setPlaidId, setPlaidId,
resetPlaidId, resetPlaidId,
setTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected,
getTransactionsToCategorizeSelected,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
export const useSetBankingPlaidToken = () => { export const useSetBankingPlaidToken = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -30,3 +35,50 @@ export const useResetBankingPlaidToken = () => {
dispatch(resetPlaidId()); dispatch(resetPlaidId());
}, [dispatch]); }, [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'; import { PayloadAction, createSlice } from '@reduxjs/toolkit';
interface StorePlaidState { interface StorePlaidState {
@@ -8,6 +9,7 @@ interface StorePlaidState {
uncategorizedTransactionsSelected: Array<number | string>; uncategorizedTransactionsSelected: Array<number | string>;
excludedTransactionsSelected: Array<number | string>; excludedTransactionsSelected: Array<number | string>;
transactionsToCategorizeSelected: Array<number | string>;
} }
export const PlaidSlice = createSlice({ export const PlaidSlice = createSlice({
@@ -22,6 +24,7 @@ export const PlaidSlice = createSlice({
}, },
uncategorizedTransactionsSelected: [], uncategorizedTransactionsSelected: [],
excludedTransactionsSelected: [], excludedTransactionsSelected: [],
transactionsToCategorizeSelected: [],
} as StorePlaidState, } as StorePlaidState,
reducers: { reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => { setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -79,6 +82,37 @@ export const PlaidSlice = createSlice({
resetExcludedTransactionsSelected: (state: StorePlaidState) => { resetExcludedTransactionsSelected: (state: StorePlaidState) => {
state.excludedTransactionsSelected = []; 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, resetUncategorizedTransactionsSelected,
setExcludedTransactionsSelected, setExcludedTransactionsSelected,
resetExcludedTransactionsSelected, resetExcludedTransactionsSelected,
setTransactionsToCategorizeSelected,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected,
} = PlaidSlice.actions; } = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken; export const getPlaidToken = (state: any) => state.plaid.plaidToken;
export const getTransactionsToCategorizeSelected = (state: any) =>
state.plaid.transactionsToCategorizeSelected;