feat: wip multi-select transactions to categorization and matching

This commit is contained in:
Ahmed Bouhuolia
2024-08-03 22:01:21 +02:00
parent 5ce11f192f
commit d74337fb94
29 changed files with 476 additions and 155 deletions

View File

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

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

@@ -201,6 +201,7 @@ export default class NewCashflowTransactionController extends BaseController {
const categorizeDTO = omit(matchedObject, [
'uncategorizedTransactionIds',
]) as ICategorizeCashflowTransactioDTO;
const uncategorizedTransactionIds =
matchedObject.uncategorizedTransactionIds;

View File

@@ -64,6 +64,8 @@ export class GetMatchedTransactions {
.whereIn('id', uncategorizedTransactionIds)
.throwIfNotFound();
const totalPending = Math.abs(sumBy(uncategorizedTransactions, 'amount'));
const filtered = filter.transactionType
? this.registered.filter((item) => item.type === filter.transactionType)
: this.registered;
@@ -80,6 +82,7 @@ export class GetMatchedTransactions {
return {
perfectMatches,
possibleMatches,
totalPending,
};
}

View File

@@ -101,7 +101,7 @@ export class MatchBankTransactions {
);
// Validates the total given matching transcations whether is not equal
// uncategorized transaction amount.
if (totalUncategorizedTransactions === totalMatchedTranasctions) {
if (totalUncategorizedTransactions !== totalMatchedTranasctions) {
throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID);
}
}

View File

@@ -58,6 +58,7 @@ export interface MatchedTransactionPOJO {
export type MatchedTransactionsPOJO = {
perfectMatches: Array<MatchedTransactionPOJO>;
possibleMatches: Array<MatchedTransactionPOJO>;
totalPending: number;
};
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 { Inject, Service } from 'typedi';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { transformToMapBy } from '@/utils';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
import { BankRule } from '@/models/BankRule';
import { bankRulesMatchTransaction } from './_utils';

View File

@@ -58,14 +58,14 @@ export class CategorizeCashflowTransaction {
// Validates the transaction shouldn't be categorized before.
this.commandValidators.validateTransactionsShouldNotCategorized(
oldIncategorizedTransactions
oldUncategorizedTransactions
);
// Validate the uncateogirzed transaction if it's deposit the transaction direction
// should `IN` and the same thing if it's withdrawal the direction should be OUT.
// this.commandValidators.validateUncategorizeTransactionType(
// uncategorizedTransactions,
// categorizeDTO.transactionType
// );
this.commandValidators.validateUncategorizeTransactionType(
oldUncategorizedTransactions,
categorizeDTO.transactionType
);
// Edits the cashflow transaction under UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onTransactionCategorizing` event.
@@ -88,6 +88,7 @@ export class CategorizeCashflowTransaction {
tenantId,
cashflowTransactionDTO
);
// Updates the uncategorized transaction as categorized.
await UncategorizedCashflowTransaction.query(trx)
.whereIn('id', uncategorizedTransactionIds)
@@ -102,7 +103,6 @@ export class CategorizeCashflowTransaction {
'id',
uncategorizedTransactionIds
);
// Triggers `onCashflowTransactionCategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorized,

View File

@@ -1,5 +1,5 @@
import { Service } from 'typedi';
import { includes, camelCase, upperFirst } from 'lodash';
import { includes, camelCase, upperFirst, sumBy } from 'lodash';
import { IAccount, IUncategorizedCashflowTransaction } from '@/interfaces';
import { getCashflowTransactionType } from './utils';
import { ServiceError } from '@/exceptions';
@@ -74,7 +74,9 @@ export class CommandCashflowValidator {
const categorized = cashflowTransactions.filter((t) => t.categorized);
if (categorized?.length > 0) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED, '', {
ids: categorized.map((t) => t.id),
});
}
}
@@ -85,17 +87,19 @@ export class CommandCashflowValidator {
* @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)}
*/
public validateUncategorizeTransactionType(
uncategorizeTransaction: IUncategorizedCashflowTransaction,
uncategorizeTransactions: Array<IUncategorizedCashflowTransaction>,
transactionType: string
) {
const amount = sumBy(uncategorizeTransactions, 'amount');
const isDepositTransaction = amount > 0;
const isWithdrawalTransaction = amount <= 0;
const type = getCashflowTransactionType(
transactionType as CASHFLOW_TRANSACTION_TYPE
);
if (
(type.direction === CASHFLOW_DIRECTION.IN &&
uncategorizeTransaction.isDepositTransaction) ||
(type.direction === CASHFLOW_DIRECTION.OUT &&
uncategorizeTransaction.isWithdrawalTransaction)
(type.direction === CASHFLOW_DIRECTION.IN && isDepositTransaction) ||
(type.direction === CASHFLOW_DIRECTION.OUT && isWithdrawalTransaction)
) {
return;
}

View File

@@ -34,12 +34,13 @@ export class DecrementUncategorizedTransactionOnCategorize {
*/
public async decrementUnCategorizedTransactionsOnCategorized({
tenantId,
uncategorizedTransaction,
uncategorizedTransactions,
}: ICashflowTransactionCategorizedPayload) {
const { Account } = this.tenancy.models(tenantId);
const accountIds = uncategorizedTransactions.map((a) => a.id);
await Account.query()
.findById(uncategorizedTransaction.accountId)
.whereIn('id', accountIds)
.decrement('uncategorizedTransactions', 1);
}

View File

@@ -1,6 +1,5 @@
import { upperFirst, camelCase, first, sum, sumBy } from 'lodash';
import {
CASHFLOW_DIRECTION,
CASHFLOW_TRANSACTION_TYPE,
CASHFLOW_TRANSACTION_TYPE_META,
ERRORS,
@@ -81,6 +80,8 @@ export const validateUncategorizedTransactionsNotExcluded = (
const excluded = transactions.filter((tran) => tran.excluded);
if (excluded?.length > 0) {
throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION);
throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION, '', {
ids: excluded.map((t) => t.id),
});
}
};