feat: getting matched transactiosn from multi uncategorized transactions

This commit is contained in:
Ahmed Bouhuolia
2024-08-01 12:11:40 +02:00
parent f5e18fc1fe
commit 47dd767b3a
5 changed files with 51 additions and 31 deletions

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(
@@ -76,14 +81,15 @@ export default class GetCashflowAccounts extends BaseController {
next: NextFunction next: NextFunction
) { ) {
const { tenantId } = req; const { tenantId } = req;
const { transactionId } = req.params; const uncategorizeTransactionsIds: Array<number> =
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,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,19 +48,20 @@ 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 filtered = filter.transactionType const filtered = filter.transactionType
@@ -71,9 +73,8 @@ 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 {
@@ -90,20 +91,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

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

View File

@@ -16,6 +16,7 @@ import { MatchTransactionsTypes } from './MatchTransactionsTypes';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { import {
sumMatchTranasctions, sumMatchTranasctions,
sumUncategorizedTransactions,
validateUncategorizedTransactionsExcluded, validateUncategorizedTransactionsExcluded,
validateUncategorizedTransactionsNotMatched, validateUncategorizedTransactionsNotMatched,
} from './_utils'; } from './_utils';
@@ -95,11 +96,14 @@ 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);
// } }
} }
/** /**

View File

@@ -2,23 +2,22 @@ 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 { ERRORS, MatchedTransactionPOJO } from './types'; import { ERRORS, MatchedTransactionPOJO } from './types';
import { isEmpty } from 'lodash'; import { isEmpty, sumBy } from 'lodash';
import { ServiceError } from '@/exceptions'; 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);
}; };
@@ -32,15 +31,23 @@ export const sumMatchTranasctions = (transactions: Array<any>) => {
); );
}; };
export const sumUncategorizedTransactions = (
uncategorizedTransactions: Array<any>
) => {
return sumBy(uncategorizedTransactions, 'amount');
};
export const validateUncategorizedTransactionsNotMatched = ( export const validateUncategorizedTransactionsNotMatched = (
uncategorizedTransactions: any uncategorizedTransactions: any
) => { ) => {
const isMatchedTransactions = uncategorizedTransactions.filter( const matchedTransactions = uncategorizedTransactions.filter(
(trans) => !isEmpty(trans.matchedBankTransactions) (trans) => !isEmpty(trans.matchedBankTransactions)
); );
// //
if (isMatchedTransactions.length > 0) { if (matchedTransactions.length > 0) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED); throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED, '', {
matchedTransactionsIds: matchedTransactions?.map((m) => m.id),
});
} }
}; };
@@ -51,6 +58,8 @@ export const validateUncategorizedTransactionsExcluded = (
(trans) => trans.excluded (trans) => trans.excluded
); );
if (excludedTransactions.length > 0) { if (excludedTransactions.length > 0) {
throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION); throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION, '', {
excludedTransactionsIds: excludedTransactions.map((e) => e.id),
});
} }
}; };