mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-16 12:50:38 +00:00
feat: bulk categorizing bank transactions
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -236,6 +236,7 @@ export interface ICashflowTransactionSchema {
|
||||
export interface ICashflowTransactionInput extends ICashflowTransactionSchema {}
|
||||
|
||||
export interface ICategorizeCashflowTransactioDTO {
|
||||
date: Date;
|
||||
creditAccountId: number;
|
||||
referenceNo: string;
|
||||
transactionNumber: string;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user