feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,47 @@
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { BankingMatchingApplication } from './BankingMatchingApplication';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto';
@Controller('banking/matching')
@ApiTags('banking-transactions-matching')
export class BankingMatchingController {
constructor(
private readonly bankingMatchingApplication: BankingMatchingApplication
) {}
@Get('matched/transactions')
@ApiOperation({ summary: 'Retrieves the matched transactions.' })
async getMatchedTransactions(
@Query('uncategorizedTransactionIds') uncategorizedTransactionIds: number[],
@Query() filter: GetMatchedTransactionsFilter
) {
return this.bankingMatchingApplication.getMatchedTransactions(
uncategorizedTransactionIds,
filter
);
}
@Post('/match/:uncategorizedTransactionId')
@ApiOperation({ summary: 'Match the given uncategorized transaction.' })
async matchTransaction(
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number | number[],
@Body() matchedTransactions: MatchBankTransactionDto
) {
return this.bankingMatchingApplication.matchTransaction(
uncategorizedTransactionId,
matchedTransactions
);
}
@Post('/unmatch/:uncategorizedTransactionId')
@ApiOperation({ summary: 'Unmatch the given uncategorized transaction.' })
async unmatchMatchedTransaction(
@Param('uncategorizedTransactionId') uncategorizedTransactionId: number
) {
return this.bankingMatchingApplication.unmatchMatchedTransaction(
uncategorizedTransactionId
);
}
}

View File

@@ -0,0 +1,57 @@
import { Module } from '@nestjs/common';
import { MatchedBankTransaction } from './models/MatchedBankTransaction';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { BankingMatchingApplication } from './BankingMatchingApplication';
import { GetMatchedTransactions } from './queries/GetMatchedTransactions.service';
import { UnmatchMatchedBankTransaction } from './commands/UnmatchMatchedTransaction.service';
import { GetMatchedTransactionsByBills } from './queries/GetMatchedTransactionsByBills.service';
import { GetMatchedTransactionsByCashflow } from './queries/GetMatchedTransactionsByCashflow';
import { GetMatchedTransactionsByExpenses } from './queries/GetMatchedTransactionsByExpenses';
import { GetMatchedTransactionsByInvoices } from './queries/GetMatchedTransactionsByInvoices.service';
import { ValidateMatchingOnExpenseDeleteSubscriber } from './events/ValidateMatchingOnExpenseDelete';
import { ValidateMatchingOnPaymentReceivedDeleteSubscriber } from './events/ValidateMatchingOnPaymentReceivedDelete';
import { DecrementUncategorizedTransactionOnMatchingSubscriber } from './events/DecrementUncategorizedTransactionsOnMatch';
import { ValidateMatchingOnPaymentMadeDeleteSubscriber } from './events/ValidateMatchingOnPaymentMadeDelete';
import { ValidateMatchingOnManualJournalDeleteSubscriber } from './events/ValidateMatchingOnManualJournalDelete';
import { ValidateMatchingOnCashflowDeleteSubscriber } from './events/ValidateMatchingOnCashflowDelete';
import { BillPaymentsModule } from '../BillPayments/BillPayments.module';
import { BankingTransactionsModule } from '../BankingTransactions/BankingTransactions.module';
import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module';
import { MatchBankTransactions } from './commands/MatchTransactions';
import { MatchTransactionsTypes } from './commands/MatchTransactionsTypes';
import { GetMatchedTransactionsByManualJournals } from './queries/GetMatchedTransactionsByManualJournals.service';
import { ValidateTransactionMatched } from './commands/ValidateTransactionsMatched.service';
import { BankingMatchingController } from './BankingMatching.controller';
const models = [RegisterTenancyModel(MatchedBankTransaction)];
@Module({
controllers: [BankingMatchingController],
imports: [
...models,
BillPaymentsModule,
BankingTransactionsModule,
PaymentsReceivedModule,
],
providers: [
ValidateTransactionMatched,
MatchBankTransactions,
MatchTransactionsTypes,
GetMatchedTransactionsByBills,
GetMatchedTransactionsByCashflow,
GetMatchedTransactionsByExpenses,
GetMatchedTransactionsByInvoices,
GetMatchedTransactionsByManualJournals,
BankingMatchingApplication,
GetMatchedTransactions,
UnmatchMatchedBankTransaction,
ValidateMatchingOnExpenseDeleteSubscriber,
ValidateMatchingOnPaymentReceivedDeleteSubscriber,
DecrementUncategorizedTransactionOnMatchingSubscriber,
ValidateMatchingOnPaymentMadeDeleteSubscriber,
ValidateMatchingOnManualJournalDeleteSubscriber,
ValidateMatchingOnCashflowDeleteSubscriber,
],
exports: [...models],
})
export class BankingMatchingModule {}

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { GetMatchedTransactions } from './queries/GetMatchedTransactions.service';
import { MatchBankTransactions } from './commands/MatchTransactions';
import { UnmatchMatchedBankTransaction } from './commands/UnmatchMatchedTransaction.service';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
import { MatchBankTransactionDto } from './dtos/MatchBankTransaction.dto';
@Injectable()
export class BankingMatchingApplication {
constructor(
private readonly getMatchedTransactionsService: GetMatchedTransactions,
private readonly matchTransactionService: MatchBankTransactions,
private readonly unmatchMatchedTransactionService: UnmatchMatchedBankTransaction,
) {}
/**
* Retrieves the matched transactions.
* @param {Array<number>} uncategorizedTransactionsIds - Uncategorized transactions ids.
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public getMatchedTransactions(
uncategorizedTransactionsIds: Array<number>,
filter: GetMatchedTransactionsFilter,
) {
return this.getMatchedTransactionsService.getMatchedTransactions(
uncategorizedTransactionsIds,
filter,
);
}
/**
* Matches the given uncategorized transaction with the given system transaction.
* @param {number} uncategorizedTransactionId
* @param {IMatchTransactionDTO} matchTransactionsDTO
* @returns {Promise<void>}
*/
public matchTransaction(
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: MatchBankTransactionDto,
): Promise<void> {
return this.matchTransactionService.matchTransaction(
uncategorizedTransactionId,
matchedTransactions,
);
}
/**
* Unmatch the given matched transaction.
* @param {number} uncategorizedTransactionId - Uncategorized transaction id.
* @returns {Promise<void>}
*/
public unmatchMatchedTransaction(uncategorizedTransactionId: number) {
return this.unmatchMatchedTransactionService.unmatchMatchedTransaction(
uncategorizedTransactionId,
);
}
}

View File

@@ -0,0 +1,64 @@
import * as moment from 'moment';
import * as R from 'ramda';
import { isEmpty, sumBy } from 'lodash';
import { ERRORS, MatchedTransactionPOJO } from './types';
import { ServiceError } from '../Items/ServiceError';
export const sortClosestMatchTransactions = (
amount: number,
date: Date,
matches: MatchedTransactionPOJO[]
) => {
return R.sortWith([
// Sort by amount difference (closest to uncategorized transaction amount first)
R.ascend((match: MatchedTransactionPOJO) =>
Math.abs(match.amount - amount)
),
// Sort by date difference (closest to uncategorized transaction date first)
R.ascend((match: MatchedTransactionPOJO) =>
Math.abs(moment(match.date).diff(moment(date), 'days'))
),
])(matches);
};
export const sumMatchTranasctions = (transactions: Array<any>) => {
return transactions.reduce(
(total, item) =>
total +
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
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

@@ -0,0 +1,153 @@
import { castArray } from 'lodash';
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { PromisePool } from '@supercharge/promise-pool';
import {
ERRORS,
IBankTransactionMatchedEventPayload,
IBankTransactionMatchingEventPayload,
IMatchTransactionDTO,
} from '../types';
import { MatchTransactionsTypes } from './MatchTransactionsTypes';
import {
sumMatchTranasctions,
sumUncategorizedTransactions,
validateUncategorizedTransactionsExcluded,
validateUncategorizedTransactionsNotMatched,
} from '../_utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { ServiceError } from '@/modules/Items/ServiceError';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { MatchBankTransactionDto } from '../dtos/MatchBankTransaction.dto';
@Injectable()
export class MatchBankTransactions {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
private readonly matchedBankTransactions: MatchTransactionsTypes,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {}
/**
* Validates the match bank transactions DTO.
* @param {number} uncategorizedTransactionId - Uncategorized transaction id.
* @param {IMatchTransactionsDTO} matchTransactionsDTO - Match transactions DTO.
* @returns {Promise<void>}
*/
async validate(
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: Array<IMatchTransactionDTO>,
) {
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
// Validates the uncategorized transaction existance.
const uncategorizedTransactions =
await this.uncategorizedBankTransactionModel()
.query()
.whereIn('id', uncategorizedTransactionIds)
.withGraphFetched('matchedBankTransactions')
.throwIfNotFound();
// Validates the uncategorized transaction is not already matched.
validateUncategorizedTransactionsNotMatched(uncategorizedTransactions);
// Validate the uncategorized transaction is not excluded.
validateUncategorizedTransactionsExcluded(uncategorizedTransactions);
// Validates the given matched transaction.
const validateMatchedTransaction = async (matchedTransaction) => {
const getMatchedTransactionsService =
this.matchedBankTransactions.registry.get(
matchedTransaction.referenceType,
);
if (!getMatchedTransactionsService) {
throw new ServiceError(
ERRORS.RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID,
);
}
const foundMatchedTransaction =
await getMatchedTransactionsService.getMatchedTransaction(
matchedTransaction.referenceId,
);
if (!foundMatchedTransaction) {
throw new ServiceError(ERRORS.RESOURCE_ID_MATCHING_TRANSACTION_INVALID);
}
return foundMatchedTransaction;
};
// Matches the given transactions under promise pool concurrency controlling.
const validatationResult = await PromisePool.withConcurrency(10)
.for(matchedTransactions)
.process(validateMatchedTransaction);
if (validatationResult.errors?.length > 0) {
const error = validatationResult.errors.map((er) => er.raw)[0];
throw new ServiceError(error);
}
// Calculate the total given matching transactions.
const totalMatchedTranasctions = sumMatchTranasctions(
validatationResult.results,
);
const totalUncategorizedTransactions = sumUncategorizedTransactions(
uncategorizedTransactions,
);
// Validates the total given matching transcations whether is not equal
// uncategorized transaction amount.
if (totalUncategorizedTransactions !== totalMatchedTranasctions) {
throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID);
}
}
/**
* Matches the given uncategorized transaction to the given references.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns {Promise<void>}
*/
public async matchTransaction(
uncategorizedTransactionId: number | Array<number>,
matchedTransactionsDto: MatchBankTransactionDto,
): Promise<void> {
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
const matchedTransactions = matchedTransactionsDto.entries;
// Validates the given matching transactions DTO.
await this.validate(uncategorizedTransactionIds, matchedTransactions);
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers the event `onBankTransactionMatching`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatching, {
uncategorizedTransactionIds,
matchedTransactions,
trx,
} as IBankTransactionMatchingEventPayload);
// Matches the given transactions under promise pool concurrency controlling.
await PromisePool.withConcurrency(10)
.for(matchedTransactions)
.process(async (matchedTransaction) => {
const getMatchedTransactionsService =
this.matchedBankTransactions.registry.get(
matchedTransaction.referenceType,
);
await getMatchedTransactionsService.createMatchedTransaction(
uncategorizedTransactionIds,
matchedTransaction,
trx,
);
});
// Triggers the event `onBankTransactionMatched`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatched, {
uncategorizedTransactionIds,
matchedTransactions,
trx,
} as IBankTransactionMatchedEventPayload);
});
}
}

View File

@@ -0,0 +1,63 @@
import { GetMatchedTransactionsByExpenses } from '../queries/GetMatchedTransactionsByExpenses';
import { GetMatchedTransactionsByBills } from '../queries/GetMatchedTransactionsByBills.service';
import { GetMatchedTransactionsByManualJournals } from '../queries/GetMatchedTransactionsByManualJournals.service';
import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry';
import { GetMatchedTransactionsByInvoices } from '../queries/GetMatchedTransactionsByInvoices.service';
import { GetMatchedTransactionCashflowTransformer } from '../queries/GetMatchedTransactionCashflowTransformer';
import { GetMatchedTransactionsByCashflow } from '../queries/GetMatchedTransactionsByCashflow';
import { Injectable } from '@nestjs/common';
@Injectable()
export class MatchTransactionsTypes {
private static registry: MatchTransactionsTypesRegistry;
/**
* Consttuctor method.
*/
constructor() {
this.boot();
}
get registered() {
return [
{ type: 'SaleInvoice', service: GetMatchedTransactionsByInvoices },
{ type: 'Bill', service: GetMatchedTransactionsByBills },
{ type: 'Expense', service: GetMatchedTransactionsByExpenses },
{
type: 'ManualJournal',
service: GetMatchedTransactionsByManualJournals,
},
{
type: 'CashflowTransaction',
service: GetMatchedTransactionsByCashflow,
},
];
}
/**
* Importable instances.
*/
private types = [];
/**
*
*/
public get registry() {
return MatchTransactionsTypes.registry;
}
/**
* Boots all the registered importables.
*/
public boot() {
if (!MatchTransactionsTypes.registry) {
const instance = MatchTransactionsTypesRegistry.getInstance();
this.registered.forEach((registered) => {
// const serviceInstanace = Container.get(registered.service);
// instance.register(registered.type, serviceInstanace);
});
MatchTransactionsTypes.registry = instance;
}
}
}

View File

@@ -0,0 +1,50 @@
import { camelCase, upperFirst } from 'lodash';
import { GetMatchedTransactionsByType } from '../queries/GetMatchedTransactionsByType';
export class MatchTransactionsTypesRegistry {
private static instance: MatchTransactionsTypesRegistry;
private importables: Record<string, GetMatchedTransactionsByType>;
constructor() {
this.importables = {};
}
/**
* Gets singleton instance of registry.
* @returns {MatchTransactionsTypesRegistry}
*/
public static getInstance(): MatchTransactionsTypesRegistry {
if (!MatchTransactionsTypesRegistry.instance) {
MatchTransactionsTypesRegistry.instance =
new MatchTransactionsTypesRegistry();
}
return MatchTransactionsTypesRegistry.instance;
}
/**
* Registers the given importable service.
* @param {string} resource
* @param {GetMatchedTransactionsByType} importable
*/
public register(
resource: string,
importable: GetMatchedTransactionsByType
): void {
const _resource = this.sanitizeResourceName(resource);
this.importables[_resource] = importable;
}
/**
* Retrieves the importable service instance of the given resource name.
* @param {string} name
* @returns {GetMatchedTransactionsByType}
*/
public get(name: string): GetMatchedTransactionsByType {
const _name = this.sanitizeResourceName(name);
return this.importables[_name];
}
private sanitizeResourceName(resource: string) {
return upperFirst(camelCase(resource));
}
}

View File

@@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { IBankTransactionUnmatchingEventPayload } from '../types';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { MatchedBankTransaction } from '../models/MatchedBankTransaction';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class UnmatchMatchedBankTransaction {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>,
) {}
/**
* Unmatch the matched the given uncategorized bank transaction.
* @param {number} uncategorizedTransactionId - Uncategorized transaction id.
* @returns {Promise<void>}
*/
public unmatchMatchedTransaction(
uncategorizedTransactionId: number,
): Promise<void> {
return this.uow.withTransaction(async (trx) => {
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, {
uncategorizedTransactionId,
trx,
} as IBankTransactionUnmatchingEventPayload);
await this.matchedBankTransactionModel()
.query(trx)
.where('uncategorizedTransactionId', uncategorizedTransactionId)
.delete();
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, {
uncategorizedTransactionId,
trx,
} as IBankTransactionUnmatchingEventPayload);
});
}
}

View File

@@ -0,0 +1,39 @@
import { Knex } from 'knex';
import { ERRORS } from '../types';
import { Inject, Injectable } from '@nestjs/common';
import { ServiceError } from '@/modules/Items/ServiceError';
import { MatchedBankTransaction } from '../models/MatchedBankTransaction';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class ValidateTransactionMatched {
constructor(
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>,
) {}
/**
* Validate the given transaction whether is matched with bank transactions.
* @param {string} referenceType - Transaction reference type.
* @param {number} referenceId - Transaction reference id.
* @returns {Promise<void>}
*/
public async validateTransactionNoMatchLinking(
referenceType: string,
referenceId: number,
trx?: Knex.Transaction,
) {
const foundMatchedTransaction = await this.matchedBankTransactionModel()
.query(trx)
.findOne({
referenceType,
referenceId,
});
if (foundMatchedTransaction) {
throw new ServiceError(ERRORS.CANNOT_DELETE_TRANSACTION_MATCHED);
}
}
}

View File

@@ -0,0 +1,41 @@
import {
IsArray,
IsNotEmpty,
IsNumber,
IsString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class MatchTransactionEntryDto {
@IsString()
@IsNotEmpty()
@ApiProperty({
description: 'The type of the reference',
example: 'SaleInvoice',
})
referenceType: string;
@IsNumber()
@IsNotEmpty()
@ApiProperty({
description: 'The ID of the reference',
example: 1,
})
referenceId: number;
}
export class MatchBankTransactionDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => MatchTransactionEntryDto)
@ApiProperty({
description: 'The entries to match',
example: [
{ referenceType: 'SaleInvoice', referenceId: 1 },
{ referenceType: 'SaleInvoice', referenceId: 2 },
],
})
entries: MatchTransactionEntryDto[];
}

View File

@@ -0,0 +1,67 @@
import {
IBankTransactionMatchedEventPayload,
IBankTransactionUnmatchedEventPayload,
} from '../types';
import PromisePool from '@supercharge/promise-pool';
import { OnEvent } from '@nestjs/event-emitter';
import { Account } from '@/modules/Accounts/models/Account.model';
import { Inject, Injectable } from '@nestjs/common';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DecrementUncategorizedTransactionOnMatchingSubscriber {
constructor(
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
@OnEvent(events.bankMatch.onMatched)
public async decrementUnCategorizedTransactionsOnMatching({
uncategorizedTransactionIds,
trx,
}: IBankTransactionMatchedEventPayload) {
const uncategorizedTransactions =
await this.uncategorizedBankTransactionModel()
.query()
.whereIn('id', uncategorizedTransactionIds);
await PromisePool.withConcurrency(1)
.for(uncategorizedTransactions)
.process(async (transaction) => {
await this.accountModel()
.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
});
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
@OnEvent(events.bankMatch.onUnmatched)
public async incrementUnCategorizedTransactionsOnUnmatching({
uncategorizedTransactionId,
trx,
}: IBankTransactionUnmatchedEventPayload) {
const transaction = await this.uncategorizedBankTransactionModel()
.query()
.findById(uncategorizedTransactionId);
await this.accountModel()
.query(trx)
.findById(transaction.accountId)
.increment('uncategorizedTransactions', 1);
}
}

View File

@@ -0,0 +1,28 @@
import { OnEvent } from '@nestjs/event-emitter';
import { Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
import { ICommandCashflowDeletingPayload } from '@/modules/BankingTransactions/types/BankingTransactions.types';
@Injectable()
export class ValidateMatchingOnCashflowDeleteSubscriber {
constructor(
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
) {}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
@OnEvent(events.cashflow.onTransactionDeleting)
public async validateMatchingOnCashflowDeleting({
oldCashflowTransaction,
trx,
}: ICommandCashflowDeletingPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
'CashflowTransaction',
oldCashflowTransaction.id,
trx,
);
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
import { IExpenseEventDeletePayload } from '@/modules/Expenses/interfaces/Expenses.interface';
import { events } from '@/common/events/events';
@Injectable()
export class ValidateMatchingOnExpenseDeleteSubscriber {
constructor(
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
) {}
/**
* Validates the expense transaction whether matched with bank transaction on deleting.
* @param {IExpenseEventDeletePayload}
*/
@OnEvent(events.expenses.onDeleting)
public async validateMatchingOnExpenseDeleting({
oldExpense,
trx,
}: IExpenseEventDeletePayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
'Expense',
oldExpense.id,
trx,
);
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
import { OnEvent } from '@nestjs/event-emitter';
import { IManualJournalDeletingPayload } from '@/modules/ManualJournals/types/ManualJournals.types';
import { events } from '@/common/events/events';
@Injectable()
export class ValidateMatchingOnManualJournalDeleteSubscriber {
constructor(
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
) {}
/**
* Validates the manual journal transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
@OnEvent(events.manualJournals.onDeleting)
public async validateMatchingOnManualJournalDeleting({
oldManualJournal,
trx,
}: IManualJournalDeletingPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
'ManualJournal',
oldManualJournal.id,
trx,
);
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
import { IBillPaymentEventDeletedPayload } from '@/modules/BillPayments/types/BillPayments.types';
import { events } from '@/common/events/events';
@Injectable()
export class ValidateMatchingOnPaymentMadeDeleteSubscriber {
constructor(
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
) {}
/**
* Validates the payment made transaction whether matched with bank transaction on deleting.
* @param {IPaymentReceivedDeletedPayload}
*/
@OnEvent(events.billPayment.onDeleting)
public async validateMatchingOnPaymentMadeDeleting({
oldBillPayment,
trx,
}: IBillPaymentEventDeletedPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
'PaymentMade',
oldBillPayment.id,
trx,
);
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service';
import { OnEvent } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { IPaymentReceivedDeletedPayload } from '@/modules/PaymentReceived/types/PaymentReceived.types';
@Injectable()
export class ValidateMatchingOnPaymentReceivedDeleteSubscriber {
constructor(
private readonly validateNoMatchingLinkedService: ValidateTransactionMatched,
) {}
/**
* Validates the payment received transaction whether matched with bank transaction on deleting.
* @param {IPaymentReceivedDeletedPayload}
*/
@OnEvent(events.paymentReceive.onDeleting)
public async validateMatchingOnPaymentReceivedDeleting({
oldPaymentReceive,
trx,
}: IPaymentReceivedDeletedPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
'PaymentReceive',
oldPaymentReceive.id,
trx,
);
}
}

View File

@@ -0,0 +1,36 @@
import { BaseModel } from '@/models/Model';
export class MatchedBankTransaction extends BaseModel {
public referenceId!: number;
public referenceType!: string;
public uncategorizedTransactionId!: number;
/**
* Table name.
*/
static get tableName() {
return 'matched_bank_transactions';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
return {};
}
}

View File

@@ -0,0 +1,131 @@
import { Transformer } from "@/modules/Transformer/Transformer";
export class GetMatchedTransactionBillsTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceId',
'referenceType',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve the reference number of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected referenceNo(bill) {
return bill.referenceNo;
}
/**
* Retrieve the amount of the bill.
* @param {Object} bill - The bill object.
* @returns {number}
*/
protected amount(bill) {
return bill.amount;
}
/**
* Retrieve the formatted amount of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected amountFormatted(bill) {
return this.formatNumber(bill.amount, {
currencyCode: bill.currencyCode,
money: true,
});
}
/**
* Retrieve the date of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected date(bill) {
return bill.billDate;
}
/**
* Retrieve the formatted date of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected dateFormatted(bill) {
return this.formatDate(bill.billDate);
}
/**
* Retrieve the transcation id of the bill.
* @param {Object} bill - The bill object.
* @returns {number}
*/
protected transactionId(bill) {
return bill.id;
}
/**
* Retrieve the manual journal transaction type.
* @returns {string}
*/
protected transactionType() {
return 'Bill';
}
/**
* Retrieves the manual journal formatted transaction type.
* @returns {string}
*/
protected transsactionTypeFormatted() {
return 'Bill';
}
/**
* Retrieves the bill transaction normal (debit or credit).
* @returns {string}
*/
protected transactionNormal() {
return 'credit';
}
/**
* Retrieve the match transaction reference id.
* @param bill
* @returns {number}
*/
protected referenceId(bill) {
return bill.id;
}
/**
* Retrieve the match transaction referenece type.
* @returns {string}
*/
protected referenceType() {
return 'Bill';
}
}

View File

@@ -0,0 +1,142 @@
import { Transformer } from "@/modules/Transformer/Transformer";
export class GetMatchedTransactionCashflowTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceId',
'referenceType',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve the invoice reference number.
* @returns {string}
*/
protected referenceNo(invoice) {
return invoice.referenceNo;
}
/**
* Retrieve the transaction amount.
* @param transaction
* @returns {number}
*/
protected amount(transaction) {
return transaction.amount;
}
/**
* Retrieve the transaction formatted amount.
* @param transaction
* @returns {string}
*/
protected amountFormatted(transaction) {
return this.formatNumber(transaction.amount, {
currencyCode: transaction.currencyCode,
money: true,
});
}
/**
* Retrieve the date of the invoice.
* @param invoice
* @returns {Date}
*/
protected date(transaction) {
return transaction.date;
}
/**
* Format the date of the invoice.
* @param invoice
* @returns {string}
*/
protected dateFormatted(transaction) {
return this.formatDate(transaction.date);
}
/**
* Retrieve the transaction ID of the invoice.
* @param invoice
* @returns {number}
*/
protected transactionId(transaction) {
return transaction.id;
}
/**
* Retrieve the invoice transaction number.
* @param invoice
* @returns {string}
*/
protected transactionNo(transaction) {
return transaction.transactionNumber;
}
/**
* Retrieve the invoice transaction type.
* @param invoice
* @returns {String}
*/
protected transactionType(transaction) {
return transaction.transactionType;
}
/**
* Retrieve the invoice formatted transaction type.
* @param invoice
* @returns {string}
*/
protected transsactionTypeFormatted(transaction) {
return transaction.transactionTypeFormatted;
}
/**
* Retrieve the cashflow transaction normal (credit or debit).
* @param transaction
* @returns {string}
*/
protected transactionNormal(transaction) {
return transaction.isCashCredit ? 'credit' : 'debit';
}
/**
* Retrieves the cashflow transaction reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
/**
* Retrieves the cashflow transaction reference type.
* @returns {string}
*/
protected referenceType() {
return 'CashflowTransaction';
}
}

View File

@@ -0,0 +1,142 @@
import { Transformer } from "@/modules/Transformer/Transformer";
export class GetMatchedTransactionExpensesTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceType',
'referenceId',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieves the expense reference number.
* @param expense
* @returns {string}
*/
protected referenceNo(expense) {
return expense.referenceNo;
}
/**
* Retrieves the expense amount.
* @param expense
* @returns {number}
*/
protected amount(expense) {
return expense.totalAmount;
}
/**
* Formats the amount of the expense.
* @param expense
* @returns {string}
*/
protected amountFormatted(expense) {
return this.formatNumber(expense.totalAmount, {
currencyCode: expense.currencyCode,
money: true,
});
}
/**
* Retrieves the date of the expense.
* @param expense
* @returns {Date}
*/
protected date(expense) {
return expense.paymentDate;
}
/**
* Formats the date of the expense.
* @param expense
* @returns {string}
*/
protected dateFormatted(expense) {
return this.formatDate(expense.paymentDate);
}
/**
* Retrieves the transaction ID of the expense.
* @param expense
* @returns {number}
*/
protected transactionId(expense) {
return expense.id;
}
/**
* Retrieves the expense transaction number.
* @param expense
* @returns {string}
*/
protected transactionNo(expense) {
return expense.expenseNo;
}
/**
* Retrieves the expense transaction type.
* @param expense
* @returns {String}
*/
protected transactionType() {
return 'Expense';
}
/**
* Retrieves the formatted transaction type of the expense.
* @param expense
* @returns {string}
*/
protected transsactionTypeFormatted() {
return 'Expense';
}
/**
* Retrieve the expense transaction normal (credit or debit).
* @returns {string}
*/
protected transactionNormal() {
return 'credit';
}
/**
* Retrieve the transaction reference type.
* @returns {string}
*/
protected referenceType() {
return 'Expense';
}
/**
* Retrieve the transaction reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
}

View File

@@ -0,0 +1,138 @@
import { Transformer } from "@/modules/Transformer/Transformer";
export class GetMatchedTransactionInvoicesTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceType',
'referenceId'
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve the invoice reference number.
* @returns {string}
*/
protected referenceNo(invoice) {
return invoice.referenceNo;
}
/**
* Retrieve the invoice amount.
* @param invoice
* @returns {number}
*/
protected amount(invoice) {
return invoice.dueAmount;
}
/**
* Format the amount of the invoice.
* @param invoice
* @returns {string}
*/
protected amountFormatted(invoice) {
return this.formatNumber(invoice.dueAmount, {
currencyCode: invoice.currencyCode,
money: true,
});
}
/**
* Retrieve the date of the invoice.
* @param invoice
* @returns {Date}
*/
protected date(invoice) {
return invoice.invoiceDate;
}
/**
* Format the date of the invoice.
* @param invoice
* @returns {string}
*/
protected dateFormatted(invoice) {
return this.formatDate(invoice.invoiceDate);
}
/**
* Retrieve the transaction ID of the invoice.
* @param invoice
* @returns {number}
*/
protected transactionId(invoice) {
return invoice.id;
}
/**
* Retrieve the invoice transaction number.
* @param invoice
* @returns {string}
*/
protected transactionNo(invoice) {
return invoice.invoiceNo;
}
/**
* Retrieve the invoice transaction type.
* @param invoice
* @returns {String}
*/
protected transactionType(invoice) {
return 'SaleInvoice';
}
/**
* Retrieve the invoice formatted transaction type.
* @param invoice
* @returns {string}
*/
protected transsactionTypeFormatted(invoice) {
return 'Sale invoice';
}
/**
* Retrieve the transaction normal of invoice (credit or debit).
* @returns {string}
*/
protected transactionNormal() {
return 'debit';
}
/**
* Retrieve the transaction reference type.
* @returns {string}
*/ protected referenceType() {
return 'SaleInvoice';
}
/**
* Retrieve the transaction reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
}

View File

@@ -0,0 +1,149 @@
import { sumBy } from 'lodash';
import { AccountNormal } from '@/interfaces/Account';
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetMatchedTransactionManualJournalsTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceType',
'referenceId',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieves the manual journal reference no.
* @param manualJournal
* @returns {string}
*/
protected referenceNo(manualJournal) {
return manualJournal.referenceNo;
}
protected total(manualJournal) {
const credit = sumBy(manualJournal?.entries, 'credit');
const debit = sumBy(manualJournal?.entries, 'debit');
return debit - credit;
}
/**
* Retrieves the manual journal amount.
* @param manualJournal
* @returns {number}
*/
protected amount(manualJournal) {
return Math.abs(this.total(manualJournal));
}
/**
* Retrieves the manual journal formatted amount.
* @param manualJournal
* @returns {string}
*/
protected amountFormatted(manualJournal) {
return this.formatNumber(manualJournal.amount, {
currencyCode: manualJournal.currencyCode,
money: true,
});
}
/**
* Retreives the manual journal date.
* @param manualJournal
* @returns {Date}
*/
protected date(manualJournal) {
return manualJournal.date;
}
/**
* Retrieves the manual journal formatted date.
* @param manualJournal
* @returns {string}
*/
protected dateFormatted(manualJournal) {
return this.formatDate(manualJournal.date);
}
/**
* Retrieve the manual journal transaction id.
* @returns {number}
*/
protected transactionId(manualJournal) {
return manualJournal.id;
}
/**
* Retrieve the manual journal transaction number.
* @param manualJournal
*/
protected transactionNo(manualJournal) {
return manualJournal.journalNumber;
}
/**
* Retrieve the manual journal transaction type.
* @returns {string}
*/
protected transactionType() {
return 'ManualJournal';
}
/**
* Retrieves the manual journal formatted transaction type.
* @returns {string}
*/
protected transsactionTypeFormatted() {
return 'Manual Journal';
}
/**
* Retrieve the manual journal transaction normal (credit or debit).
* @returns {string}
*/
protected transactionNormal(transaction) {
const amount = this.total(transaction);
return amount >= 0 ? AccountNormal.DEBIT : AccountNormal.CREDIT;
}
/**
* Retrieve the manual journal reference type.
* @returns {string}
*/
protected referenceType() {
return 'ManualJournal';
}
/**
* Retrieves the manual journal reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
}

View File

@@ -0,0 +1,114 @@
import * as R from 'ramda';
import * as moment from 'moment';
import { first, sumBy } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { Inject, Injectable } from '@nestjs/common';
import {
GetMatchedTransactionsFilter,
MatchedTransactionsPOJO,
} from '../types';
import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses';
import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills.service';
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals.service';
import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow';
import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices.service';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { sortClosestMatchTransactions } from '../_utils';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetMatchedTransactions {
constructor(
private readonly getMatchedInvoicesService: GetMatchedTransactionsByInvoices,
private readonly getMatchedBillsService: GetMatchedTransactionsByBills,
private readonly getMatchedManualJournalService: GetMatchedTransactionsByManualJournals,
private readonly getMatchedExpensesService: GetMatchedTransactionsByExpenses,
private readonly getMatchedCashflowService: GetMatchedTransactionsByCashflow,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {}
/**
* Registered matched transactions types.
*/
get registered() {
return [
{ type: 'SaleInvoice', service: this.getMatchedInvoicesService },
{ type: 'Bill', service: this.getMatchedBillsService },
{ type: 'Expense', service: this.getMatchedExpensesService },
{ type: 'ManualJournal', service: this.getMatchedManualJournalService },
{ type: 'Cashflow', service: this.getMatchedCashflowService },
];
}
/**
* Retrieves the matched transactions.
* @param {Array<number>} uncategorizedTransactionIds - Uncategorized transactions ids.
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public async getMatchedTransactions(
uncategorizedTransactionIds: Array<number>,
filter: GetMatchedTransactionsFilter,
): Promise<MatchedTransactionsPOJO> {
const uncategorizedTransactions =
await this.uncategorizedBankTransactionModel()
.query()
.whereIn('id', uncategorizedTransactionIds)
.throwIfNotFound();
const totalPending = sumBy(uncategorizedTransactions, 'amount');
const filtered = filter.transactionType
? this.registered.filter((item) => item.type === filter.transactionType)
: this.registered;
const matchedTransactions = await PromisePool.withConcurrency(2)
.for(filtered)
.process(async ({ type, service }) => {
return service.getMatchedTransactions(filter);
});
const { perfectMatches, possibleMatches } = this.groupMatchedResults(
uncategorizedTransactions,
matchedTransactions,
);
return {
perfectMatches,
possibleMatches,
totalPending,
};
}
/**
* Groups the given results for getting perfect and possible matches
* based on the given uncategorized transaction.
* @param uncategorizedTransaction
* @param matchedTransactions
* @returns {MatchedTransactionsPOJO}
*/
private groupMatchedResults(
uncategorizedTransactions: Array<any>,
matchedTransactions,
): MatchedTransactionsPOJO {
const results = R.compose(R.flatten)(matchedTransactions?.results);
const firstUncategorized = first(uncategorizedTransactions);
const amount = sumBy(uncategorizedTransactions, 'amount');
const date = firstUncategorized.date;
// Sort the results based on amount, date, and transaction type
const closestResullts = sortClosestMatchTransactions(amount, date, results);
const perfectMatches = R.filter(
(match) =>
match.amount === amount && moment(match.date).isSame(date, 'day'),
closestResullts,
);
const possibleMatches = R.difference(closestResullts, perfectMatches);
const totalPending = sumBy(uncategorizedTransactions, 'amount');
return { perfectMatches, possibleMatches, totalPending };
}
}

View File

@@ -0,0 +1,141 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { first } from 'lodash';
import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer';
import {
GetMatchedTransactionsFilter,
IMatchTransactionDTO,
MatchedTransactionPOJO,
} from '../types';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { CreateBillPaymentService } from '@/modules/BillPayments/commands/CreateBillPayment.service';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Bill } from '@/modules/Bills/models/Bill';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateBillPaymentDto } from '@/modules/BillPayments/dtos/BillPayment.dto';
@Injectable()
export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType {
constructor(
private readonly createPaymentMadeService: CreateBillPaymentService,
private readonly transformer: TransformerInjectable,
@Inject(Bill.name)
private readonly billModel: TenantModelProxy<typeof Bill>,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {
super();
}
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter -
*/
public async getMatchedTransactions(filter: GetMatchedTransactionsFilter) {
// Retrieves the bill matches.
const bills = await this.billModel()
.query()
.onBuild((q) => {
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
q.modify('published');
if (filter.fromDate) {
q.where('billDate', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('billDate', '<=', filter.toDate);
}
q.orderBy('billDate', 'DESC');
});
return this.transformer.transform(
bills,
new GetMatchedTransactionBillsTransformer(),
);
}
/**
* Retrieves the given bill matched transaction.
* @param {number} tenantId
* @param {number} transactionId
* @returns {Promise<MatchedTransactionPOJO>}
*/
public async getMatchedTransaction(
transactionId: number,
): Promise<MatchedTransactionPOJO> {
const bill = await this.billModel()
.query()
.findById(transactionId)
.throwIfNotFound();
return this.transformer.transform(
bill,
new GetMatchedTransactionBillsTransformer(),
);
}
/**
* Creates the common matched transaction.
* @param {number} tenantId
* @param {Array<number>} uncategorizedTransactionIds
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx
*/
public async createMatchedTransaction(
uncategorizedTransactionIds: Array<number>,
matchTransactionDTO: IMatchTransactionDTO,
trx?: Knex.Transaction,
): Promise<void> {
await super.createMatchedTransaction(
uncategorizedTransactionIds,
matchTransactionDTO,
trx,
);
const uncategorizedTransactionId = first(uncategorizedTransactionIds);
const uncategorizedTransaction =
await this.uncategorizedBankTransactionModel()
.query(trx)
.findById(uncategorizedTransactionId)
.throwIfNotFound();
const bill = await this.billModel()
.query(trx)
.findById(matchTransactionDTO.referenceId)
.throwIfNotFound();
const createPaymentMadeDTO: CreateBillPaymentDto = {
vendorId: bill.vendorId,
paymentAccountId: uncategorizedTransaction.accountId,
paymentDate: uncategorizedTransaction.date,
exchangeRate: 1,
entries: [
{
paymentAmount: bill.dueAmount,
billId: bill.id,
},
],
branchId: bill.branchId,
};
// Create a new bill payment associated to the matched bill.
const billPayment = await this.createPaymentMadeService.createBillPayment(
createPaymentMadeDTO,
trx,
);
// Link the create bill payment with matched transaction.
await super.createMatchedTransaction(
uncategorizedTransactionIds,
{
referenceType: 'BillPayment',
referenceId: billPayment.id,
},
trx,
);
}
}

View File

@@ -0,0 +1,80 @@
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
import { GetMatchedTransactionsFilter } from '../types';
import { BankTransaction } from '@/modules/BankingTransactions/models/BankTransaction';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByType {
constructor(
private readonly transformer: TransformerInjectable,
@Inject(BankTransaction.name)
private readonly bankTransactionModel: TenantModelProxy<
typeof BankTransaction
>,
) {
super();
}
/**
* Retrieve the matched transactions of cash flow.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>,
) {
const transactions = await this.bankTransactionModel()
.query()
.onBuild((q) => {
// Not matched to bank transaction.
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
// Not categorized.
q.modify('notCategorized');
// Published.
q.modify('published');
if (filter.fromDate) {
q.where('date', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('date', '<=', filter.toDate);
}
q.orderBy('date', 'DESC');
});
return this.transformer.transform(
transactions,
new GetMatchedTransactionCashflowTransformer(),
);
}
/**
* Retrieves the matched transaction of cash flow.
* @param {number} tenantId
* @param {number} transactionId
* @returns
*/
async getMatchedTransaction(transactionId: number) {
const transactions = await this.bankTransactionModel()
.query()
.findById(transactionId)
.withGraphJoined('matchedBankTransaction')
.whereNull('matchedBankTransaction.id')
.modify('notCategorized')
.modify('published')
.throwIfNotFound();
return this.transformer.transform(
transactions,
new GetMatchedTransactionCashflowTransformer(),
);
}
}

View File

@@ -0,0 +1,77 @@
import { Inject, Injectable } from '@nestjs/common';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from '../types';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { Expense } from '@/modules/Expenses/models/Expense.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType {
constructor(
protected readonly transformer: TransformerInjectable,
@Inject(Expense.name)
protected readonly expenseModel: TenantModelProxy<typeof Expense>,
) {
super();
}
/**
* Retrieves the matched transactions of expenses.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(filter: GetMatchedTransactionsFilter) {
// Retrieve the expense matches.
const expenses = await this.expenseModel()
.query()
.onBuild((query) => {
// Filter out the not matched to bank transactions.
query.withGraphJoined('matchedBankTransaction');
query.whereNull('matchedBankTransaction.id');
// Filter the published onyl
query.modify('filterByPublished');
if (filter.fromDate) {
query.where('paymentDate', '>=', filter.fromDate);
}
if (filter.toDate) {
query.where('paymentDate', '<=', filter.toDate);
}
if (filter.minAmount) {
query.where('totalAmount', '>=', filter.minAmount);
}
if (filter.maxAmount) {
query.where('totalAmount', '<=', filter.maxAmount);
}
query.orderBy('paymentDate', 'DESC');
});
return this.transformer.transform(
expenses,
new GetMatchedTransactionExpensesTransformer(),
);
}
/**
* Retrieves the given matched expense transaction.
* @param {number} tenantId
* @param {number} transactionId
* @returns {GetMatchedTransactionExpensesTransformer-}
*/
public async getMatchedTransaction(
transactionId: number,
): Promise<MatchedTransactionPOJO> {
const expense = await this.expenseModel()
.query()
.findById(transactionId)
.throwIfNotFound();
return this.transformer.transform(
expense,
new GetMatchedTransactionExpensesTransformer(),
);
}
}

View File

@@ -0,0 +1,145 @@
import { Knex } from 'knex';
import { first } from 'lodash';
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
import {
GetMatchedTransactionsFilter,
IMatchTransactionDTO,
MatchedTransactionPOJO,
MatchedTransactionsPOJO,
} from '../types';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { CreatePaymentReceivedService } from '@/modules/PaymentReceived/commands/CreatePaymentReceived.serivce';
import { Inject, Injectable } from '@nestjs/common';
import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction';
import { IPaymentReceivedCreateDTO } from '@/modules/PaymentReceived/types/PaymentReceived.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByType {
constructor(
private readonly transformer: TransformerInjectable,
private readonly createPaymentReceivedService: CreatePaymentReceivedService,
@Inject(SaleInvoice.name)
private readonly saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>,
@Inject(UncategorizedBankTransaction.name)
private readonly uncategorizedBankTransactionModel: TenantModelProxy<
typeof UncategorizedBankTransaction
>,
) {
super();
}
/**
* Retrieves the matched transactions.
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public async getMatchedTransactions(
filter: GetMatchedTransactionsFilter,
): Promise<MatchedTransactionsPOJO> {
// Retrieve the invoices that not matched, unpaid.
const invoices = await this.saleInvoiceModel()
.query()
.onBuild((q) => {
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
q.modify('unpaid');
q.modify('published');
if (filter.fromDate) {
q.where('invoiceDate', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('invoiceDate', '<=', filter.toDate);
}
q.orderBy('invoiceDate', 'DESC');
});
return this.transformer.transform(
invoices,
new GetMatchedTransactionInvoicesTransformer(),
);
}
/**
* Retrieves the matched transaction.
* @param {number} tenantId
* @param {number} transactionId
* @returns {Promise<MatchedTransactionPOJO>}
*/
public async getMatchedTransaction(
transactionId: number,
): Promise<MatchedTransactionPOJO> {
const invoice = await this.saleInvoiceModel()
.query()
.findById(transactionId);
return this.transformer.transform(
invoice,
new GetMatchedTransactionInvoicesTransformer(),
);
}
/**
* Creates the common matched transaction.
* @param {number} tenantId
* @param {Array<number>} uncategorizedTransactionIds
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx
*/
public async createMatchedTransaction(
uncategorizedTransactionIds: Array<number>,
matchTransactionDTO: IMatchTransactionDTO,
trx?: Knex.Transaction,
) {
await super.createMatchedTransaction(
uncategorizedTransactionIds,
matchTransactionDTO,
trx,
);
const uncategorizedTransactionId = first(uncategorizedTransactionIds);
const uncategorizedTransaction =
await this.uncategorizedBankTransactionModel()
.query(trx)
.findById(uncategorizedTransactionId)
.throwIfNotFound();
const invoice = await SaleInvoice.query(trx)
.findById(matchTransactionDTO.referenceId)
.throwIfNotFound();
const createPaymentReceivedDTO: IPaymentReceivedCreateDTO = {
customerId: invoice.customerId,
paymentDate: uncategorizedTransaction.date,
amount: invoice.dueAmount,
depositAccountId: uncategorizedTransaction.accountId,
entries: [
{
index: 1,
invoiceId: invoice.id,
paymentAmount: invoice.dueAmount,
},
],
branchId: invoice.branchId,
};
// Create a payment received associated to the matched invoice.
const paymentReceived =
await this.createPaymentReceivedService.createPaymentReceived(
createPaymentReceivedDTO,
trx,
);
// Link the create payment received with matched invoice transaction.
await super.createMatchedTransaction(
uncategorizedTransactionIds,
{
referenceType: 'PaymentReceive',
referenceId: paymentReceived.id,
},
trx,
);
}
}

View File

@@ -0,0 +1,78 @@
import { Inject, Injectable } from '@nestjs/common';
import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionsFilter } from '../types';
import { ManualJournal } from '@/modules/ManualJournals/models/ManualJournal';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType {
constructor(
private readonly transformer: TransformerInjectable,
@Inject(ManualJournal.name)
private readonly manualJournalModel: TenantModelProxy<typeof ManualJournal>,
) {
super();
}
/**
* Retrieve the matched transactions of manual journals.
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>,
) {
// @todo: get the account id from the filter
const accountId = 1000;
const manualJournals = await this.manualJournalModel()
.query()
.onBuild((query) => {
query.withGraphJoined('matchedBankTransaction');
query.whereNull('matchedBankTransaction.id');
query.withGraphJoined('entries');
query.where('entries.accountId', accountId);
query.modify('filterByPublished');
if (filter.fromDate) {
query.where('date', '>=', filter.fromDate);
}
if (filter.toDate) {
query.where('date', '<=', filter.toDate);
}
if (filter.minAmount) {
query.where('amount', '>=', filter.minAmount);
}
if (filter.maxAmount) {
query.where('amount', '<=', filter.maxAmount);
}
});
return this.transformer.transform(
manualJournals,
new GetMatchedTransactionManualJournalsTransformer(),
);
}
/**
* Retrieves the matched transaction of manual journals.
* @param {number} tenantId
* @param {number} transactionId
* @returns
*/
public async getMatchedTransaction(transactionId: number) {
const manualJournal = await this.manualJournalModel()
.query()
.findById(transactionId)
.whereNotExists(ManualJournal.relatedQuery('matchedBankTransaction'))
.throwIfNotFound();
return this.transformer.transform(
manualJournal,
new GetMatchedTransactionManualJournalsTransformer(),
);
}
}

View File

@@ -0,0 +1,69 @@
import { Knex } from 'knex';
import {
GetMatchedTransactionsFilter,
IMatchTransactionDTO,
MatchedTransactionPOJO,
MatchedTransactionsPOJO,
} from '../types';
import PromisePool from '@supercharge/promise-pool';
import { MatchedBankTransaction } from '../models/MatchedBankTransaction';
import { Inject } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
export abstract class GetMatchedTransactionsByType {
@Inject(MatchedBankTransaction.name)
private readonly matchedBankTransactionModel: TenantModelProxy<
typeof MatchedBankTransaction
>;
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public async getMatchedTransactions(
filter: GetMatchedTransactionsFilter,
): Promise<MatchedTransactionsPOJO> {
throw new Error(
'The `getMatchedTransactions` method is not defined for the transaction type.',
);
}
/**
* Retrieves the matched transaction details.
* @param {number} tenantId -
* @param {number} transactionId -
* @returns {Promise<MatchedTransactionPOJO>}
*/
public async getMatchedTransaction(
transactionId: number,
): Promise<MatchedTransactionPOJO> {
throw new Error(
'The `getMatchedTransaction` method is not defined for the transaction type.',
);
}
/**
* Creates the common matched transaction.
* @param {number} tenantId
* @param {Array<number>} uncategorizedTransactionIds
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx
*/
public async createMatchedTransaction(
uncategorizedTransactionIds: Array<number>,
matchTransactionDTO: IMatchTransactionDTO,
trx?: Knex.Transaction,
) {
await PromisePool.withConcurrency(2)
.for(uncategorizedTransactionIds)
.process(async (uncategorizedTransactionId) => {
await this.matchedBankTransactionModel().query(trx).insert({
uncategorizedTransactionId,
referenceType: matchTransactionDTO.referenceType,
referenceId: matchTransactionDTO.referenceId,
});
});
}
}

View File

@@ -0,0 +1,73 @@
import { Knex } from 'knex';
export interface IBankTransactionMatchingEventPayload {
tenantId: number;
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction;
}
export interface IBankTransactionMatchedEventPayload {
// tenantId: number;
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction;
}
export interface IBankTransactionUnmatchingEventPayload {
// tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction;
}
export interface IBankTransactionUnmatchedEventPayload {
// tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction;
}
export interface IMatchTransactionDTO {
referenceType: string;
referenceId: number;
}
export interface IMatchTransactionsDTO {
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
}
export interface GetMatchedTransactionsFilter {
fromDate: string;
toDate: string;
minAmount: number;
maxAmount: number;
transactionType: string;
}
export interface MatchedTransactionPOJO {
amount: number;
amountFormatted: string;
date: string;
dateFormatted: string;
referenceNo: string;
transactionNo: string;
transactionId: number;
transactionType: string;
}
export type MatchedTransactionsPOJO = {
perfectMatches: Array<MatchedTransactionPOJO>;
possibleMatches: Array<MatchedTransactionPOJO>;
totalPending: number;
};
export const ERRORS = {
RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID:
'RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID',
RESOURCE_ID_MATCHING_TRANSACTION_INVALID:
'RESOURCE_ID_MATCHING_TRANSACTION_INVALID',
TOTAL_MATCHING_TRANSACTIONS_INVALID: 'TOTAL_MATCHING_TRANSACTIONS_INVALID',
TRANSACTION_ALREADY_MATCHED: 'TRANSACTION_ALREADY_MATCHED',
CANNOT_MATCH_EXCLUDED_TRANSACTION: 'CANNOT_MATCH_EXCLUDED_TRANSACTION',
CANNOT_DELETE_TRANSACTION_MATCHED: 'CANNOT_DELETE_TRANSACTION_MATCHED',
};