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/Banking/BankingController.ts b/packages/server/src/api/controllers/Banking/BankingController.ts index 30695b19d..69933f16d 100644 --- a/packages/server/src/api/controllers/Banking/BankingController.ts +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -6,6 +6,7 @@ import { BankingRulesController } from './BankingRulesController'; import { BankTransactionsMatchingController } from './BankTransactionsMatchingController'; import { RecognizedTransactionsController } from './RecognizedTransactionsController'; import { BankAccountsController } from './BankAccountsController'; +import { BankingUncategorizedController } from './BankingUncategorizedController'; @Service() export class BankingController extends BaseController { @@ -29,6 +30,10 @@ export class BankingController extends BaseController { '/bank_accounts', Container.get(BankAccountsController).router() ); + router.use( + '/categorize', + Container.get(BankingUncategorizedController).router() + ); return router; } } diff --git a/packages/server/src/api/controllers/Banking/BankingUncategorizedController.ts b/packages/server/src/api/controllers/Banking/BankingUncategorizedController.ts new file mode 100644 index 000000000..396ccbcda --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankingUncategorizedController.ts @@ -0,0 +1,57 @@ +import { Inject, Service } from 'typedi'; +import { NextFunction, Request, Response, Router } from 'express'; +import { query } from 'express-validator'; +import BaseController from '../BaseController'; +import { GetAutofillCategorizeTransaction } from '@/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransaction'; + +@Service() +export class BankingUncategorizedController extends BaseController { + @Inject() + private getAutofillCategorizeTransactionService: GetAutofillCategorizeTransaction; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/autofill', + [ + query('uncategorizedTransactionIds').isArray({ min: 1 }), + query('uncategorizedTransactionIds.*').isNumeric().toInt(), + ], + this.validationResult, + this.getAutofillCategorizeTransaction.bind(this) + ); + return router; + } + + /** + * Retrieves the autofill values of the categorize form of the given + * uncategorized transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + public async getAutofillCategorizeTransaction( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const uncategorizedTransactionIds = req.query.uncategorizedTransactionIds; + + try { + const data = + await this.getAutofillCategorizeTransactionService.getAutofillCategorizeTransaction( + tenantId, + uncategorizedTransactionIds + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 6b0be76e2..c7d57105c 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -1,6 +1,6 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { param } from 'express-validator'; +import { param, query } from 'express-validator'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; @@ -24,7 +24,12 @@ export default class GetCashflowAccounts extends BaseController { const router = Router(); router.get( - '/transactions/:transactionId/matches', + '/transactions/matches', + [ + query('uncategorizeTransactionsIds').exists().isArray({ min: 1 }), + query('uncategorizeTransactionsIds.*').exists().isNumeric().toInt(), + ], + this.validationResult, this.getMatchedTransactions.bind(this) ); router.get( @@ -44,7 +49,7 @@ export default class GetCashflowAccounts extends BaseController { * @param {NextFunction} next */ private getCashflowTransaction = async ( - req: Request, + req: Request<{ transactionId: number }>, res: Response, next: NextFunction ) => { @@ -71,19 +76,24 @@ export default class GetCashflowAccounts extends BaseController { * @param {NextFunction} next */ private async getMatchedTransactions( - req: Request<{ transactionId: number }>, + req: Request< + { transactionId: number }, + null, + null, + { uncategorizeTransactionsIds: Array } + >, res: Response, next: NextFunction ) { const { tenantId } = req; - const { transactionId } = req.params; + const uncategorizeTransactionsIds = req.query.uncategorizeTransactionsIds; const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter; try { const data = await this.bankTransactionsMatchingApp.getMatchedTransactions( tenantId, - transactionId, + uncategorizeTransactionsIds, filter ); return res.status(200).send(data); diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index a1af70c15..5e204ca12 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(), @@ -161,7 +167,7 @@ export default class NewCashflowTransactionController extends BaseController { * @param {NextFunction} next */ private revertCategorizedCashflowTransaction = async ( - req: Request, + req: Request<{ id: number }>, res: Response, next: NextFunction ) => { @@ -191,14 +197,19 @@ 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.', @@ -269,7 +280,7 @@ export default class NewCashflowTransactionController extends BaseController { * @param {NextFunction} next */ public getUncategorizedCashflowTransactions = async ( - req: Request, + req: Request<{ id: number }>, res: Response, next: NextFunction ) => { 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..c1f55a86f 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -130,20 +130,23 @@ export interface ICommandCashflowDeletedPayload { export interface ICashflowTransactionCategorizedPayload { tenantId: number; - uncategorizedTransaction: any; + uncategorizedTransactions: Array; cashflowTransaction: ICashflowTransaction; + oldUncategorizedTransactions: Array; categorizeDTO: any; trx: Knex.Transaction; } export interface ICashflowTransactionUncategorizingPayload { tenantId: number; - uncategorizedTransaction: IUncategorizedCashflowTransaction; + uncategorizedTransactionId: number; + oldUncategorizedTransactions: Array; trx: Knex.Transaction; } export interface ICashflowTransactionUncategorizedPayload { tenantId: number; - uncategorizedTransaction: IUncategorizedCashflowTransaction; - oldUncategorizedTransaction: IUncategorizedCashflowTransaction; + uncategorizedTransactionId: number; + uncategorizedTransactions: Array; + oldUncategorizedTransactions: Array; trx: Knex.Transaction; } diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 029825583..2f3f0a50e 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -31,7 +31,7 @@ export default class UncategorizedCashflowTransaction extends mixin( /** * Timestamps columns. */ - static get timestamps() { + get timestamps() { return ['createdAt', 'updatedAt']; } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts index f0cfdcaed..c7caf1325 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -1,6 +1,7 @@ import { Inject, Service } from 'typedi'; import * as R from 'ramda'; import moment from 'moment'; +import { first, sumBy } from 'lodash'; import { PromisePool } from '@supercharge/promise-pool'; import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types'; import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; @@ -47,21 +48,24 @@ export class GetMatchedTransactions { /** * Retrieves the matched transactions. * @param {number} tenantId - + * @param {Array} uncategorizedTransactionIds - Uncategorized transactions ids. * @param {GetMatchedTransactionsFilter} filter - * @returns {Promise} */ public async getMatchedTransactions( tenantId: number, - uncategorizedTransactionId: number, + uncategorizedTransactionIds: Array, filter: GetMatchedTransactionsFilter ): Promise { const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); - const uncategorizedTransaction = + const uncategorizedTransactions = await UncategorizedCashflowTransaction.query() - .findById(uncategorizedTransactionId) + .whereIn('id', uncategorizedTransactionIds) .throwIfNotFound(); + const totalPending = Math.abs(sumBy(uncategorizedTransactions, 'amount')); + const filtered = filter.transactionType ? this.registered.filter((item) => item.type === filter.transactionType) : this.registered; @@ -71,14 +75,14 @@ export class GetMatchedTransactions { .process(async ({ type, service }) => { return service.getMatchedTransactions(tenantId, filter); }); - const { perfectMatches, possibleMatches } = this.groupMatchedResults( - uncategorizedTransaction, + uncategorizedTransactions, matchedTransactions ); return { perfectMatches, possibleMatches, + totalPending, }; } @@ -90,20 +94,20 @@ export class GetMatchedTransactions { * @returns {MatchedTransactionsPOJO} */ private groupMatchedResults( - uncategorizedTransaction, + uncategorizedTransactions: Array, matchedTransactions ): MatchedTransactionsPOJO { const results = R.compose(R.flatten)(matchedTransactions?.results); + const firstUncategorized = first(uncategorizedTransactions); + const amount = sumBy(uncategorizedTransactions, 'amount'); + const date = firstUncategorized.date; + // Sort the results based on amount, date, and transaction type - const closestResullts = sortClosestMatchTransactions( - uncategorizedTransaction, - results - ); + const closestResullts = sortClosestMatchTransactions(amount, date, results); const perfectMatches = R.filter( (match) => - match.amount === uncategorizedTransaction.amount && - moment(match.date).isSame(uncategorizedTransaction.date, 'day'), + match.amount === amount && moment(match.date).isSame(date, 'day'), closestResullts ); const possibleMatches = R.difference(closestResullts, perfectMatches); diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts index ad036fa98..5d39c0740 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() @@ -43,24 +44,28 @@ export abstract class GetMatchedTransactionsByType { } /** - * + * Creates the common matched transaction. * @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..dd60c166c 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 { @@ -23,12 +23,12 @@ export class MatchBankTransactionsApplication { */ public getMatchedTransactions( tenantId: number, - uncategorizedTransactionId: number, + uncategorizedTransactionsIds: Array, filter: GetMatchedTransactionsFilter ) { return this.getMatchedTransactionsService.getMatchedTransactions( tenantId, - uncategorizedTransactionId, + uncategorizedTransactionsIds, filter ); } @@ -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..5a28dd0b1 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,16 @@ import { ERRORS, IBankTransactionMatchedEventPayload, IBankTransactionMatchingEventPayload, - IMatchTransactionsDTO, + IMatchTransactionDTO, } from './types'; import { MatchTransactionsTypes } from './MatchTransactionsTypes'; import { ServiceError } from '@/exceptions'; -import { sumMatchTranasctions } from './_utils'; +import { + sumMatchTranasctions, + sumUncategorizedTransactions, + validateUncategorizedTransactionsExcluded, + validateUncategorizedTransactionsNotMatched, +} from './_utils'; @Service() export class MatchBankTransactions { @@ -39,27 +44,25 @@ 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) + .whereIn('id', uncategorizedTransactionIds) .withGraphFetched('matchedBankTransactions') .throwIfNotFound(); // 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 = @@ -94,9 +97,12 @@ export class MatchBankTransactions { const totalMatchedTranasctions = sumMatchTranasctions( validatationResult.results ); + const totalUncategorizedTransactions = sumUncategorizedTransactions( + uncategorizedTransactions + ); // Validates the total given matching transcations whether is not equal // uncategorized transaction amount. - if (totalMatchedTranasctions !== uncategorizedTransaction.amount) { + if (totalUncategorizedTransactions !== totalMatchedTranasctions) { throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID); } } @@ -109,23 +115,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 +145,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..46a31729c 100644 --- a/packages/server/src/services/Banking/Matching/_utils.ts +++ b/packages/server/src/services/Banking/Matching/_utils.ts @@ -1,22 +1,23 @@ 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, sumBy } from 'lodash'; +import { ServiceError } from '@/exceptions'; export const sortClosestMatchTransactions = ( - uncategorizedTransaction: UncategorizedCashflowTransaction, + amount: number, + date: Date, matches: MatchedTransactionPOJO[] ) => { return R.sortWith([ // Sort by amount difference (closest to uncategorized transaction amount first) R.ascend((match: MatchedTransactionPOJO) => - Math.abs(match.amount - uncategorizedTransaction.amount) + Math.abs(match.amount - amount) ), // Sort by date difference (closest to uncategorized transaction date first) R.ascend((match: MatchedTransactionPOJO) => - Math.abs( - moment(match.date).diff(moment(uncategorizedTransaction.date), 'days') - ) + Math.abs(moment(match.date).diff(moment(date), 'days')) ), ])(matches); }; @@ -29,3 +30,36 @@ export const sumMatchTranasctions = (transactions: Array) => { 0 ); }; + +export const sumUncategorizedTransactions = ( + uncategorizedTransactions: Array +) => { + return sumBy(uncategorizedTransactions, 'amount'); +}; + +export const validateUncategorizedTransactionsNotMatched = ( + uncategorizedTransactions: any +) => { + const matchedTransactions = uncategorizedTransactions.filter( + (trans) => !isEmpty(trans.matchedBankTransactions) + ); + // + if (matchedTransactions.length > 0) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED, '', { + matchedTransactionsIds: matchedTransactions?.map((m) => m.id), + }); + } +}; + +export const validateUncategorizedTransactionsExcluded = ( + uncategorizedTransactions: any +) => { + const excludedTransactions = uncategorizedTransactions.filter( + (trans) => trans.excluded + ); + if (excludedTransactions.length > 0) { + throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION, '', { + excludedTransactionsIds: excludedTransactions.map((e) => e.id), + }); + } +}; diff --git a/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts index 8cd291f18..5e37d71d1 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,24 @@ 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 - ); - await Account.query(trx) - .findById(transaction.accountId) - .decrement('uncategorizedTransactions', 1); + const uncategorizedTransactions = + await UncategorizedCashflowTransaction.query().whereIn( + 'id', + uncategorizedTransactionIds + ); + await PromisePool.withConcurrency(1) + .for(uncategorizedTransactions) + .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..707c0fe0c 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; } @@ -57,6 +58,7 @@ export interface MatchedTransactionPOJO { export type MatchedTransactionsPOJO = { perfectMatches: Array; possibleMatches: Array; + totalPending: number; }; export const ERRORS = { diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransaction.ts b/packages/server/src/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransaction.ts new file mode 100644 index 000000000..e31402fad --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransaction.ts @@ -0,0 +1,45 @@ +import { Inject, Service } from 'typedi'; +import { castArray, first, uniq } from 'lodash'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetAutofillCategorizeTransctionTransformer } from './GetAutofillCategorizeTransactionTransformer'; + +@Service() +export class GetAutofillCategorizeTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the autofill values of categorize transactions form. + * @param {number} tenantId - Tenant id. + * @param {Array | number} uncategorizeTransactionsId - Uncategorized transactions ids. + */ + public async getAutofillCategorizeTransaction( + tenantId: number, + uncategorizeTransactionsId: Array | number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + const uncategorizeTransactionsIds = uniq( + castArray(uncategorizeTransactionsId) + ); + const uncategorizedTransactions = + await UncategorizedCashflowTransaction.query() + .whereIn('id', uncategorizeTransactionsIds) + .withGraphFetched('recognizedTransaction.assignAccount') + .withGraphFetched('recognizedTransaction.bankRule') + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + {}, + new GetAutofillCategorizeTransctionTransformer(), + { + uncategorizedTransactions, + firstUncategorizedTransaction: first(uncategorizedTransactions), + } + ); + } +} diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransactionTransformer.ts b/packages/server/src/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransactionTransformer.ts new file mode 100644 index 000000000..46a142e48 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransactionTransformer.ts @@ -0,0 +1,174 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { sumBy } from 'lodash'; + +export class GetAutofillCategorizeTransctionTransformer extends Transformer { + /** + * Included attributes to the object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'amount', + 'formattedAmount', + 'isRecognized', + 'date', + 'formattedDate', + 'creditAccountId', + 'debitAccountId', + 'referenceNo', + 'transactionType', + 'recognizedByRuleId', + 'recognizedByRuleName', + 'isWithdrawalTransaction', + 'isDepositTransaction', + ]; + }; + + /** + * Detarmines whether the transaction is recognized. + * @returns {boolean} + */ + public isRecognized() { + return !!this.options.firstUncategorizedTransaction?.recognizedTransaction; + } + + /** + * Retrieves the total amount of uncategorized transactions. + * @returns {number} + */ + public amount() { + return sumBy(this.options.uncategorizedTransactions, 'amount'); + } + + /** + * Retrieves the formatted total amount of uncategorized transactions. + * @returns {string} + */ + public formattedAmount() { + return this.formatNumber(this.amount(), { + currencyCode: 'USD', + money: true, + }); + } + + /** + * Detarmines whether the transaction is deposit. + * @returns {boolean} + */ + public isDepositTransaction() { + const amount = this.amount(); + + return amount > 0; + } + + /** + * Detarmines whether the transaction is withdrawal. + * @returns {boolean} + */ + public isWithdrawalTransaction() { + const amount = this.amount(); + + return amount < 0; + } + + /** + * + * @param {string} + */ + public date() { + return this.options.firstUncategorizedTransaction?.date || null; + } + + /** + * Retrieves the formatted date of uncategorized transaction. + * @returns {string} + */ + public formattedDate() { + return this.formatDate(this.date()); + } + + /** + * + * @param {string} + */ + public referenceNo() { + return this.options.firstUncategorizedTransaction?.referenceNo || null; + } + + /** + * + * @returns {number} + */ + public creditAccountId() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignedAccountId || null + ); + } + + /** + * + * @returns {number} + */ + public debitAccountId() { + return this.options.firstUncategorizedTransaction?.accountId || null; + } + + /** + * + * @returns {string} + */ + public transactionType() { + const assignCategory = + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignCategory || null; + + return assignCategory || this.isDepositTransaction() + ? 'other_income' + : 'other_expense'; + } + + /** + * + * @returns {string} + */ + public payee() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignedPayee || null + ); + } + + /** + * + * @returns {string} + */ + public memo() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignedMemo || null + ); + } + + /** + * Retrieves the rule id the transaction recongized by. + * @returns {string} + */ + public recognizedByRuleId() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.bankRuleId || null + ); + } + + /** + * Retrieves the rule name the transaction recongized by. + * @returns {string} + */ + public recognizedByRuleName() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.bankRule?.name || null + ); + } +} diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts index 5582652d8..f7e81d4f6 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -1,8 +1,8 @@ import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { transformToMapBy } from '@/utils'; -import { Inject, Service } from 'typedi'; import { PromisePool } from '@supercharge/promise-pool'; import { BankRule } from '@/models/BankRule'; import { bankRulesMatchTransaction } from './_utils'; 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..8a02556a3 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,27 +41,29 @@ 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( + oldUncategorizedTransactions + ); // 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, + oldUncategorizedTransactions, categorizeDTO.transactionType ); // Edits the cashflow transaction under UOW env. @@ -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. @@ -83,15 +88,20 @@ export class CategorizeCashflowTransaction { tenantId, 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( @@ -99,7 +109,8 @@ export class CategorizeCashflowTransaction { { 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..fc2871c28 100644 --- a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts +++ b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { includes, camelCase, upperFirst } from 'lodash'; +import { includes, camelCase, upperFirst, sumBy } from 'lodash'; import { IAccount, IUncategorizedCashflowTransaction } from '@/interfaces'; import { getCashflowTransactionType } from './utils'; import { ServiceError } from '@/exceptions'; @@ -68,11 +68,15 @@ 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) { - throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + const categorized = cashflowTransactions.filter((t) => t.categorized); + + if (categorized?.length > 0) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED, '', { + ids: categorized.map((t) => t.id), + }); } } @@ -83,17 +87,19 @@ export class CommandCashflowValidator { * @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)} */ public validateUncategorizeTransactionType( - uncategorizeTransaction: IUncategorizedCashflowTransaction, + uncategorizeTransactions: Array, transactionType: string ) { + const amount = sumBy(uncategorizeTransactions, 'amount'); + const isDepositTransaction = amount > 0; + const isWithdrawalTransaction = amount <= 0; + const type = getCashflowTransactionType( - upperFirst(camelCase(transactionType)) as CASHFLOW_TRANSACTION_TYPE + transactionType as CASHFLOW_TRANSACTION_TYPE ); if ( - (type.direction === CASHFLOW_DIRECTION.IN && - uncategorizeTransaction.isDepositTransaction) || - (type.direction === CASHFLOW_DIRECTION.OUT && - uncategorizeTransaction.isWithdrawalTransaction) + (type.direction === CASHFLOW_DIRECTION.IN && isDepositTransaction) || + (type.direction === CASHFLOW_DIRECTION.OUT && isWithdrawalTransaction) ) { return; } diff --git a/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts index ba6740685..963bc80a2 100644 --- a/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts +++ b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts @@ -8,6 +8,7 @@ import { ICashflowTransactionUncategorizedPayload, ICashflowTransactionUncategorizingPayload, } from '@/interfaces'; +import { validateTransactionShouldBeCategorized } from './utils'; @Service() export class UncategorizeCashflowTransaction { @@ -24,11 +25,12 @@ export class UncategorizeCashflowTransaction { * Uncategorizes the given cashflow transaction. * @param {number} tenantId * @param {number} cashflowTransactionId + * @returns {Promise>} */ public async uncategorize( tenantId: number, uncategorizedTransactionId: number - ) { + ): Promise> { const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const oldUncategorizedTransaction = @@ -36,6 +38,22 @@ export class UncategorizeCashflowTransaction { .findById(uncategorizedTransactionId) .throwIfNotFound(); + validateTransactionShouldBeCategorized(oldUncategorizedTransaction); + + const associatedUncategorizedTransactions = + await UncategorizedCashflowTransaction.query() + .where('categorizeRefId', oldUncategorizedTransaction.categorizeRefId) + .where( + 'categorizeRefType', + oldUncategorizedTransaction.categorizeRefType + ); + const oldUncategorizedTransactions = [ + oldUncategorizedTransaction, + ...associatedUncategorizedTransactions, + ]; + const oldUncategoirzedTransactionsIds = oldUncategorizedTransactions.map( + (t) => t.id + ); // Updates the transaction under UOW. return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { // Triggers `onTransactionUncategorizing` event. @@ -43,30 +61,36 @@ export class UncategorizeCashflowTransaction { events.cashflow.onTransactionUncategorizing, { tenantId, + uncategorizedTransactionId, + oldUncategorizedTransactions, trx, } as ICashflowTransactionUncategorizingPayload ); // Removes the ref relation with the related transaction. - const uncategorizedTransaction = - await UncategorizedCashflowTransaction.query(trx).updateAndFetchById( - uncategorizedTransactionId, - { - categorized: false, - categorizeRefId: null, - categorizeRefType: null, - } + await UncategorizedCashflowTransaction.query(trx) + .whereIn('id', oldUncategoirzedTransactionsIds) + .patch({ + categorized: false, + categorizeRefId: null, + categorizeRefType: null, + }); + const uncategorizedTransactions = + await UncategorizedCashflowTransaction.query(trx).whereIn( + 'id', + oldUncategoirzedTransactionsIds ); // Triggers `onTransactionUncategorized` event. await this.eventPublisher.emitAsync( events.cashflow.onTransactionUncategorized, { tenantId, - uncategorizedTransaction, - oldUncategorizedTransaction, + uncategorizedTransactionId, + uncategorizedTransactions, + oldUncategorizedTransactions, trx, } as ICashflowTransactionUncategorizedPayload ); - return uncategorizedTransaction; + return oldUncategoirzedTransactionsIds; }); } } diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index 28768c85f..f9b8e24e8 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -16,7 +16,9 @@ export const ERRORS = { CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED', - CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION: 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION' + CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION: 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION', + TRANSACTION_NOT_CATEGORIZED: 'TRANSACTION_NOT_CATEGORIZED' + }; export enum CASHFLOW_DIRECTION { diff --git a/packages/server/src/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize.ts b/packages/server/src/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize.ts index df68c36de..a1570a390 100644 --- a/packages/server/src/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize.ts +++ b/packages/server/src/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize.ts @@ -5,6 +5,7 @@ import { ICashflowTransactionCategorizedPayload, ICashflowTransactionUncategorizedPayload, } from '@/interfaces'; +import PromisePool from '@supercharge/promise-pool'; @Service() export class DecrementUncategorizedTransactionOnCategorize { @@ -34,13 +35,18 @@ export class DecrementUncategorizedTransactionOnCategorize { */ public async decrementUnCategorizedTransactionsOnCategorized({ tenantId, - uncategorizedTransaction, + uncategorizedTransactions, + trx }: ICashflowTransactionCategorizedPayload) { const { Account } = this.tenancy.models(tenantId); - await Account.query() - .findById(uncategorizedTransaction.accountId) - .decrement('uncategorizedTransactions', 1); + await PromisePool.withConcurrency(1) + .for(uncategorizedTransactions) + .process(async (uncategorizedTransaction) => { + await Account.query(trx) + .findById(uncategorizedTransaction.accountId) + .decrement('uncategorizedTransactions', 1); + }); } /** @@ -49,13 +55,18 @@ export class DecrementUncategorizedTransactionOnCategorize { */ public async incrementUnCategorizedTransactionsOnUncategorized({ tenantId, - uncategorizedTransaction, + uncategorizedTransactions, + trx }: ICashflowTransactionUncategorizedPayload) { const { Account } = this.tenancy.models(tenantId); - await Account.query() - .findById(uncategorizedTransaction.accountId) - .increment('uncategorizedTransactions', 1); + await PromisePool.withConcurrency(1) + .for(uncategorizedTransactions) + .process(async (uncategorizedTransaction) => { + await Account.query(trx) + .findById(uncategorizedTransaction.accountId) + .increment('uncategorizedTransactions', 1); + }); } /** diff --git a/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts index 9460cc739..4bbdc3fac 100644 --- a/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts +++ b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts @@ -1,8 +1,10 @@ import { Inject, Service } from 'typedi'; +import { PromisePool } from '@supercharge/promise-pool'; import events from '@/subscribers/events'; import { ICashflowTransactionUncategorizedPayload } from '@/interfaces'; import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { ServiceError } from '@/exceptions'; @Service() export class DeleteCashflowTransactionOnUncategorize { @@ -25,18 +27,27 @@ export class DeleteCashflowTransactionOnUncategorize { */ public async deleteCashflowTransactionOnUncategorize({ tenantId, - oldUncategorizedTransaction, + oldUncategorizedTransactions, trx, }: ICashflowTransactionUncategorizedPayload) { - // Deletes the cashflow transaction. - if ( - oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction' - ) { - await this.deleteCashflowTransactionService.deleteCashflowTransaction( - tenantId, + const _oldUncategorizedTransactions = oldUncategorizedTransactions.filter( + (transaction) => transaction.categorizeRefType === 'CashflowTransaction' + ); - oldUncategorizedTransaction.categorizeRefId - ); + // Deletes the cashflow transaction. + if (_oldUncategorizedTransactions.length > 0) { + const result = await PromisePool.withConcurrency(1) + .for(_oldUncategorizedTransactions) + .process(async (oldUncategorizedTransaction) => { + await this.deleteCashflowTransactionService.deleteCashflowTransaction( + tenantId, + oldUncategorizedTransaction.categorizeRefId, + trx + ); + }); + if (result.errors.length > 0) { + throw new ServiceError('SOMETHING_WRONG'); + } } } } diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 8258abb28..98918de9b 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -1,7 +1,8 @@ -import { upperFirst, camelCase } from 'lodash'; +import { upperFirst, camelCase, first, sum, sumBy } from 'lodash'; import { CASHFLOW_TRANSACTION_TYPE, CASHFLOW_TRANSACTION_TYPE_META, + ERRORS, ICashflowTransactionTypeMeta, } from './constants'; import { @@ -9,6 +10,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 +30,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 +51,46 @@ 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, '', { + ids: excluded.map((t) => t.id), + }); + } +}; + + +export const validateTransactionShouldBeCategorized = ( + uncategorizedTransaction: any +) => { + if (!uncategorizedTransaction.categorized) { + throw new ServiceError(ERRORS.TRANSACTION_NOT_CATEGORIZED); + } +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index 994b2c6e0..cf267da57 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -12,6 +12,7 @@ import { PopoverInteractionKind, Position, Intent, + Switch, Tooltip, MenuDivider, } from '@blueprintjs/core'; @@ -44,6 +45,7 @@ import { useExcludeUncategorizedTransactions, useUnexcludeUncategorizedTransactions, } from '@/hooks/query/bank-rules'; +import { withBankingActions } from '../withBankingActions'; import { withBanking } from '../withBanking'; import withAlertActions from '@/containers/Alert/withAlertActions'; import { DialogsName } from '@/constants/dialogs'; @@ -61,6 +63,10 @@ function AccountTransactionsActionsBar({ // #withBanking uncategorizedTransationsIdsSelected, excludedTransactionsIdsSelected, + openMatchingTransactionAside, + + // #withBankingActions + enableMultipleCategorization, // #withAlerts openAlert, @@ -185,6 +191,10 @@ function AccountTransactionsActionsBar({ }); }; + // Handle multi select transactions for categorization or matching. + const handleMultipleCategorizingSwitch = (event) => { + enableMultipleCategorization(event.currentTarget.checked); + } // Handle resume bank feeds syncing. const handleResumeFeedsSyncing = () => { openAlert('resume-feeds-syncing-bank-accounnt', { @@ -290,6 +300,22 @@ function AccountTransactionsActionsBar({ + {openMatchingTransactionAside && ( + + + + )} + ({ uncategorizedTransationsIdsSelected, excludedTransactionsIdsSelected, + openMatchingTransactionAside, }), ), + withBankingActions, )(AccountTransactionsActionsBar); diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountTransactionsUncategorizedTable.module.scss b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountTransactionsUncategorizedTable.module.scss new file mode 100644 index 000000000..875ab6a14 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountTransactionsUncategorizedTable.module.scss @@ -0,0 +1,15 @@ + + +.table :global .td.categorize_include, +.table :global .th.categorize_include { + display: none; +} + +.table.showCategorizeColumn :global .td.categorize_include, +.table.showCategorizeColumn :global .th.categorize_include { + display: flex; +} + +.categorizeCheckbox:global(.bp4-checkbox) :global .bp4-control-indicator { + border-radius: 20px; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountTransactionsUncategorizedTable.tsx similarity index 78% rename from packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx rename to packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountTransactionsUncategorizedTable.tsx index 0d36596d3..d3c575898 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountTransactionsUncategorizedTable.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import React from 'react'; +import clsx from 'classnames'; import styled from 'styled-components'; import { Intent } from '@blueprintjs/core'; import { @@ -12,17 +13,19 @@ import { AppToaster, } from '@/components'; import { TABLES } from '@/constants/tables'; -import { ActionsMenu } from './UncategorizedTransactions/components'; +import { ActionsMenu } from './components'; import withSettings from '@/containers/Settings/withSettings'; -import { withBankingActions } from '../withBankingActions'; +import { withBankingActions } from '../../withBankingActions'; import { useMemorizedColumnsWidths } from '@/hooks'; -import { useAccountUncategorizedTransactionsColumns } from './components'; -import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot'; +import { useAccountUncategorizedTransactionsContext } from '../AllTransactionsUncategorizedBoot'; import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; +import { useAccountUncategorizedTransactionsColumns } from './hooks'; import { compose } from '@/utils'; +import { withBanking } from '../../withBanking'; +import styles from './AccountTransactionsUncategorizedTable.module.scss'; /** * Account transactions data table. @@ -31,9 +34,16 @@ function AccountTransactionsDataTable({ // #withSettings cashflowTansactionsTableSize, + // #withBanking + openMatchingTransactionAside, + enableMultipleCategorization, + // #withBankingActions setUncategorizedTransactionIdForMatching, setUncategorizedTransactionsSelected, + + addTransactionsToCategorizeSelected, + setTransactionsToCategorizeSelected, }) { // Retrieve table columns. const columns = useAccountUncategorizedTransactionsColumns(); @@ -51,7 +61,11 @@ function AccountTransactionsDataTable({ // Handle cell click. const handleCellClick = (cell) => { - setUncategorizedTransactionIdForMatching(cell.row.original.id); + if (enableMultipleCategorization) { + addTransactionsToCategorizeSelected(cell.row.original.id); + } else { + setTransactionsToCategorizeSelected(cell.row.original.id); + } }; // Handles categorize button click. const handleCategorizeBtnClick = (transaction) => { @@ -66,7 +80,7 @@ function AccountTransactionsDataTable({ message: 'The bank transaction has been excluded successfully.', }); }) - .catch((error) => { + .catch(() => { AppToaster.show({ intent: Intent.DANGER, message: 'Something went wrong.', @@ -74,12 +88,6 @@ function AccountTransactionsDataTable({ }); }; - // Handle selected rows change. - const handleSelectedRowsChange = (selected) => { - const _selectedIds = selected?.map((row) => row.original.id); - setUncategorizedTransactionsSelected(_selectedIds); - }; - return ( ); } @@ -121,6 +130,12 @@ export default compose( cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, })), withBankingActions, + withBanking( + ({ openMatchingTransactionAside, enableMultipleCategorization }) => ({ + openMatchingTransactionAside, + enableMultipleCategorization, + }), + ), )(AccountTransactionsDataTable); const DashboardConstrantTable = styled(DataTable)` diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountUncategorizedTransactionsAll.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountUncategorizedTransactionsAll.tsx index 8a3ce66f9..01c1e2f0d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountUncategorizedTransactionsAll.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/AccountUncategorizedTransactionsAll.tsx @@ -1,6 +1,6 @@ import * as R from 'ramda'; import { useEffect } from 'react'; -import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable'; +import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable'; import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot'; import { AccountTransactionsCard } from './AccountTransactionsCard'; import { diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/hooks.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/hooks.tsx new file mode 100644 index 000000000..6b6f56339 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/hooks.tsx @@ -0,0 +1,157 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; +import { + Checkbox, + Intent, + PopoverInteractionKind, + Position, + Tag, + Tooltip, +} from '@blueprintjs/core'; +import { + useAddTransactionsToCategorizeSelected, + useIsTransactionToCategorizeSelected, + useRemoveTransactionsToCategorizeSelected, +} from '@/hooks/state/banking'; +import { Box, Icon } from '@/components'; +import styles from './AccountTransactionsUncategorizedTable.module.scss'; + +function statusAccessor(transaction) { + return transaction.is_recognized ? ( + + {transaction.assigned_category_formatted} + + {transaction.assigned_account_name} + + } + > + + + Recognized + + + + ) : null; +} + +interface TransactionSelectCheckboxProps { + transactionId: number; +} + +function TransactionSelectCheckbox({ + transactionId, +}: TransactionSelectCheckboxProps) { + const addTransactionsToCategorizeSelected = + useAddTransactionsToCategorizeSelected(); + + const removeTransactionsToCategorizeSelected = + useRemoveTransactionsToCategorizeSelected(); + + const isTransactionSelected = + useIsTransactionToCategorizeSelected(transactionId); + + const handleChange = (event) => { + isTransactionSelected + ? removeTransactionsToCategorizeSelected(transactionId) + : addTransactionsToCategorizeSelected(transactionId); + }; + + return ( + + ); +} + +/** + * Retrieve account uncategorized transctions table columns. + */ +export function useAccountUncategorizedTransactionsColumns() { + return React.useMemo( + () => [ + { + id: 'date', + Header: intl.get('date'), + accessor: 'formatted_date', + width: 40, + clickable: true, + textOverview: true, + }, + { + id: 'description', + Header: 'Description', + accessor: 'description', + width: 160, + textOverview: true, + clickable: true, + }, + { + id: 'payee', + Header: 'Payee', + accessor: 'payee', + width: 60, + clickable: true, + textOverview: true, + }, + { + id: 'reference_number', + Header: 'Ref.#', + accessor: 'reference_no', + width: 50, + clickable: true, + textOverview: true, + }, + { + id: 'status', + Header: 'Status', + accessor: statusAccessor, + }, + { + id: 'deposit', + Header: intl.get('cash_flow.label.deposit'), + accessor: 'formatted_deposit_amount', + width: 40, + className: 'deposit', + textOverview: true, + align: 'right', + clickable: true, + }, + { + id: 'withdrawal', + Header: intl.get('cash_flow.label.withdrawal'), + accessor: 'formatted_withdrawal_amount', + className: 'withdrawal', + width: 40, + textOverview: true, + align: 'right', + clickable: true, + }, + { + id: 'categorize_include', + Header: '', + accessor: (value) => ( + + ), + width: 20, + minWidth: 20, + maxWidth: 20, + align: 'right', + className: 'categorize_include selection-checkbox', + }, + ], + [], + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 2edf0c423..715ee0b33 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -150,99 +150,3 @@ export function AccountTransactionsProgressBar() { ) : null; } - -function statusAccessor(transaction) { - return transaction.is_recognized ? ( - - {transaction.assigned_category_formatted} - - {transaction.assigned_account_name} - - } - > - - - Recognized - - - - ) : null; -} - -/** - * Retrieve account uncategorized transctions table columns. - */ -export function useAccountUncategorizedTransactionsColumns() { - return React.useMemo( - () => [ - { - id: 'date', - Header: intl.get('date'), - accessor: 'formatted_date', - width: 40, - clickable: true, - textOverview: true, - }, - { - id: 'description', - Header: 'Description', - accessor: 'description', - width: 160, - textOverview: true, - clickable: true, - }, - { - id: 'payee', - Header: 'Payee', - accessor: 'payee', - width: 60, - clickable: true, - textOverview: true, - }, - { - id: 'reference_number', - Header: 'Ref.#', - accessor: 'reference_no', - width: 50, - clickable: true, - textOverview: true, - }, - { - id: 'status', - Header: 'Status', - accessor: statusAccessor, - }, - { - id: 'deposit', - Header: intl.get('cash_flow.label.deposit'), - accessor: 'formatted_deposit_amount', - width: 40, - className: 'deposit', - textOverview: true, - align: 'right', - clickable: true, - }, - { - id: 'withdrawal', - Header: intl.get('cash_flow.label.withdrawal'), - accessor: 'formatted_withdrawal_amount', - className: 'withdrawal', - width: 40, - textOverview: true, - align: 'right', - clickable: true, - }, - ], - [], - ); -} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx index 00729183d..812a6681b 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx @@ -6,10 +6,13 @@ import { useAccounts, useBranches } from '@/hooks/query'; import { useFeatureCan } from '@/hooks/state'; import { Features } from '@/constants'; import { Spinner } from '@blueprintjs/core'; -import { useGetRecognizedBankTransaction } from '@/hooks/query/bank-rules'; -import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; +import { + GetAutofillCategorizeTransaction, + useGetAutofillCategorizeTransaction, +} from '@/hooks/query/bank-rules'; interface CategorizeTransactionBootProps { + uncategorizedTransactionsIds: Array; children: React.ReactNode; } @@ -19,8 +22,8 @@ interface CategorizeTransactionBootValue { isBranchesLoading: boolean; isAccountsLoading: boolean; primaryBranch: any; - recognizedTranasction: any; - isRecognizedTransactionLoading: boolean; + autofillCategorizeValues: null | GetAutofillCategorizeTransaction; + isAutofillCategorizeValuesLoading: boolean; } const CategorizeTransactionBootContext = @@ -32,11 +35,9 @@ const CategorizeTransactionBootContext = * Categorize transcation boot. */ function CategorizeTransactionBoot({ + uncategorizedTransactionsIds, ...props }: CategorizeTransactionBootProps) { - const { uncategorizedTransaction, uncategorizedTransactionId } = - useCategorizeTransactionTabsBoot(); - // Detarmines whether the feature is enabled. const { featureCan } = useFeatureCan(); const isBranchFeatureCan = featureCan(Features.Branches); @@ -49,13 +50,11 @@ function CategorizeTransactionBoot({ {}, { enabled: isBranchFeatureCan }, ); - // Fetches the recognized transaction. + // Fetches the autofill values of categorize transaction. const { - data: recognizedTranasction, - isLoading: isRecognizedTransactionLoading, - } = useGetRecognizedBankTransaction(uncategorizedTransactionId, { - enabled: !!uncategorizedTransaction.is_recognized, - }); + data: autofillCategorizeValues, + isLoading: isAutofillCategorizeValuesLoading, + } = useGetAutofillCategorizeTransaction(uncategorizedTransactionsIds, {}); // Retrieves the primary branch. const primaryBranch = useMemo( @@ -69,11 +68,11 @@ function CategorizeTransactionBoot({ isBranchesLoading, isAccountsLoading, primaryBranch, - recognizedTranasction, - isRecognizedTransactionLoading, + autofillCategorizeValues, + isAutofillCategorizeValuesLoading, }; const isLoading = - isBranchesLoading || isAccountsLoading || isRecognizedTransactionLoading; + isBranchesLoading || isAccountsLoading || isAutofillCategorizeValuesLoading; if (isLoading) { ; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx index a07004d2b..89513c6b8 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx @@ -1,15 +1,16 @@ // @ts-nocheck import styled from 'styled-components'; +import * as R from 'ramda'; import { CategorizeTransactionBoot } from './CategorizeTransactionBoot'; import { CategorizeTransactionForm } from './CategorizeTransactionForm'; -import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; - -export function CategorizeTransactionContent() { - const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot(); +import { withBanking } from '@/containers/CashFlow/withBanking'; +function CategorizeTransactionContentRoot({ + transactionsToCategorizeIdsSelected, +}) { return ( @@ -18,6 +19,12 @@ export function CategorizeTransactionContent() { ); } +export const CategorizeTransactionContent = R.compose( + withBanking(({ transactionsToCategorizeIdsSelected }) => ({ + transactionsToCategorizeIdsSelected, + })), +)(CategorizeTransactionContentRoot); + const CategorizeTransactionDrawerBody = styled.div` display: flex; flex-direction: column; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx index 9f328bd7d..b2fe5a59f 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx @@ -22,7 +22,7 @@ function CategorizeTransactionFormRoot({ // #withBankingActions closeMatchingTransactionAside, }) { - const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot(); + const { uncategorizedTransactionIds } = useCategorizeTransactionTabsBoot(); const { mutateAsync: categorizeTransaction } = useCategorizeTransaction(); // Form initial values in create and edit mode. @@ -30,10 +30,10 @@ function CategorizeTransactionFormRoot({ // Callbacks handles form submit. const handleFormSubmit = (values, { setSubmitting, setErrors }) => { - const transformedValues = tranformToRequest(values); + const _values = tranformToRequest(values, uncategorizedTransactionIds); setSubmitting(true); - categorizeTransaction([uncategorizedTransactionId, transformedValues]) + categorizeTransaction(_values) .then(() => { setSubmitting(false); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx index af3b10400..43322b5f7 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx @@ -6,6 +6,7 @@ import { Box, FFormGroup, FSelect } from '@/components'; import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants'; import { useFormikContext } from 'formik'; import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; +import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; // Retrieves the add money in button options. const MoneyInOptions = getAddMoneyInOptions(); @@ -18,16 +19,18 @@ const Title = styled('h3')` `; export function CategorizeTransactionFormContent() { - const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); + const { autofillCategorizeValues } = useCategorizeTransactionBoot(); - const transactionTypes = uncategorizedTransaction?.is_deposit_transaction + const transactionTypes = autofillCategorizeValues?.isDepositTransaction ? MoneyInOptions : MoneyOutOptions; + const formattedAmount = autofillCategorizeValues?.formattedAmount; + return ( - {uncategorizedTransaction.formatted_amount} + {formattedAmount} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts index 340ed16fd..93ebde247 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts @@ -1,8 +1,8 @@ // @ts-nocheck import * as R from 'ramda'; import { transformToForm, transfromToSnakeCase } from '@/utils'; -import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; +import { GetAutofillCategorizeTransaction } from '@/hooks/query/bank-rules'; // Default initial form values. export const defaultInitialValues = { @@ -18,48 +18,28 @@ export const defaultInitialValues = { }; export const transformToCategorizeForm = ( - uncategorizedTransaction: any, - recognizedTransaction?: any, + autofillCategorizeTransaction: GetAutofillCategorizeTransaction, ) => { - let defaultValues = { - debitAccountId: uncategorizedTransaction.account_id, - transactionType: uncategorizedTransaction.is_deposit_transaction - ? 'other_income' - : 'other_expense', - amount: uncategorizedTransaction.amount, - date: uncategorizedTransaction.date, - }; - if (recognizedTransaction) { - const recognizedDefaults = getRecognizedTransactionDefaultValues( - recognizedTransaction, - ); - defaultValues = R.merge(defaultValues, recognizedDefaults); - } - return transformToForm(defaultValues, defaultInitialValues); + return transformToForm(autofillCategorizeTransaction, defaultInitialValues); }; -export const getRecognizedTransactionDefaultValues = ( - recognizedTransaction: any, +export const tranformToRequest = ( + formValues: Record, + uncategorizedTransactionIds: Array, ) => { return { - creditAccountId: recognizedTransaction.assignedAccountId || '', - // transactionType: recognizedTransaction.assignCategory, - referenceNo: recognizedTransaction.referenceNo || '', + uncategorized_transaction_ids: uncategorizedTransactionIds, + ...transfromToSnakeCase(formValues), }; }; -export const tranformToRequest = (formValues: Record) => { - return transfromToSnakeCase(formValues); -}; - /** * Categorize transaction form initial values. * @returns */ export const useCategorizeTransactionFormInitialValues = () => { - const { primaryBranch, recognizedTranasction } = + const { primaryBranch, autofillCategorizeValues } = useCategorizeTransactionBoot(); - const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); return { ...defaultInitialValues, @@ -68,10 +48,7 @@ export const useCategorizeTransactionFormInitialValues = () => { * values such as `notes` come back from the API as null, so remove those * as well. */ - ...transformToCategorizeForm( - uncategorizedTransaction, - recognizedTranasction, - ), + ...transformToCategorizeForm(autofillCategorizeValues), /** Assign the primary branch id as default value. */ branchId: primaryBranch?.id || null, diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx index bc228471d..6c0741ff7 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx @@ -19,20 +19,32 @@ function CategorizeTransactionAsideRoot({ // #withBanking selectedUncategorizedTransactionId, + resetTransactionsToCategorizeSelected, + enableMultipleCategorization, }: CategorizeTransactionAsideProps) { - // + // useEffect( () => () => { + // Close the reconcile matching form. closeReconcileMatchingTransaction(); + + // Reset the selected transactions to categorize. + resetTransactionsToCategorizeSelected(); + + // Disable multi matching. + enableMultipleCategorization(false); }, - [closeReconcileMatchingTransaction], + [ + closeReconcileMatchingTransaction, + resetTransactionsToCategorizeSelected, + enableMultipleCategorization, + ], ); const handleClose = () => { closeMatchingTransactionAside(); - }; - const uncategorizedTransactionId = selectedUncategorizedTransactionId; - + } + // Cannot continue if there is no selected transactions.; if (!selectedUncategorizedTransactionId) { return null; } @@ -40,7 +52,7 @@ function CategorizeTransactionAsideRoot({