mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 13:50:31 +00:00
feat: wip multi-select transactions to categorization and matching
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,7 @@ export default class NewCashflowTransactionController extends BaseController {
|
||||
const categorizeDTO = omit(matchedObject, [
|
||||
'uncategorizedTransactionIds',
|
||||
]) as ICategorizeCashflowTransactioDTO;
|
||||
|
||||
const uncategorizedTransactionIds =
|
||||
matchedObject.uncategorizedTransactionIds;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface MatchedTransactionPOJO {
|
||||
export type MatchedTransactionsPOJO = {
|
||||
perfectMatches: Array<MatchedTransactionPOJO>;
|
||||
possibleMatches: Array<MatchedTransactionPOJO>;
|
||||
totalPending: number;
|
||||
};
|
||||
|
||||
export const ERRORS = {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user