diff --git a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts index 23c392e46..a1db1faf6 100644 --- a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -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 { 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) { diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index a1af70c15..df6c86bcf 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -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.', diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index e6838711c..4fe1e90c1 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -236,6 +236,7 @@ export interface ICashflowTransactionSchema { export interface ICashflowTransactionInput extends ICashflowTransactionSchema {} export interface ICategorizeCashflowTransactioDTO { + date: Date; creditAccountId: number; referenceNo: string; transactionNumber: string; diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index 99977c31d..66b258406 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -130,14 +130,15 @@ export interface ICommandCashflowDeletedPayload { export interface ICashflowTransactionCategorizedPayload { tenantId: number; - uncategorizedTransaction: any; + uncategorizedTransactions: any; cashflowTransaction: ICashflowTransaction; + oldUncategorizedTransactions: Array; categorizeDTO: any; trx: Knex.Transaction; } export interface ICashflowTransactionUncategorizingPayload { tenantId: number; - uncategorizedTransaction: IUncategorizedCashflowTransaction; + oldUncategorizedTransactions: Array; trx: Knex.Transaction; } export interface ICashflowTransactionUncategorizedPayload { diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts index ad036fa98..df35a9d9b 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts @@ -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} uncategorizedTransactionIds * @param {IMatchTransactionDTO} matchTransactionDTO * @param {Knex.Transaction} trx */ public async createMatchedTransaction( tenantId: number, - uncategorizedTransactionId: number, + uncategorizedTransactionIds: Array, 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, + }); + }); } } diff --git a/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts index 61884190f..39702c4bb 100644 --- a/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts +++ b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts @@ -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, + matchedTransactions: Array ): Promise { return this.matchTransactionService.matchTransaction( tenantId, uncategorizedTransactionId, - matchTransactionsDTO + matchedTransactions ); } diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index a85fb6a98..9ec128831 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -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, + matchedTransactions: Array ) { 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, + matchedTransactions: Array ): Promise { - 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); }); diff --git a/packages/server/src/services/Banking/Matching/_utils.ts b/packages/server/src/services/Banking/Matching/_utils.ts index 67e7b0042..b78045904 100644 --- a/packages/server/src/services/Banking/Matching/_utils.ts +++ b/packages/server/src/services/Banking/Matching/_utils.ts @@ -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) => { 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); + } +}; diff --git a/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts index 8cd291f18..8acda3d4d 100644 --- a/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts +++ b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts @@ -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); + }); } /** diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts index d415b6478..ff295bd9d 100644 --- a/packages/server/src/services/Banking/Matching/types.ts +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -2,15 +2,15 @@ import { Knex } from 'knex'; export interface IBankTransactionMatchingEventPayload { tenantId: number; - uncategorizedTransactionId: number; - matchTransactionsDTO: IMatchTransactionsDTO; + uncategorizedTransactionIds: Array; + matchedTransactions: Array; trx?: Knex.Transaction; } export interface IBankTransactionMatchedEventPayload { tenantId: number; - uncategorizedTransactionId: number; - matchTransactionsDTO: IMatchTransactionsDTO; + uncategorizedTransactionIds: Array; + matchedTransactions: Array; trx?: Knex.Transaction; } @@ -32,6 +32,7 @@ export interface IMatchTransactionDTO { } export interface IMatchTransactionsDTO { + uncategorizedTransactionIds: Array; matchedTransactions: Array; } diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts index f534e706e..f54935db7 100644 --- a/packages/server/src/services/Cashflow/CashflowApplication.ts +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -164,12 +164,12 @@ export class CashflowApplication { */ public categorizeTransaction( tenantId: number, - cashflowTransactionId: number, + uncategorizeTransactionIds: Array, categorizeDTO: ICategorizeCashflowTransactioDTO ) { return this.categorizeTransactionService.categorize( tenantId, - cashflowTransactionId, + uncategorizeTransactionIds, categorizeDTO ); } diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts index 43ddfe603..3a4977f48 100644 --- a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -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, 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 diff --git a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts index 7a6c5c973..cec6ac4ca 100644 --- a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts +++ b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts @@ -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 ) { - 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 && diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 8258abb28..2cc80c041 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -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, 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 +) => { + const excluded = transactions.filter((tran) => tran.excluded); + + if (excluded?.length > 0) { + throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION); + } +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 2edf0c423..00981d38e 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -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) => , + width: 10, + minWidth: 10, + maxWidth: 10, + align: 'right', + }, ], [], ); diff --git a/packages/webapp/src/containers/CashFlow/withBankingActions.ts b/packages/webapp/src/containers/CashFlow/withBankingActions.ts index 4cb4e281e..b8fb49e31 100644 --- a/packages/webapp/src/containers/CashFlow/withBankingActions.ts +++ b/packages/webapp/src/containers/CashFlow/withBankingActions.ts @@ -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) => void; resetExcludedTransactionsSelected: () => void; + + setTransactionsToCategorizeSelected: (ids: Array) => void; + resetTransactionsToCategorizeSelected: () => void; } const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ @@ -56,6 +61,11 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ ), resetExcludedTransactionsSelected: () => dispatch(resetExcludedTransactionsSelected()), + + setTransactionsToCategorizeSelected: (ids: Array) => + dispatch(setTransactionsToCategorizeSelected({ ids })), + resetTransactionsToCategorizeSelected: () => + dispatch(resetTransactionsToCategorizeSelected()), }); export const withBankingActions = connect< diff --git a/packages/webapp/src/hooks/state/banking.ts b/packages/webapp/src/hooks/state/banking.ts index 9b6b356ca..a6e007e01 100644 --- a/packages/webapp/src/hooks/state/banking.ts +++ b/packages/webapp/src/hooks/state/banking.ts @@ -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) => { + 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]); +}; diff --git a/packages/webapp/src/store/banking/banking.reducer.ts b/packages/webapp/src/store/banking/banking.reducer.ts index d2887076c..1d9720cb0 100644 --- a/packages/webapp/src/store/banking/banking.reducer.ts +++ b/packages/webapp/src/store/banking/banking.reducer.ts @@ -1,3 +1,4 @@ +import { uniq } from 'lodash'; import { PayloadAction, createSlice } from '@reduxjs/toolkit'; interface StorePlaidState { @@ -8,6 +9,7 @@ interface StorePlaidState { uncategorizedTransactionsSelected: Array; excludedTransactionsSelected: Array; + transactionsToCategorizeSelected: Array; } export const PlaidSlice = createSlice({ @@ -22,6 +24,7 @@ export const PlaidSlice = createSlice({ }, uncategorizedTransactionsSelected: [], excludedTransactionsSelected: [], + transactionsToCategorizeSelected: [], } as StorePlaidState, reducers: { setPlaidId: (state: StorePlaidState, action: PayloadAction) => { @@ -79,6 +82,37 @@ export const PlaidSlice = createSlice({ resetExcludedTransactionsSelected: (state: StorePlaidState) => { state.excludedTransactionsSelected = []; }, + + setTransactionsToCategorizeSelected: ( + state: StorePlaidState, + action: PayloadAction<{ ids: Array }>, + ) => { + 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;