Merge pull request #533 from bigcapitalhq/bulk-categorize-bank-transactions

feat: Bulk categorize bank transactions
This commit is contained in:
Ahmed Bouhuolia
2024-08-04 22:23:11 +02:00
committed by GitHub
50 changed files with 1267 additions and 429 deletions

View File

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

View File

@@ -6,6 +6,7 @@ import { BankingRulesController } from './BankingRulesController';
import { BankTransactionsMatchingController } from './BankTransactionsMatchingController'; import { BankTransactionsMatchingController } from './BankTransactionsMatchingController';
import { RecognizedTransactionsController } from './RecognizedTransactionsController'; import { RecognizedTransactionsController } from './RecognizedTransactionsController';
import { BankAccountsController } from './BankAccountsController'; import { BankAccountsController } from './BankAccountsController';
import { BankingUncategorizedController } from './BankingUncategorizedController';
@Service() @Service()
export class BankingController extends BaseController { export class BankingController extends BaseController {
@@ -29,6 +30,10 @@ export class BankingController extends BaseController {
'/bank_accounts', '/bank_accounts',
Container.get(BankAccountsController).router() Container.get(BankAccountsController).router()
); );
router.use(
'/categorize',
Container.get(BankingUncategorizedController).router()
);
return router; return router;
} }
} }

View File

@@ -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<Response | null>}
*/
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);
}
}
}

View File

@@ -1,6 +1,6 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { param } from 'express-validator'; import { param, query } from 'express-validator';
import BaseController from '../BaseController'; import BaseController from '../BaseController';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import CheckPolicies from '@/api/middleware/CheckPolicies'; import CheckPolicies from '@/api/middleware/CheckPolicies';
@@ -24,7 +24,12 @@ export default class GetCashflowAccounts extends BaseController {
const router = Router(); const router = Router();
router.get( router.get(
'/transactions/:transactionId/matches', '/transactions/matches',
[
query('uncategorizeTransactionsIds').exists().isArray({ min: 1 }),
query('uncategorizeTransactionsIds.*').exists().isNumeric().toInt(),
],
this.validationResult,
this.getMatchedTransactions.bind(this) this.getMatchedTransactions.bind(this)
); );
router.get( router.get(
@@ -44,7 +49,7 @@ export default class GetCashflowAccounts extends BaseController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
private getCashflowTransaction = async ( private getCashflowTransaction = async (
req: Request, req: Request<{ transactionId: number }>,
res: Response, res: Response,
next: NextFunction next: NextFunction
) => { ) => {
@@ -71,19 +76,24 @@ export default class GetCashflowAccounts extends BaseController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
private async getMatchedTransactions( private async getMatchedTransactions(
req: Request<{ transactionId: number }>, req: Request<
{ transactionId: number },
null,
null,
{ uncategorizeTransactionsIds: Array<number> }
>,
res: Response, res: Response,
next: NextFunction next: NextFunction
) { ) {
const { tenantId } = req; const { tenantId } = req;
const { transactionId } = req.params; const uncategorizeTransactionsIds = req.query.uncategorizeTransactionsIds;
const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter; const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter;
try { try {
const data = const data =
await this.bankTransactionsMatchingApp.getMatchedTransactions( await this.bankTransactionsMatchingApp.getMatchedTransactions(
tenantId, tenantId,
transactionId, uncategorizeTransactionsIds,
filter filter
); );
return res.status(200).send(data); return res.status(200).send(data);

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ export default class UncategorizedCashflowTransaction extends mixin(
/** /**
* Timestamps columns. * Timestamps columns.
*/ */
static get timestamps() { get timestamps() {
return ['createdAt', 'updatedAt']; return ['createdAt', 'updatedAt'];
} }

View File

@@ -1,6 +1,7 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import * as R from 'ramda'; import * as R from 'ramda';
import moment from 'moment'; import moment from 'moment';
import { first, sumBy } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool'; import { PromisePool } from '@supercharge/promise-pool';
import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types'; import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types';
import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses';
@@ -47,21 +48,24 @@ export class GetMatchedTransactions {
/** /**
* Retrieves the matched transactions. * Retrieves the matched transactions.
* @param {number} tenantId - * @param {number} tenantId -
* @param {Array<number>} uncategorizedTransactionIds - Uncategorized transactions ids.
* @param {GetMatchedTransactionsFilter} filter - * @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>} * @returns {Promise<MatchedTransactionsPOJO>}
*/ */
public async getMatchedTransactions( public async getMatchedTransactions(
tenantId: number, tenantId: number,
uncategorizedTransactionId: number, uncategorizedTransactionIds: Array<number>,
filter: GetMatchedTransactionsFilter filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> { ): Promise<MatchedTransactionsPOJO> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizedTransaction = const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query() await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId) .whereIn('id', uncategorizedTransactionIds)
.throwIfNotFound(); .throwIfNotFound();
const totalPending = Math.abs(sumBy(uncategorizedTransactions, 'amount'));
const filtered = filter.transactionType const filtered = filter.transactionType
? this.registered.filter((item) => item.type === filter.transactionType) ? this.registered.filter((item) => item.type === filter.transactionType)
: this.registered; : this.registered;
@@ -71,14 +75,14 @@ export class GetMatchedTransactions {
.process(async ({ type, service }) => { .process(async ({ type, service }) => {
return service.getMatchedTransactions(tenantId, filter); return service.getMatchedTransactions(tenantId, filter);
}); });
const { perfectMatches, possibleMatches } = this.groupMatchedResults( const { perfectMatches, possibleMatches } = this.groupMatchedResults(
uncategorizedTransaction, uncategorizedTransactions,
matchedTransactions matchedTransactions
); );
return { return {
perfectMatches, perfectMatches,
possibleMatches, possibleMatches,
totalPending,
}; };
} }
@@ -90,20 +94,20 @@ export class GetMatchedTransactions {
* @returns {MatchedTransactionsPOJO} * @returns {MatchedTransactionsPOJO}
*/ */
private groupMatchedResults( private groupMatchedResults(
uncategorizedTransaction, uncategorizedTransactions: Array<any>,
matchedTransactions matchedTransactions
): MatchedTransactionsPOJO { ): MatchedTransactionsPOJO {
const results = R.compose(R.flatten)(matchedTransactions?.results); 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 // Sort the results based on amount, date, and transaction type
const closestResullts = sortClosestMatchTransactions( const closestResullts = sortClosestMatchTransactions(amount, date, results);
uncategorizedTransaction,
results
);
const perfectMatches = R.filter( const perfectMatches = R.filter(
(match) => (match) =>
match.amount === uncategorizedTransaction.amount && match.amount === amount && moment(match.date).isSame(date, 'day'),
moment(match.date).isSame(uncategorizedTransaction.date, 'day'),
closestResullts closestResullts
); );
const possibleMatches = R.difference(closestResullts, perfectMatches); const possibleMatches = R.difference(closestResullts, perfectMatches);

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,23 @@
import moment from 'moment'; import moment from 'moment';
import * as R from 'ramda'; import * as R from 'ramda';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import { MatchedTransactionPOJO } from './types'; import { ERRORS, MatchedTransactionPOJO } from './types';
import { isEmpty, sumBy } from 'lodash';
import { ServiceError } from '@/exceptions';
export const sortClosestMatchTransactions = ( export const sortClosestMatchTransactions = (
uncategorizedTransaction: UncategorizedCashflowTransaction, amount: number,
date: Date,
matches: MatchedTransactionPOJO[] matches: MatchedTransactionPOJO[]
) => { ) => {
return R.sortWith([ return R.sortWith([
// Sort by amount difference (closest to uncategorized transaction amount first) // Sort by amount difference (closest to uncategorized transaction amount first)
R.ascend((match: MatchedTransactionPOJO) => 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) // Sort by date difference (closest to uncategorized transaction date first)
R.ascend((match: MatchedTransactionPOJO) => R.ascend((match: MatchedTransactionPOJO) =>
Math.abs( Math.abs(moment(match.date).diff(moment(date), 'days'))
moment(match.date).diff(moment(uncategorizedTransaction.date), 'days')
)
), ),
])(matches); ])(matches);
}; };
@@ -29,3 +30,36 @@ export const sumMatchTranasctions = (transactions: Array<any>) => {
0 0
); );
}; };
export const sumUncategorizedTransactions = (
uncategorizedTransactions: Array<any>
) => {
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),
});
}
};

View File

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

View File

@@ -2,15 +2,15 @@ import { Knex } from 'knex';
export interface IBankTransactionMatchingEventPayload { export interface IBankTransactionMatchingEventPayload {
tenantId: number; tenantId: number;
uncategorizedTransactionId: number; uncategorizedTransactionIds: Array<number>;
matchTransactionsDTO: IMatchTransactionsDTO; matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction; trx?: Knex.Transaction;
} }
export interface IBankTransactionMatchedEventPayload { export interface IBankTransactionMatchedEventPayload {
tenantId: number; tenantId: number;
uncategorizedTransactionId: number; uncategorizedTransactionIds: Array<number>;
matchTransactionsDTO: IMatchTransactionsDTO; matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction; trx?: Knex.Transaction;
} }
@@ -32,6 +32,7 @@ export interface IMatchTransactionDTO {
} }
export interface IMatchTransactionsDTO { export interface IMatchTransactionsDTO {
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>; matchedTransactions: Array<IMatchTransactionDTO>;
} }
@@ -57,6 +58,7 @@ export interface MatchedTransactionPOJO {
export type MatchedTransactionsPOJO = { export type MatchedTransactionsPOJO = {
perfectMatches: Array<MatchedTransactionPOJO>; perfectMatches: Array<MatchedTransactionPOJO>;
possibleMatches: Array<MatchedTransactionPOJO>; possibleMatches: Array<MatchedTransactionPOJO>;
totalPending: number;
}; };
export const ERRORS = { export const ERRORS = {

View File

@@ -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> | number} uncategorizeTransactionsId - Uncategorized transactions ids.
*/
public async getAutofillCategorizeTransaction(
tenantId: number,
uncategorizeTransactionsId: Array<number> | 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),
}
);
}
}

View File

@@ -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
);
}
}

View File

@@ -1,8 +1,8 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import { transformToMapBy } from '@/utils'; import { transformToMapBy } from '@/utils';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool'; import { PromisePool } from '@supercharge/promise-pool';
import { BankRule } from '@/models/BankRule'; import { BankRule } from '@/models/BankRule';
import { bankRulesMatchTransaction } from './_utils'; import { bankRulesMatchTransaction } from './_utils';

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import { includes, camelCase, upperFirst } from 'lodash'; import { includes, camelCase, upperFirst, sumBy } from 'lodash';
import { IAccount, IUncategorizedCashflowTransaction } from '@/interfaces'; import { IAccount, IUncategorizedCashflowTransaction } from '@/interfaces';
import { getCashflowTransactionType } from './utils'; import { getCashflowTransactionType } from './utils';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
@@ -68,11 +68,15 @@ export class CommandCashflowValidator {
* Validate the given transcation shouldn't be categorized. * Validate the given transcation shouldn't be categorized.
* @param {CashflowTransaction} cashflowTransaction * @param {CashflowTransaction} cashflowTransaction
*/ */
public validateTransactionShouldNotCategorized( public validateTransactionsShouldNotCategorized(
cashflowTransaction: CashflowTransaction cashflowTransactions: Array<IUncategorizedCashflowTransaction>
) { ) {
if (cashflowTransaction.uncategorize) { const categorized = cashflowTransactions.filter((t) => t.categorized);
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_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)} * @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)}
*/ */
public validateUncategorizeTransactionType( public validateUncategorizeTransactionType(
uncategorizeTransaction: IUncategorizedCashflowTransaction, uncategorizeTransactions: Array<IUncategorizedCashflowTransaction>,
transactionType: string transactionType: string
) { ) {
const amount = sumBy(uncategorizeTransactions, 'amount');
const isDepositTransaction = amount > 0;
const isWithdrawalTransaction = amount <= 0;
const type = getCashflowTransactionType( const type = getCashflowTransactionType(
upperFirst(camelCase(transactionType)) as CASHFLOW_TRANSACTION_TYPE transactionType as CASHFLOW_TRANSACTION_TYPE
); );
if ( if (
(type.direction === CASHFLOW_DIRECTION.IN && (type.direction === CASHFLOW_DIRECTION.IN && isDepositTransaction) ||
uncategorizeTransaction.isDepositTransaction) || (type.direction === CASHFLOW_DIRECTION.OUT && isWithdrawalTransaction)
(type.direction === CASHFLOW_DIRECTION.OUT &&
uncategorizeTransaction.isWithdrawalTransaction)
) { ) {
return; return;
} }

View File

@@ -8,6 +8,7 @@ import {
ICashflowTransactionUncategorizedPayload, ICashflowTransactionUncategorizedPayload,
ICashflowTransactionUncategorizingPayload, ICashflowTransactionUncategorizingPayload,
} from '@/interfaces'; } from '@/interfaces';
import { validateTransactionShouldBeCategorized } from './utils';
@Service() @Service()
export class UncategorizeCashflowTransaction { export class UncategorizeCashflowTransaction {
@@ -24,11 +25,12 @@ export class UncategorizeCashflowTransaction {
* Uncategorizes the given cashflow transaction. * Uncategorizes the given cashflow transaction.
* @param {number} tenantId * @param {number} tenantId
* @param {number} cashflowTransactionId * @param {number} cashflowTransactionId
* @returns {Promise<Array<number>>}
*/ */
public async uncategorize( public async uncategorize(
tenantId: number, tenantId: number,
uncategorizedTransactionId: number uncategorizedTransactionId: number
) { ): Promise<Array<number>> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const oldUncategorizedTransaction = const oldUncategorizedTransaction =
@@ -36,6 +38,22 @@ export class UncategorizeCashflowTransaction {
.findById(uncategorizedTransactionId) .findById(uncategorizedTransactionId)
.throwIfNotFound(); .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. // Updates the transaction under UOW.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onTransactionUncategorizing` event. // Triggers `onTransactionUncategorizing` event.
@@ -43,30 +61,36 @@ export class UncategorizeCashflowTransaction {
events.cashflow.onTransactionUncategorizing, events.cashflow.onTransactionUncategorizing,
{ {
tenantId, tenantId,
uncategorizedTransactionId,
oldUncategorizedTransactions,
trx, trx,
} as ICashflowTransactionUncategorizingPayload } as ICashflowTransactionUncategorizingPayload
); );
// Removes the ref relation with the related transaction. // Removes the ref relation with the related transaction.
const uncategorizedTransaction = await UncategorizedCashflowTransaction.query(trx)
await UncategorizedCashflowTransaction.query(trx).updateAndFetchById( .whereIn('id', oldUncategoirzedTransactionsIds)
uncategorizedTransactionId, .patch({
{ categorized: false,
categorized: false, categorizeRefId: null,
categorizeRefId: null, categorizeRefType: null,
categorizeRefType: null, });
} const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query(trx).whereIn(
'id',
oldUncategoirzedTransactionsIds
); );
// Triggers `onTransactionUncategorized` event. // Triggers `onTransactionUncategorized` event.
await this.eventPublisher.emitAsync( await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorized, events.cashflow.onTransactionUncategorized,
{ {
tenantId, tenantId,
uncategorizedTransaction, uncategorizedTransactionId,
oldUncategorizedTransaction, uncategorizedTransactions,
oldUncategorizedTransactions,
trx, trx,
} as ICashflowTransactionUncategorizedPayload } as ICashflowTransactionUncategorizedPayload
); );
return uncategorizedTransaction; return oldUncategoirzedTransactionsIds;
}); });
} }
} }

View File

@@ -16,7 +16,9 @@ export const ERRORS = {
CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED:
'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 { export enum CASHFLOW_DIRECTION {

View File

@@ -5,6 +5,7 @@ import {
ICashflowTransactionCategorizedPayload, ICashflowTransactionCategorizedPayload,
ICashflowTransactionUncategorizedPayload, ICashflowTransactionUncategorizedPayload,
} from '@/interfaces'; } from '@/interfaces';
import PromisePool from '@supercharge/promise-pool';
@Service() @Service()
export class DecrementUncategorizedTransactionOnCategorize { export class DecrementUncategorizedTransactionOnCategorize {
@@ -34,13 +35,18 @@ export class DecrementUncategorizedTransactionOnCategorize {
*/ */
public async decrementUnCategorizedTransactionsOnCategorized({ public async decrementUnCategorizedTransactionsOnCategorized({
tenantId, tenantId,
uncategorizedTransaction, uncategorizedTransactions,
trx
}: ICashflowTransactionCategorizedPayload) { }: ICashflowTransactionCategorizedPayload) {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
await Account.query() await PromisePool.withConcurrency(1)
.findById(uncategorizedTransaction.accountId) .for(uncategorizedTransactions)
.decrement('uncategorizedTransactions', 1); .process(async (uncategorizedTransaction) => {
await Account.query(trx)
.findById(uncategorizedTransaction.accountId)
.decrement('uncategorizedTransactions', 1);
});
} }
/** /**
@@ -49,13 +55,18 @@ export class DecrementUncategorizedTransactionOnCategorize {
*/ */
public async incrementUnCategorizedTransactionsOnUncategorized({ public async incrementUnCategorizedTransactionsOnUncategorized({
tenantId, tenantId,
uncategorizedTransaction, uncategorizedTransactions,
trx
}: ICashflowTransactionUncategorizedPayload) { }: ICashflowTransactionUncategorizedPayload) {
const { Account } = this.tenancy.models(tenantId); const { Account } = this.tenancy.models(tenantId);
await Account.query() await PromisePool.withConcurrency(1)
.findById(uncategorizedTransaction.accountId) .for(uncategorizedTransactions)
.increment('uncategorizedTransactions', 1); .process(async (uncategorizedTransaction) => {
await Account.query(trx)
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
});
} }
/** /**

View File

@@ -1,8 +1,10 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { ICashflowTransactionUncategorizedPayload } from '@/interfaces'; import { ICashflowTransactionUncategorizedPayload } from '@/interfaces';
import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService'; import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ServiceError } from '@/exceptions';
@Service() @Service()
export class DeleteCashflowTransactionOnUncategorize { export class DeleteCashflowTransactionOnUncategorize {
@@ -25,18 +27,27 @@ export class DeleteCashflowTransactionOnUncategorize {
*/ */
public async deleteCashflowTransactionOnUncategorize({ public async deleteCashflowTransactionOnUncategorize({
tenantId, tenantId,
oldUncategorizedTransaction, oldUncategorizedTransactions,
trx, trx,
}: ICashflowTransactionUncategorizedPayload) { }: ICashflowTransactionUncategorizedPayload) {
// Deletes the cashflow transaction. const _oldUncategorizedTransactions = oldUncategorizedTransactions.filter(
if ( (transaction) => transaction.categorizeRefType === 'CashflowTransaction'
oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction' );
) {
await this.deleteCashflowTransactionService.deleteCashflowTransaction(
tenantId,
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');
}
} }
} }
} }

View File

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

View File

@@ -12,6 +12,7 @@ import {
PopoverInteractionKind, PopoverInteractionKind,
Position, Position,
Intent, Intent,
Switch,
Tooltip, Tooltip,
MenuDivider, MenuDivider,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
@@ -44,6 +45,7 @@ import {
useExcludeUncategorizedTransactions, useExcludeUncategorizedTransactions,
useUnexcludeUncategorizedTransactions, useUnexcludeUncategorizedTransactions,
} from '@/hooks/query/bank-rules'; } from '@/hooks/query/bank-rules';
import { withBankingActions } from '../withBankingActions';
import { withBanking } from '../withBanking'; import { withBanking } from '../withBanking';
import withAlertActions from '@/containers/Alert/withAlertActions'; import withAlertActions from '@/containers/Alert/withAlertActions';
import { DialogsName } from '@/constants/dialogs'; import { DialogsName } from '@/constants/dialogs';
@@ -61,6 +63,10 @@ function AccountTransactionsActionsBar({
// #withBanking // #withBanking
uncategorizedTransationsIdsSelected, uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
openMatchingTransactionAside,
// #withBankingActions
enableMultipleCategorization,
// #withAlerts // #withAlerts
openAlert, 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. // Handle resume bank feeds syncing.
const handleResumeFeedsSyncing = () => { const handleResumeFeedsSyncing = () => {
openAlert('resume-feeds-syncing-bank-accounnt', { openAlert('resume-feeds-syncing-bank-accounnt', {
@@ -290,6 +300,22 @@ function AccountTransactionsActionsBar({
</NavbarGroup> </NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}> <NavbarGroup align={Alignment.RIGHT}>
{openMatchingTransactionAside && (
<Tooltip
content={
'Enables to categorize or matching multiple bank transactions into one transaction.'
}
position={Position.BOTTOM}
minimal
>
<Switch
label={'Multi Select'}
inline
onChange={handleMultipleCategorizingSwitch}
/>
</Tooltip>
)}
<NavbarDivider />
<Popover <Popover
minimal={true} minimal={true}
interactionKind={PopoverInteractionKind.CLICK} interactionKind={PopoverInteractionKind.CLICK}
@@ -352,9 +378,12 @@ export default compose(
({ ({
uncategorizedTransationsIdsSelected, uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
openMatchingTransactionAside,
}) => ({ }) => ({
uncategorizedTransationsIdsSelected, uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
openMatchingTransactionAside,
}), }),
), ),
withBankingActions,
)(AccountTransactionsActionsBar); )(AccountTransactionsActionsBar);

View File

@@ -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;
}

View File

@@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import clsx from 'classnames';
import styled from 'styled-components'; import styled from 'styled-components';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import { import {
@@ -12,17 +13,19 @@ import {
AppToaster, AppToaster,
} from '@/components'; } from '@/components';
import { TABLES } from '@/constants/tables'; import { TABLES } from '@/constants/tables';
import { ActionsMenu } from './UncategorizedTransactions/components'; import { ActionsMenu } from './components';
import withSettings from '@/containers/Settings/withSettings'; import withSettings from '@/containers/Settings/withSettings';
import { withBankingActions } from '../withBankingActions'; import { withBankingActions } from '../../withBankingActions';
import { useMemorizedColumnsWidths } from '@/hooks'; import { useMemorizedColumnsWidths } from '@/hooks';
import { useAccountUncategorizedTransactionsColumns } from './components'; import { useAccountUncategorizedTransactionsContext } from '../AllTransactionsUncategorizedBoot';
import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot';
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { useAccountUncategorizedTransactionsColumns } from './hooks';
import { compose } from '@/utils'; import { compose } from '@/utils';
import { withBanking } from '../../withBanking';
import styles from './AccountTransactionsUncategorizedTable.module.scss';
/** /**
* Account transactions data table. * Account transactions data table.
@@ -31,9 +34,16 @@ function AccountTransactionsDataTable({
// #withSettings // #withSettings
cashflowTansactionsTableSize, cashflowTansactionsTableSize,
// #withBanking
openMatchingTransactionAside,
enableMultipleCategorization,
// #withBankingActions // #withBankingActions
setUncategorizedTransactionIdForMatching, setUncategorizedTransactionIdForMatching,
setUncategorizedTransactionsSelected, setUncategorizedTransactionsSelected,
addTransactionsToCategorizeSelected,
setTransactionsToCategorizeSelected,
}) { }) {
// Retrieve table columns. // Retrieve table columns.
const columns = useAccountUncategorizedTransactionsColumns(); const columns = useAccountUncategorizedTransactionsColumns();
@@ -51,7 +61,11 @@ function AccountTransactionsDataTable({
// Handle cell click. // Handle cell click.
const handleCellClick = (cell) => { 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. // Handles categorize button click.
const handleCategorizeBtnClick = (transaction) => { const handleCategorizeBtnClick = (transaction) => {
@@ -66,7 +80,7 @@ function AccountTransactionsDataTable({
message: 'The bank transaction has been excluded successfully.', message: 'The bank transaction has been excluded successfully.',
}); });
}) })
.catch((error) => { .catch(() => {
AppToaster.show({ AppToaster.show({
intent: Intent.DANGER, intent: Intent.DANGER,
message: 'Something went wrong.', 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 ( return (
<CashflowTransactionsTable <CashflowTransactionsTable
noInitialFetch={true} noInitialFetch={true}
@@ -106,12 +114,13 @@ function AccountTransactionsDataTable({
noResults={ noResults={
'There is no uncategorized transactions in the current account.' 'There is no uncategorized transactions in the current account.'
} }
className="table-constrant"
onSelectedRowsChange={handleSelectedRowsChange}
payload={{ payload={{
onExclude: handleExcludeTransaction, onExclude: handleExcludeTransaction,
onCategorize: handleCategorizeBtnClick, onCategorize: handleCategorizeBtnClick,
}} }}
className={clsx('table-constrant', styles.table, {
[styles.showCategorizeColumn]: enableMultipleCategorization,
})}
/> />
); );
} }
@@ -121,6 +130,12 @@ export default compose(
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
})), })),
withBankingActions, withBankingActions,
withBanking(
({ openMatchingTransactionAside, enableMultipleCategorization }) => ({
openMatchingTransactionAside,
enableMultipleCategorization,
}),
),
)(AccountTransactionsDataTable); )(AccountTransactionsDataTable);
const DashboardConstrantTable = styled(DataTable)` const DashboardConstrantTable = styled(DataTable)`

View File

@@ -1,6 +1,6 @@
import * as R from 'ramda'; import * as R from 'ramda';
import { useEffect } from 'react'; import { useEffect } from 'react';
import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable'; import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable';
import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot'; import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot';
import { AccountTransactionsCard } from './AccountTransactionsCard'; import { AccountTransactionsCard } from './AccountTransactionsCard';
import { import {

View File

@@ -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 ? (
<Tooltip
interactionKind={PopoverInteractionKind.HOVER}
position={Position.RIGHT}
content={
<Box>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</Box>
}
>
<Box>
<Tag intent={Intent.SUCCESS} interactive>
Recognized
</Tag>
</Box>
</Tooltip>
) : 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 (
<Checkbox
large
checked={isTransactionSelected}
onChange={handleChange}
className={styles.categorizeCheckbox}
/>
);
}
/**
* 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) => (
<TransactionSelectCheckbox transactionId={value.id} />
),
width: 20,
minWidth: 20,
maxWidth: 20,
align: 'right',
className: 'categorize_include selection-checkbox',
},
],
[],
);
}

View File

@@ -150,99 +150,3 @@ export function AccountTransactionsProgressBar() {
<MaterialProgressBar /> <MaterialProgressBar />
) : null; ) : null;
} }
function statusAccessor(transaction) {
return transaction.is_recognized ? (
<Tooltip
compact
interactionKind={PopoverInteractionKind.HOVER}
position={Position.RIGHT}
content={
<Box>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</Box>
}
>
<Box>
<Tag intent={Intent.SUCCESS} interactive>
Recognized
</Tag>
</Box>
</Tooltip>
) : 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,
},
],
[],
);
}

View File

@@ -6,10 +6,13 @@ import { useAccounts, useBranches } from '@/hooks/query';
import { useFeatureCan } from '@/hooks/state'; import { useFeatureCan } from '@/hooks/state';
import { Features } from '@/constants'; import { Features } from '@/constants';
import { Spinner } from '@blueprintjs/core'; import { Spinner } from '@blueprintjs/core';
import { useGetRecognizedBankTransaction } from '@/hooks/query/bank-rules'; import {
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; GetAutofillCategorizeTransaction,
useGetAutofillCategorizeTransaction,
} from '@/hooks/query/bank-rules';
interface CategorizeTransactionBootProps { interface CategorizeTransactionBootProps {
uncategorizedTransactionsIds: Array<number>;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -19,8 +22,8 @@ interface CategorizeTransactionBootValue {
isBranchesLoading: boolean; isBranchesLoading: boolean;
isAccountsLoading: boolean; isAccountsLoading: boolean;
primaryBranch: any; primaryBranch: any;
recognizedTranasction: any; autofillCategorizeValues: null | GetAutofillCategorizeTransaction;
isRecognizedTransactionLoading: boolean; isAutofillCategorizeValuesLoading: boolean;
} }
const CategorizeTransactionBootContext = const CategorizeTransactionBootContext =
@@ -32,11 +35,9 @@ const CategorizeTransactionBootContext =
* Categorize transcation boot. * Categorize transcation boot.
*/ */
function CategorizeTransactionBoot({ function CategorizeTransactionBoot({
uncategorizedTransactionsIds,
...props ...props
}: CategorizeTransactionBootProps) { }: CategorizeTransactionBootProps) {
const { uncategorizedTransaction, uncategorizedTransactionId } =
useCategorizeTransactionTabsBoot();
// Detarmines whether the feature is enabled. // Detarmines whether the feature is enabled.
const { featureCan } = useFeatureCan(); const { featureCan } = useFeatureCan();
const isBranchFeatureCan = featureCan(Features.Branches); const isBranchFeatureCan = featureCan(Features.Branches);
@@ -49,13 +50,11 @@ function CategorizeTransactionBoot({
{}, {},
{ enabled: isBranchFeatureCan }, { enabled: isBranchFeatureCan },
); );
// Fetches the recognized transaction. // Fetches the autofill values of categorize transaction.
const { const {
data: recognizedTranasction, data: autofillCategorizeValues,
isLoading: isRecognizedTransactionLoading, isLoading: isAutofillCategorizeValuesLoading,
} = useGetRecognizedBankTransaction(uncategorizedTransactionId, { } = useGetAutofillCategorizeTransaction(uncategorizedTransactionsIds, {});
enabled: !!uncategorizedTransaction.is_recognized,
});
// Retrieves the primary branch. // Retrieves the primary branch.
const primaryBranch = useMemo( const primaryBranch = useMemo(
@@ -69,11 +68,11 @@ function CategorizeTransactionBoot({
isBranchesLoading, isBranchesLoading,
isAccountsLoading, isAccountsLoading,
primaryBranch, primaryBranch,
recognizedTranasction, autofillCategorizeValues,
isRecognizedTransactionLoading, isAutofillCategorizeValuesLoading,
}; };
const isLoading = const isLoading =
isBranchesLoading || isAccountsLoading || isRecognizedTransactionLoading; isBranchesLoading || isAccountsLoading || isAutofillCategorizeValuesLoading;
if (isLoading) { if (isLoading) {
<Spinner size={30} />; <Spinner size={30} />;

View File

@@ -1,15 +1,16 @@
// @ts-nocheck // @ts-nocheck
import styled from 'styled-components'; import styled from 'styled-components';
import * as R from 'ramda';
import { CategorizeTransactionBoot } from './CategorizeTransactionBoot'; import { CategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { CategorizeTransactionForm } from './CategorizeTransactionForm'; import { CategorizeTransactionForm } from './CategorizeTransactionForm';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; import { withBanking } from '@/containers/CashFlow/withBanking';
export function CategorizeTransactionContent() {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
function CategorizeTransactionContentRoot({
transactionsToCategorizeIdsSelected,
}) {
return ( return (
<CategorizeTransactionBoot <CategorizeTransactionBoot
uncategorizedTransactionId={uncategorizedTransactionId} uncategorizedTransactionsIds={transactionsToCategorizeIdsSelected}
> >
<CategorizeTransactionDrawerBody> <CategorizeTransactionDrawerBody>
<CategorizeTransactionForm /> <CategorizeTransactionForm />
@@ -18,6 +19,12 @@ export function CategorizeTransactionContent() {
); );
} }
export const CategorizeTransactionContent = R.compose(
withBanking(({ transactionsToCategorizeIdsSelected }) => ({
transactionsToCategorizeIdsSelected,
})),
)(CategorizeTransactionContentRoot);
const CategorizeTransactionDrawerBody = styled.div` const CategorizeTransactionDrawerBody = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -22,7 +22,7 @@ function CategorizeTransactionFormRoot({
// #withBankingActions // #withBankingActions
closeMatchingTransactionAside, closeMatchingTransactionAside,
}) { }) {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot(); const { uncategorizedTransactionIds } = useCategorizeTransactionTabsBoot();
const { mutateAsync: categorizeTransaction } = useCategorizeTransaction(); const { mutateAsync: categorizeTransaction } = useCategorizeTransaction();
// Form initial values in create and edit mode. // Form initial values in create and edit mode.
@@ -30,10 +30,10 @@ function CategorizeTransactionFormRoot({
// Callbacks handles form submit. // Callbacks handles form submit.
const handleFormSubmit = (values, { setSubmitting, setErrors }) => { const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
const transformedValues = tranformToRequest(values); const _values = tranformToRequest(values, uncategorizedTransactionIds);
setSubmitting(true); setSubmitting(true);
categorizeTransaction([uncategorizedTransactionId, transformedValues]) categorizeTransaction(_values)
.then(() => { .then(() => {
setSubmitting(false); setSubmitting(false);

View File

@@ -6,6 +6,7 @@ import { Box, FFormGroup, FSelect } from '@/components';
import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants'; import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
// Retrieves the add money in button options. // Retrieves the add money in button options.
const MoneyInOptions = getAddMoneyInOptions(); const MoneyInOptions = getAddMoneyInOptions();
@@ -18,16 +19,18 @@ const Title = styled('h3')`
`; `;
export function CategorizeTransactionFormContent() { export function CategorizeTransactionFormContent() {
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); const { autofillCategorizeValues } = useCategorizeTransactionBoot();
const transactionTypes = uncategorizedTransaction?.is_deposit_transaction const transactionTypes = autofillCategorizeValues?.isDepositTransaction
? MoneyInOptions ? MoneyInOptions
: MoneyOutOptions; : MoneyOutOptions;
const formattedAmount = autofillCategorizeValues?.formattedAmount;
return ( return (
<Box style={{ flex: 1, margin: 20 }}> <Box style={{ flex: 1, margin: 20 }}>
<FormGroup label={'Amount'} inline> <FormGroup label={'Amount'} inline>
<Title>{uncategorizedTransaction.formatted_amount}</Title> <Title>{formattedAmount}</Title>
</FormGroup> </FormGroup>
<FFormGroup name={'category'} label={'Category'} fastField inline> <FFormGroup name={'category'} label={'Category'} fastField inline>

View File

@@ -1,8 +1,8 @@
// @ts-nocheck // @ts-nocheck
import * as R from 'ramda'; import * as R from 'ramda';
import { transformToForm, transfromToSnakeCase } from '@/utils'; import { transformToForm, transfromToSnakeCase } from '@/utils';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { GetAutofillCategorizeTransaction } from '@/hooks/query/bank-rules';
// Default initial form values. // Default initial form values.
export const defaultInitialValues = { export const defaultInitialValues = {
@@ -18,48 +18,28 @@ export const defaultInitialValues = {
}; };
export const transformToCategorizeForm = ( export const transformToCategorizeForm = (
uncategorizedTransaction: any, autofillCategorizeTransaction: GetAutofillCategorizeTransaction,
recognizedTransaction?: any,
) => { ) => {
let defaultValues = { return transformToForm(autofillCategorizeTransaction, defaultInitialValues);
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);
}; };
export const getRecognizedTransactionDefaultValues = ( export const tranformToRequest = (
recognizedTransaction: any, formValues: Record<string, any>,
uncategorizedTransactionIds: Array<number>,
) => { ) => {
return { return {
creditAccountId: recognizedTransaction.assignedAccountId || '', uncategorized_transaction_ids: uncategorizedTransactionIds,
// transactionType: recognizedTransaction.assignCategory, ...transfromToSnakeCase(formValues),
referenceNo: recognizedTransaction.referenceNo || '',
}; };
}; };
export const tranformToRequest = (formValues: Record<string, any>) => {
return transfromToSnakeCase(formValues);
};
/** /**
* Categorize transaction form initial values. * Categorize transaction form initial values.
* @returns * @returns
*/ */
export const useCategorizeTransactionFormInitialValues = () => { export const useCategorizeTransactionFormInitialValues = () => {
const { primaryBranch, recognizedTranasction } = const { primaryBranch, autofillCategorizeValues } =
useCategorizeTransactionBoot(); useCategorizeTransactionBoot();
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
return { return {
...defaultInitialValues, ...defaultInitialValues,
@@ -68,10 +48,7 @@ export const useCategorizeTransactionFormInitialValues = () => {
* values such as `notes` come back from the API as null, so remove those * values such as `notes` come back from the API as null, so remove those
* as well. * as well.
*/ */
...transformToCategorizeForm( ...transformToCategorizeForm(autofillCategorizeValues),
uncategorizedTransaction,
recognizedTranasction,
),
/** Assign the primary branch id as default value. */ /** Assign the primary branch id as default value. */
branchId: primaryBranch?.id || null, branchId: primaryBranch?.id || null,

View File

@@ -19,20 +19,32 @@ function CategorizeTransactionAsideRoot({
// #withBanking // #withBanking
selectedUncategorizedTransactionId, selectedUncategorizedTransactionId,
resetTransactionsToCategorizeSelected,
enableMultipleCategorization,
}: CategorizeTransactionAsideProps) { }: CategorizeTransactionAsideProps) {
// //
useEffect( useEffect(
() => () => { () => () => {
// Close the reconcile matching form.
closeReconcileMatchingTransaction(); closeReconcileMatchingTransaction();
// Reset the selected transactions to categorize.
resetTransactionsToCategorizeSelected();
// Disable multi matching.
enableMultipleCategorization(false);
}, },
[closeReconcileMatchingTransaction], [
closeReconcileMatchingTransaction,
resetTransactionsToCategorizeSelected,
enableMultipleCategorization,
],
); );
const handleClose = () => { const handleClose = () => {
closeMatchingTransactionAside(); closeMatchingTransactionAside();
}; }
const uncategorizedTransactionId = selectedUncategorizedTransactionId; // Cannot continue if there is no selected transactions.;
if (!selectedUncategorizedTransactionId) { if (!selectedUncategorizedTransactionId) {
return null; return null;
} }
@@ -40,7 +52,7 @@ function CategorizeTransactionAsideRoot({
<Aside title={'Categorize Bank Transaction'} onClose={handleClose}> <Aside title={'Categorize Bank Transaction'} onClose={handleClose}>
<Aside.Body> <Aside.Body>
<CategorizeTransactionTabsBoot <CategorizeTransactionTabsBoot
uncategorizedTransactionId={uncategorizedTransactionId} uncategorizedTransactionId={selectedUncategorizedTransactionId}
> >
<CategorizeTransactionTabs /> <CategorizeTransactionTabs />
</CategorizeTransactionTabsBoot> </CategorizeTransactionTabsBoot>
@@ -51,7 +63,7 @@ function CategorizeTransactionAsideRoot({
export const CategorizeTransactionAside = R.compose( export const CategorizeTransactionAside = R.compose(
withBankingActions, withBankingActions,
withBanking(({ selectedUncategorizedTransactionId }) => ({ withBanking(({ transactionsToCategorizeIdsSelected }) => ({
selectedUncategorizedTransactionId, selectedUncategorizedTransactionId: transactionsToCategorizeIdsSelected,
})), })),
)(CategorizeTransactionAsideRoot); )(CategorizeTransactionAsideRoot);

View File

@@ -2,14 +2,10 @@
import { Tab, Tabs } from '@blueprintjs/core'; import { Tab, Tabs } from '@blueprintjs/core';
import { MatchingBankTransaction } from './MatchingTransaction'; import { MatchingBankTransaction } from './MatchingTransaction';
import { CategorizeTransactionContent } from '../CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent'; import { CategorizeTransactionContent } from '../CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import styles from './CategorizeTransactionTabs.module.scss'; import styles from './CategorizeTransactionTabs.module.scss';
export function CategorizeTransactionTabs() { export function CategorizeTransactionTabs() {
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); const defaultSelectedTabId = 'categorize';
const defaultSelectedTabId = uncategorizedTransaction?.is_recognized
? 'categorize'
: 'matching';
return ( return (
<Tabs <Tabs

View File

@@ -1,16 +1,13 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React, { useMemo } from 'react';
import { Spinner } from '@blueprintjs/core'; import { castArray, uniq } from 'lodash';
import { useUncategorizedTransaction } from '@/hooks/query';
interface CategorizeTransactionTabsValue { interface CategorizeTransactionTabsValue {
uncategorizedTransactionId: number; uncategorizedTransactionIds: Array<number>;
isUncategorizedTransactionLoading: boolean;
uncategorizedTransaction: any;
} }
interface CategorizeTransactionTabsBootProps { interface CategorizeTransactionTabsBootProps {
uncategorizedTransactionId: number; uncategorizedTransactionIds: number | Array<number>;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -26,28 +23,23 @@ export function CategorizeTransactionTabsBoot({
uncategorizedTransactionId, uncategorizedTransactionId,
children, children,
}: CategorizeTransactionTabsBootProps) { }: CategorizeTransactionTabsBootProps) {
const { const uncategorizedTransactionIds = useMemo(
data: uncategorizedTransaction, () => uniq(castArray(uncategorizedTransactionId)),
isLoading: isUncategorizedTransactionLoading, [uncategorizedTransactionId],
} = useUncategorizedTransaction(uncategorizedTransactionId); );
const provider = { const provider = {
uncategorizedTransactionId, uncategorizedTransactionIds,
uncategorizedTransaction,
isUncategorizedTransactionLoading,
}; };
const isLoading = isUncategorizedTransactionLoading; // Use a key prop to force re-render of children when `uncategorizedTransactionIds` changes
// Use a key prop to force re-render of children when uncategorizedTransactionId changes
const childrenPerKey = React.useMemo(() => { const childrenPerKey = React.useMemo(() => {
return React.Children.map(children, (child) => return React.Children.map(children, (child) =>
React.cloneElement(child, { key: uncategorizedTransactionId }), React.cloneElement(child, {
key: uncategorizedTransactionIds?.join(','),
}),
); );
}, [children, uncategorizedTransactionId]); }, [children, uncategorizedTransactionIds]);
if (isLoading) {
return <Spinner size={30} />;
}
return ( return (
<CategorizeTransactionTabsBootContext.Provider value={provider}> <CategorizeTransactionTabsBootContext.Provider value={provider}>
{childrenPerKey} {childrenPerKey}

View File

@@ -25,9 +25,9 @@ import {
WithBankingActionsProps, WithBankingActionsProps,
withBankingActions, withBankingActions,
} from '../withBankingActions'; } from '../withBankingActions';
import styles from './CategorizeTransactionAside.module.scss';
import { MatchingReconcileTransactionForm } from './MatchingReconcileTransactionAside/MatchingReconcileTransactionForm';
import { withBanking } from '../withBanking'; import { withBanking } from '../withBanking';
import { MatchingReconcileTransactionForm } from './MatchingReconcileTransactionAside/MatchingReconcileTransactionForm';
import styles from './CategorizeTransactionAside.module.scss';
const initialValues = { const initialValues = {
matched: {}, matched: {},
@@ -40,8 +40,11 @@ const initialValues = {
function MatchingBankTransactionRoot({ function MatchingBankTransactionRoot({
// #withBankingActions // #withBankingActions
closeMatchingTransactionAside, closeMatchingTransactionAside,
// #withBanking
transactionsToCategorizeIdsSelected,
}) { }) {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot(); const { uncategorizedTransactionIds } = useCategorizeTransactionTabsBoot();
const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction(); const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction();
// Handles the form submitting. // Handles the form submitting.
@@ -49,7 +52,7 @@ function MatchingBankTransactionRoot({
values: MatchingTransactionFormValues, values: MatchingTransactionFormValues,
{ setSubmitting }: FormikHelpers<MatchingTransactionFormValues>, { setSubmitting }: FormikHelpers<MatchingTransactionFormValues>,
) => { ) => {
const _values = transformToReq(values); const _values = transformToReq(values, uncategorizedTransactionIds);
if (_values.matchedTransactions?.length === 0) { if (_values.matchedTransactions?.length === 0) {
AppToaster.show({ AppToaster.show({
@@ -59,7 +62,7 @@ function MatchingBankTransactionRoot({
return; return;
} }
setSubmitting(true); setSubmitting(true);
matchTransaction({ id: uncategorizedTransactionId, value: _values }) matchTransaction(_values)
.then(() => { .then(() => {
AppToaster.show({ AppToaster.show({
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
@@ -78,7 +81,7 @@ function MatchingBankTransactionRoot({
message: `The total amount does not equal the uncategorized transaction.`, message: `The total amount does not equal the uncategorized transaction.`,
intent: Intent.DANGER, intent: Intent.DANGER,
}); });
setSubmitting(false);
return; return;
} }
AppToaster.show({ AppToaster.show({
@@ -91,7 +94,7 @@ function MatchingBankTransactionRoot({
return ( return (
<MatchingTransactionBoot <MatchingTransactionBoot
uncategorizedTransactionId={uncategorizedTransactionId} uncategorizedTransactionsIds={uncategorizedTransactionIds}
> >
<Formik initialValues={initialValues} onSubmit={handleSubmit}> <Formik initialValues={initialValues} onSubmit={handleSubmit}>
<MatchingBankTransactionFormContent /> <MatchingBankTransactionFormContent />
@@ -100,9 +103,12 @@ function MatchingBankTransactionRoot({
); );
} }
export const MatchingBankTransaction = R.compose(withBankingActions)( export const MatchingBankTransaction = R.compose(
MatchingBankTransactionRoot, withBankingActions,
); withBanking(({ transactionsToCategorizeIdsSelected }) => ({
transactionsToCategorizeIdsSelected,
})),
)(MatchingBankTransactionRoot);
/** /**
* Matching bank transaction form content. * Matching bank transaction form content.

View File

@@ -10,6 +10,7 @@ interface MatchingTransactionBootValues {
possibleMatches: Array<any>; possibleMatches: Array<any>;
perfectMatchesCount: number; perfectMatchesCount: number;
perfectMatches: Array<any>; perfectMatches: Array<any>;
totalPending: number;
matches: Array<any>; matches: Array<any>;
} }
@@ -18,12 +19,12 @@ const RuleFormBootContext = createContext<MatchingTransactionBootValues>(
); );
interface RuleFormBootProps { interface RuleFormBootProps {
uncategorizedTransactionId: number; uncategorizedTransactionsIds: Array<number>;
children: React.ReactNode; children: React.ReactNode;
} }
function MatchingTransactionBoot({ function MatchingTransactionBoot({
uncategorizedTransactionId, uncategorizedTransactionsIds,
...props ...props
}: RuleFormBootProps) { }: RuleFormBootProps) {
const { const {
@@ -31,11 +32,12 @@ function MatchingTransactionBoot({
isLoading: isMatchingTransactionsLoading, isLoading: isMatchingTransactionsLoading,
isFetching: isMatchingTransactionsFetching, isFetching: isMatchingTransactionsFetching,
isSuccess: isMatchingTransactionsSuccess, isSuccess: isMatchingTransactionsSuccess,
} = useGetBankTransactionsMatches(uncategorizedTransactionId); } = useGetBankTransactionsMatches(uncategorizedTransactionsIds);
const possibleMatches = defaultTo(matchingTransactions?.possibleMatches, []); const possibleMatches = defaultTo(matchingTransactions?.possibleMatches, []);
const perfectMatchesCount = matchingTransactions?.perfectMatches?.length || 0; const perfectMatchesCount = matchingTransactions?.perfectMatches?.length || 0;
const perfectMatches = defaultTo(matchingTransactions?.perfectMatches, []); const perfectMatches = defaultTo(matchingTransactions?.perfectMatches, []);
const totalPending = defaultTo(matchingTransactions?.totalPending, 0);
const matches = R.concat(perfectMatches, possibleMatches); const matches = R.concat(perfectMatches, possibleMatches);
@@ -46,6 +48,7 @@ function MatchingTransactionBoot({
possibleMatches, possibleMatches,
perfectMatchesCount, perfectMatchesCount,
perfectMatches, perfectMatches,
totalPending,
matches, matches,
} as MatchingTransactionBootValues; } as MatchingTransactionBootValues;

View File

@@ -4,7 +4,10 @@ import { useMatchingTransactionBoot } from './MatchingTransactionBoot';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot'; import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import { useMemo } from 'react'; import { useMemo } from 'react';
export const transformToReq = (values: MatchingTransactionFormValues) => { export const transformToReq = (
values: MatchingTransactionFormValues,
uncategorizedTransactions: Array<number>,
) => {
const matchedTransactions = Object.entries(values.matched) const matchedTransactions = Object.entries(values.matched)
.filter(([key, value]) => value) .filter(([key, value]) => value)
.map(([key]) => { .map(([key]) => {
@@ -12,14 +15,13 @@ export const transformToReq = (values: MatchingTransactionFormValues) => {
return { reference_type, reference_id: parseInt(reference_id, 10) }; return { reference_type, reference_id: parseInt(reference_id, 10) };
}); });
return { matchedTransactions, uncategorizedTransactions };
return { matchedTransactions };
}; };
export const useGetPendingAmountMatched = () => { export const useGetPendingAmountMatched = () => {
const { values } = useFormikContext<MatchingTransactionFormValues>(); const { values } = useFormikContext<MatchingTransactionFormValues>();
const { perfectMatches, possibleMatches } = useMatchingTransactionBoot(); const { perfectMatches, possibleMatches, totalPending } =
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); useMatchingTransactionBoot();
return useMemo(() => { return useMemo(() => {
const matchedItems = [...perfectMatches, ...possibleMatches].filter( const matchedItems = [...perfectMatches, ...possibleMatches].filter(
@@ -34,11 +36,10 @@ export const useGetPendingAmountMatched = () => {
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount), (item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
0, 0,
); );
const amount = uncategorizedTransaction.amount; const pendingAmount = totalPending - totalMatchedAmount;
const pendingAmount = amount - totalMatchedAmount;
return pendingAmount; return pendingAmount;
}, [uncategorizedTransaction, perfectMatches, possibleMatches, values]); }, [totalPending, perfectMatches, possibleMatches, values]);
}; };
export const useAtleastOneMatchedSelected = () => { export const useAtleastOneMatchedSelected = () => {

View File

@@ -18,6 +18,10 @@ export const withBanking = (mapState) => {
state.plaid.uncategorizedTransactionsSelected, state.plaid.uncategorizedTransactionsSelected,
excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected, excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected,
enableMultipleCategorization: state.plaid.enableMultipleCategorization,
transactionsToCategorizeIdsSelected:
state.plaid.transactionsToCategorizeSelected,
}; };
return mapState ? mapState(mapped, state, props) : mapped; return mapState ? mapState(mapped, state, props) : mapped;
}; };

View File

@@ -8,6 +8,11 @@ import {
resetUncategorizedTransactionsSelected, resetUncategorizedTransactionsSelected,
resetExcludedTransactionsSelected, resetExcludedTransactionsSelected,
setExcludedTransactionsSelected, setExcludedTransactionsSelected,
resetTransactionsToCategorizeSelected,
setTransactionsToCategorizeSelected,
enableMultipleCategorization,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps { export interface WithBankingActionsProps {
@@ -23,6 +28,13 @@ export interface WithBankingActionsProps {
setExcludedTransactionsSelected: (ids: Array<string | number>) => void; setExcludedTransactionsSelected: (ids: Array<string | number>) => void;
resetExcludedTransactionsSelected: () => void; resetExcludedTransactionsSelected: () => void;
setTransactionsToCategorizeSelected: (ids: Array<string | number>) => void;
addTransactionsToCategorizeSelected: (id: string | number) => void;
removeTransactionsToCategorizeSelected: (id: string | number) => void;
resetTransactionsToCategorizeSelected: () => void;
enableMultipleCategorization: (enable: boolean) => void;
} }
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -68,10 +80,46 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
), ),
/** /**
* Resets the excluded selected transactions * Resets the excluded selected transactions.
*/ */
resetExcludedTransactionsSelected: () => resetExcludedTransactionsSelected: () =>
dispatch(resetExcludedTransactionsSelected()), dispatch(resetExcludedTransactionsSelected()),
/**
* Sets the selected transactions to categorize or match.
* @param {Array<string | number>} ids
*/
setTransactionsToCategorizeSelected: (ids: Array<string | number>) =>
dispatch(setTransactionsToCategorizeSelected({ ids })),
/**
* Adds selected transactions to categorize.
* @param {string | number} id
* @returns
*/
addTransactionsToCategorizeSelected: (id: string | number) =>
dispatch(addTransactionsToCategorizeSelected({ id })),
/**
* Removes the selected transactions.
* @param {string | number} id
* @returns
*/
removeTransactionsToCategorizeSelected: (id: string | number) =>
dispatch(removeTransactionsToCategorizeSelected({ id })),
/**
* Resets the selected transactions to categorize or match.
*/
resetTransactionsToCategorizeSelected: () =>
dispatch(resetTransactionsToCategorizeSelected()),
/**
* Enables/Disables the multiple selection to categorize or match.
* @param {boolean} enable
*/
enableMultipleCategorization: (enable: boolean) =>
dispatch(enableMultipleCategorization({ enable })),
}); });
export const withBankingActions = connect< export const withBankingActions = connect<

View File

@@ -22,6 +22,7 @@ const QUERY_KEY = {
RECOGNIZED_BANK_TRANSACTIONS_INFINITY: RECOGNIZED_BANK_TRANSACTIONS_INFINITY:
'RECOGNIZED_BANK_TRANSACTIONS_INFINITY', 'RECOGNIZED_BANK_TRANSACTIONS_INFINITY',
BANK_ACCOUNT_SUMMARY_META: 'BANK_ACCOUNT_SUMMARY_META', BANK_ACCOUNT_SUMMARY_META: 'BANK_ACCOUNT_SUMMARY_META',
AUTOFILL_CATEGORIZE_BANK_TRANSACTION: 'AUTOFILL_CATEGORIZE_BANK_TRANSACTION',
}; };
const commonInvalidateQueries = (query: QueryClient) => { const commonInvalidateQueries = (query: QueryClient) => {
@@ -238,10 +239,13 @@ export function useBankRule(
); );
} }
type GetBankTransactionsMatchesValue = number; interface GetBankTransactionsMatchesValue {
uncategorizeTransactionsIds: Array<number>;
}
interface GetBankTransactionsMatchesResponse { interface GetBankTransactionsMatchesResponse {
perfectMatches: Array<any>; perfectMatches: Array<any>;
possibleMatches: Array<any>; possibleMatches: Array<any>;
totalPending: number;
} }
/** /**
@@ -250,16 +254,18 @@ interface GetBankTransactionsMatchesResponse {
* @returns {UseQueryResult<GetBankTransactionsMatchesResponse, Error>} * @returns {UseQueryResult<GetBankTransactionsMatchesResponse, Error>}
*/ */
export function useGetBankTransactionsMatches( export function useGetBankTransactionsMatches(
uncategorizedTransactionId: number, uncategorizeTransactionsIds: Array<number>,
options?: UseQueryOptions<GetBankTransactionsMatchesResponse, Error>, options?: UseQueryOptions<GetBankTransactionsMatchesResponse, Error>,
): UseQueryResult<GetBankTransactionsMatchesResponse, Error> { ): UseQueryResult<GetBankTransactionsMatchesResponse, Error> {
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useQuery<GetBankTransactionsMatchesResponse, Error>( return useQuery<GetBankTransactionsMatchesResponse, Error>(
[QUERY_KEY.BANK_TRANSACTION_MATCHES, uncategorizedTransactionId], [QUERY_KEY.BANK_TRANSACTION_MATCHES, uncategorizeTransactionsIds],
() => () =>
apiRequest apiRequest
.get(`/cashflow/transactions/${uncategorizedTransactionId}/matches`) .get(`/cashflow/transactions/matches`, {
params: { uncategorizeTransactionsIds },
})
.then((res) => transformToCamelCase(res.data)), .then((res) => transformToCamelCase(res.data)),
options, options,
); );
@@ -437,8 +443,8 @@ export function useUnexcludeUncategorizedTransactions(
} }
interface MatchUncategorizedTransactionValues { interface MatchUncategorizedTransactionValues {
id: number; uncategorizedTransactions: Array<number>;
value: any; matchedTransactions: Array<{ reference_type: string; reference_id: number }>;
} }
interface MatchUncategorizedTransactionRes {} interface MatchUncategorizedTransactionRes {}
@@ -465,7 +471,7 @@ export function useMatchUncategorizedTransaction(
MatchUncategorizedTransactionRes, MatchUncategorizedTransactionRes,
Error, Error,
MatchUncategorizedTransactionValues MatchUncategorizedTransactionValues
>(({ id, value }) => apiRequest.post(`/banking/matches/${id}`, value), { >((value) => apiRequest.post('/banking/matches/match', value), {
onSuccess: (res, id) => { onSuccess: (res, id) => {
queryClient.invalidateQueries( queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
@@ -581,6 +587,42 @@ export function useGetBankAccountSummaryMeta(
); );
} }
export interface GetAutofillCategorizeTransaction {
accountId: number | null;
amount: number;
category: string | null;
date: Date;
formattedAmount: string;
formattedDate: string;
isRecognized: boolean;
recognizedByRuleId: number | null;
recognizedByRuleName: string | null;
referenceNo: null | string;
isDepositTransaction: boolean;
isWithdrawalTransaction: boolean;
}
export function useGetAutofillCategorizeTransaction(
uncategorizedTransactionIds: number[],
options: any,
) {
const apiRequest = useApiRequest();
return useQuery<GetAutofillCategorizeTransaction, Error>(
[
QUERY_KEY.AUTOFILL_CATEGORIZE_BANK_TRANSACTION,
uncategorizedTransactionIds,
],
() =>
apiRequest
.get(`/banking/categorize/autofill`, {
params: { uncategorizedTransactionIds },
})
.then((res) => transformToCamelCase(res.data?.data)),
{ ...options },
);
}
/** /**
* @returns * @returns
*/ */

View File

@@ -243,8 +243,7 @@ export function useCategorizeTransaction(props) {
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useMutation( return useMutation(
([id, values]) => (values) => apiRequest.post(`cashflow/transactions/categorize`, values),
apiRequest.post(`cashflow/transactions/${id}/categorize`, values),
{ {
onSuccess: (res, id) => { onSuccess: (res, id) => {
// Invalidate queries. // Invalidate queries.
@@ -279,7 +278,6 @@ export function useUncategorizeTransaction(props) {
queryClient.invalidateQueries( queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
); );
// Invalidate bank account summary. // Invalidate bank account summary.
queryClient.invalidateQueries('BANK_ACCOUNT_SUMMARY_META'); queryClient.invalidateQueries('BANK_ACCOUNT_SUMMARY_META');
}, },

View File

@@ -1,10 +1,17 @@
import { useDispatch, useSelector } from 'react-redux';
import { useCallback, useMemo } from 'react';
import { import {
getPlaidToken, getPlaidToken,
setPlaidId, setPlaidId,
resetPlaidId, resetPlaidId,
setTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected,
getTransactionsToCategorizeSelected,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
getOpenMatchingTransactionAside,
getTransactionsToCategorizeIdsSelected,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
export const useSetBankingPlaidToken = () => { export const useSetBankingPlaidToken = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -30,3 +37,76 @@ export const useResetBankingPlaidToken = () => {
dispatch(resetPlaidId()); dispatch(resetPlaidId());
}, [dispatch]); }, [dispatch]);
}; };
export const useGetTransactionsToCategorizeSelected = () => {
const selectedTransactions = useSelector(getTransactionsToCategorizeSelected);
return useMemo(() => selectedTransactions, [selectedTransactions]);
};
export const useSetTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(
(ids: Array<string | number>) => {
return dispatch(setTransactionsToCategorizeSelected({ ids }));
},
[dispatch],
);
};
export const useAddTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(
(id: string | number) => {
return dispatch(addTransactionsToCategorizeSelected({ id }));
},
[dispatch],
);
};
export const useRemoveTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(
(id: string | number) => {
return dispatch(removeTransactionsToCategorizeSelected({ id }));
},
[dispatch],
);
};
export const useResetTransactionsToCategorizeSelected = () => {
const dispatch = useDispatch();
return useCallback(() => {
dispatch(resetTransactionsToCategorizeSelected());
}, [dispatch]);
};
export const useGetOpenMatchingTransactionAside = () => {
const openMatchingTransactionAside = useSelector(
getOpenMatchingTransactionAside,
);
return useMemo(
() => openMatchingTransactionAside,
[openMatchingTransactionAside],
);
};
/**
* Returns the detarmined value whether the given transaction id is checked.
* @param {number} transactionId
* @returns {boolean}
*/
export const useIsTransactionToCategorizeSelected = (transactionId: number) => {
const transactionsToCategorizeIdsSelected = useSelector(
getTransactionsToCategorizeIdsSelected,
);
return useMemo(
() => transactionsToCategorizeIdsSelected.indexOf(transactionId) !== -1,
[transactionsToCategorizeIdsSelected, transactionId],
);
};

View File

@@ -1,3 +1,4 @@
import { castArray, uniq } from 'lodash';
import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { PayloadAction, createSlice } from '@reduxjs/toolkit';
interface StorePlaidState { interface StorePlaidState {
@@ -8,6 +9,9 @@ interface StorePlaidState {
uncategorizedTransactionsSelected: Array<number | string>; uncategorizedTransactionsSelected: Array<number | string>;
excludedTransactionsSelected: Array<number | string>; excludedTransactionsSelected: Array<number | string>;
transactionsToCategorizeSelected: Array<number | string>;
enableMultipleCategorization: boolean;
} }
export const PlaidSlice = createSlice({ export const PlaidSlice = createSlice({
@@ -22,6 +26,8 @@ export const PlaidSlice = createSlice({
}, },
uncategorizedTransactionsSelected: [], uncategorizedTransactionsSelected: [],
excludedTransactionsSelected: [], excludedTransactionsSelected: [],
transactionsToCategorizeSelected: [],
enableMultipleCategorization: false,
} as StorePlaidState, } as StorePlaidState,
reducers: { reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => { setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -97,6 +103,79 @@ export const PlaidSlice = createSlice({
resetExcludedTransactionsSelected: (state: StorePlaidState) => { resetExcludedTransactionsSelected: (state: StorePlaidState) => {
state.excludedTransactionsSelected = []; state.excludedTransactionsSelected = [];
}, },
/**
* Sets the selected transactions to categorize or match.
* @param {StorePlaidState} state
* @param {PayloadAction<{ ids: Array<string | number> }>} action
*/
setTransactionsToCategorizeSelected: (
state: StorePlaidState,
action: PayloadAction<{ ids: Array<string | number> }>,
) => {
const ids = castArray(action.payload.ids);
state.transactionsToCategorizeSelected = ids;
state.openMatchingTransactionAside = true;
},
/**
* Adds a transaction to selected transactions to categorize or match.
* @param {StorePlaidState} state
* @param {PayloadAction<{ id: string | number }>} action
*/
addTransactionsToCategorizeSelected: (
state: StorePlaidState,
action: PayloadAction<{ id: string | number }>,
) => {
state.transactionsToCategorizeSelected = uniq([
...state.transactionsToCategorizeSelected,
action.payload.id,
]);
state.openMatchingTransactionAside = true;
},
/**
* Removes a transaction from the selected transactions to categorize or match.
* @param {StorePlaidState} state
* @param {PayloadAction<{ id: string | number }>} action
*/
removeTransactionsToCategorizeSelected: (
state: StorePlaidState,
action: PayloadAction<{ id: string | number }>,
) => {
state.transactionsToCategorizeSelected =
state.transactionsToCategorizeSelected.filter(
(t) => t !== action.payload.id,
);
if (state.transactionsToCategorizeSelected.length === 0) {
state.openMatchingTransactionAside = false;
} else {
state.openMatchingTransactionAside = true;
}
},
/**
* Resets the selected transactions to categorize or match.
* @param {StorePlaidState} state
*/
resetTransactionsToCategorizeSelected: (state: StorePlaidState) => {
state.transactionsToCategorizeSelected = [];
state.openMatchingTransactionAside = false;
},
/**
* Enables/Disables the multiple selection to categorize or match.
* @param {StorePlaidState} state
* @param {PayloadAction<{ enable: boolean }>} action
*/
enableMultipleCategorization: (
state: StorePlaidState,
action: PayloadAction<{ enable: boolean }>,
) => {
state.enableMultipleCategorization = action.payload.enable;
},
}, },
}); });
@@ -111,6 +190,22 @@ export const {
resetUncategorizedTransactionsSelected, resetUncategorizedTransactionsSelected,
setExcludedTransactionsSelected, setExcludedTransactionsSelected,
resetExcludedTransactionsSelected, resetExcludedTransactionsSelected,
setTransactionsToCategorizeSelected,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected,
enableMultipleCategorization,
} = PlaidSlice.actions; } = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken; export const getPlaidToken = (state: any) => state.plaid.plaidToken;
export const getTransactionsToCategorizeSelected = (state: any) =>
state.plaid.transactionsToCategorizeSelected;
export const getOpenMatchingTransactionAside = (state: any) =>
state.plaid.openMatchingTransactionAside;
export const isMultipleCategorization = (state: any) =>
state.plaid.enableMultipleCategorization;
export const getTransactionsToCategorizeIdsSelected = (state: any) =>
state.plaid.transactionsToCategorizeSelected;

View File

@@ -124,7 +124,7 @@
} }
} }
.bp4-control.bp4-checkbox .bp4-control-indicator { .bp4-control.bp4-checkbox:not(.bp4-large) .bp4-control-indicator {
cursor: auto; cursor: auto;
&, &,