From 738a84bb4b0d1e2aed92c4444eb8d73b6f270dc3 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 20 Jun 2024 23:31:46 +0200 Subject: [PATCH] feat: match bank transaction --- .../BankTransactionsMatchingController.ts | 12 ++- .../ExcludeBankTransactionsController.ts | 2 +- .../Banking/ReconcileBankController.ts | 30 ++++++ packages/server/src/models/Bill.ts | 16 +++ .../server/src/models/CashflowTransaction.ts | 17 +++ packages/server/src/models/Expense.ts | 16 +++ .../src/models/MatchedBankTransaction.ts | 10 +- packages/server/src/models/SaleInvoice.ts | 16 +++ .../UncategorizedCashflowTransaction.ts | 13 +++ ...hedTransactionManualJournalsTransformer.ts | 4 +- .../Matching/GetMatchedTransactions.ts | 17 +-- .../Matching/GetMatchedTransactionsByBills.ts | 6 +- .../GetMatchedTransactionsByExpenses.ts | 7 +- .../GetMatchedTransactionsByInvoices.ts | 38 ++++++- .../GetMatchedTransactionsByManualJournals.ts | 33 +++++- .../Matching/GetMatchedTransactionsByType.ts | 65 +++++++++++ .../Banking/Matching/MatchTransactions.ts | 102 +++++++++++++++--- .../Matching/MatchTransactionsTypes.ts | 57 ++++++++++ .../MatchTransactionsTypesRegistry.ts | 50 +++++++++ .../src/services/Banking/Matching/types.ts | 36 +++++-- 20 files changed, 492 insertions(+), 55 deletions(-) create mode 100644 packages/server/src/api/controllers/Banking/ReconcileBankController.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts create mode 100644 packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts create mode 100644 packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts diff --git a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts index 98e000cb8..d49fef2ef 100644 --- a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -2,7 +2,7 @@ import { Inject, Service } from 'typedi'; import { NextFunction, Request, Response, Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication'; -import { param } from 'express-validator'; +import { body, param } from 'express-validator'; import { GetMatchedTransactionsFilter, IMatchTransactionDTO, @@ -21,7 +21,14 @@ export class BankTransactionsMatchingController extends BaseController { router.post( '/:transactionId', - [param('transactionId').exists()], + [ + param('transactionId').exists(), + + body('matchedTransactions').isArray({ min: 1 }), + body('matchedTransactions.*.reference_type').exists(), + body('matchedTransactions.*.reference_id').isNumeric().toInt(), + body('matchedTransactions.*.amount').exists().isNumeric().toFloat(), + ], this.validationResult, this.matchBankTransaction.bind(this) ); @@ -60,7 +67,6 @@ export class BankTransactionsMatchingController extends BaseController { transactionId, matchTransactionDTO ); - return res.status(200).send({ message: 'The bank transaction has been matched.', }); diff --git a/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts index 6cf3a92d3..880bb4705 100644 --- a/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts +++ b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts @@ -7,7 +7,7 @@ import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/E @Service() export class ExcludeBankTransactionsController extends BaseController { @Inject() - prviate excludeBankTransactionApp: ExcludeBankTransactionsApplication; + private excludeBankTransactionApp: ExcludeBankTransactionsApplication; /** * Router constructor. diff --git a/packages/server/src/api/controllers/Banking/ReconcileBankController.ts b/packages/server/src/api/controllers/Banking/ReconcileBankController.ts new file mode 100644 index 000000000..4aa08a295 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/ReconcileBankController.ts @@ -0,0 +1,30 @@ +import { body } from 'express-validator'; +import BaseController from '../BaseController'; +import { Router } from 'express'; +import { Service } from 'typedi'; + +@Service() +export class BankReconcileController extends BaseController { + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/', + [ + body('amount').exists(), + body('date').exists(), + body('fromAccountId').exists(), + body('toAccountId').exists(), + body('reference').optional({ nullable: true }), + ], + this.validationResult, + this.createBankReconcileTransaction.bind(this) + ); + return router; + } + + createBankReconcileTransaction() {} +} diff --git a/packages/server/src/models/Bill.ts b/packages/server/src/models/Bill.ts index 439669ad5..422865cb0 100644 --- a/packages/server/src/models/Bill.ts +++ b/packages/server/src/models/Bill.ts @@ -404,6 +404,7 @@ export default class Bill extends mixin(TenantModel, [ const Branch = require('models/Branch'); const TaxRateTransaction = require('models/TaxRateTransaction'); const Document = require('models/Document'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { vendor: { @@ -485,6 +486,21 @@ export default class Bill extends mixin(TenantModel, [ query.where('model_ref', 'Bill'); }, }, + + /** + * Bill may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'bills.id', + to: 'matched_bank_transactions.referenceId', + }, + filter(query) { + query.where('reference_type', 'Bill'); + }, + }, }; } diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index 3cc2baba7..c5aadbccb 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -102,6 +102,7 @@ export default class CashflowTransaction extends TenantModel { const CashflowTransactionLine = require('models/CashflowTransactionLine'); const AccountTransaction = require('models/AccountTransaction'); const Account = require('models/Account'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { /** @@ -158,6 +159,22 @@ export default class CashflowTransaction extends TenantModel { to: 'accounts.id', }, }, + + /** + * Cashflow transaction may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'cashflow_transactions.id', + to: 'matched_bank_transactions.referenceId', + }, + filter: (query) => { + const referenceTypes = getCashflowAccountTransactionsTypes(); + query.whereIn('reference_type', referenceTypes); + }, + }, }; } } diff --git a/packages/server/src/models/Expense.ts b/packages/server/src/models/Expense.ts index 21fa0ac9c..17e0b273d 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -182,6 +182,7 @@ export default class Expense extends mixin(TenantModel, [ const ExpenseCategory = require('models/ExpenseCategory'); const Document = require('models/Document'); const Branch = require('models/Branch'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { paymentAccount: { @@ -234,6 +235,21 @@ export default class Expense extends mixin(TenantModel, [ query.where('model_ref', 'Expense'); }, }, + + /** + * Expense may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'expenses_transactions.id', + to: 'matched_bank_transactions.referenceId', + }, + filter(query) { + query.where('reference_type', 'Expense'); + }, + }, }; } diff --git a/packages/server/src/models/MatchedBankTransaction.ts b/packages/server/src/models/MatchedBankTransaction.ts index 5d025cbbb..4d5df6315 100644 --- a/packages/server/src/models/MatchedBankTransaction.ts +++ b/packages/server/src/models/MatchedBankTransaction.ts @@ -1,4 +1,5 @@ import TenantModel from 'models/TenantModel'; +import { Model } from 'objection'; export class MatchedBankTransaction extends TenantModel { /** @@ -12,7 +13,7 @@ export class MatchedBankTransaction extends TenantModel { * Timestamps columns. */ get timestamps() { - return []; + return ['createdAt', 'updatedAt']; } /** @@ -21,4 +22,11 @@ export class MatchedBankTransaction extends TenantModel { static get virtualAttributes() { return []; } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } } diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 76a3b437e..49ceb1302 100644 --- a/packages/server/src/models/SaleInvoice.ts +++ b/packages/server/src/models/SaleInvoice.ts @@ -411,6 +411,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ const Account = require('models/Account'); const TaxRateTransaction = require('models/TaxRateTransaction'); const Document = require('models/Document'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { /** @@ -543,6 +544,21 @@ export default class SaleInvoice extends mixin(TenantModel, [ query.where('model_ref', 'SaleInvoice'); }, }, + + /** + * Sale invocie may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'sales_invoices.id', + to: "matched_bank_transactions.referenceId", + }, + filter(query) { + query.where('reference_type', 'SaleInvoice'); + }, + }, }; } diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 373da0a27..97da9eea1 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -97,6 +97,7 @@ export default class UncategorizedCashflowTransaction extends mixin( const { RecognizedBankTransaction, } = require('models/RecognizedBankTransaction'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { /** @@ -122,6 +123,18 @@ export default class UncategorizedCashflowTransaction extends mixin( to: 'recognized_bank_transactions.id', }, }, + + /** + * Uncategorized transaction may has association to matched transaction. + */ + matchedBankTransaction: { + relation: Model.BelongsToOneRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'uncategorized_cashflow_transactions.id', + to: 'matched_bank_transactions.uncategorizedTransactionId', + }, + }, }; } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts index d43307700..60fda4226 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -43,7 +43,7 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer * @returns {number} */ protected amount(manualJournal) { - return manualJournal.totalAmount; + return manualJournal.amount; } /** @@ -52,7 +52,7 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer * @returns {string} */ protected amountFormatted(manualJournal) { - return this.formatNumber(manualJournal.totalAmount, { + return this.formatNumber(manualJournal.amount, { currencyCode: manualJournal.currencyCode, }); } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts index ead394c97..0dc71e02a 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -1,7 +1,7 @@ import { Inject, Service } from 'typedi'; import * as R from 'ramda'; import { PromisePool } from '@supercharge/promise-pool'; -import { GetMatchedTransactionsFilter } from './types'; +import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types'; import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; @@ -20,6 +20,9 @@ export class GetMatchedTransactions { @Inject() private getMatchedExpensesService: GetMatchedTransactionsByExpenses; + /** + * Registered matched transactions types. + */ get registered() { return [ { type: 'SaleInvoice', service: this.getMatchedInvoicesService }, @@ -37,7 +40,7 @@ export class GetMatchedTransactions { public async getMatchedTransactions( tenantId: number, filter: GetMatchedTransactionsFilter - ) { + ): Promise { const filtered = filter.transactionType ? this.registered.filter((item) => item.type === filter.transactionType) : this.registered; @@ -50,13 +53,3 @@ export class GetMatchedTransactions { return R.compose(R.flatten)(matchedTransactions?.results); } } - -interface MatchedTransaction { - amount: number; - amountFormatted: string; - date: string; - dateFormatted: string; - referenceNo: string; - transactionNo: string; - transactionId: number; -} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts index 3e62bf319..7ac246bce 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts @@ -1,12 +1,12 @@ +import { Inject, Service } from 'typedi'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; -import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; import { GetMatchedTransactionsFilter } from './types'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { Inject, Service } from 'typedi'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() -export class GetMatchedTransactionsByBills { +export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType { @Inject() private tenancy: HasTenancyService; diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts index c28ce6900..8480e29b2 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts @@ -3,14 +3,15 @@ import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTran import { GetMatchedTransactionsFilter } from './types'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() -export class GetMatchedTransactionsByExpenses { +export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType { @Inject() - private tenancy: HasTenancyService; + protected tenancy: HasTenancyService; @Inject() - private transformer: TransformerInjectable; + protected transformer: TransformerInjectable; /** * diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts index 73185e776..3e67a2180 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -1,16 +1,21 @@ import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; -import { GetMatchedTransactionsFilter } from './types'; +import { + GetMatchedTransactionsFilter, + MatchedTransactionPOJO, + MatchedTransactionsPOJO, +} from './types'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() -export class GetMatchedTransactionsByInvoices { +export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByType { @Inject() - private tenancy: HasTenancyService; + protected tenancy: HasTenancyService; @Inject() - private transformer: TransformerInjectable; + protected transformer: TransformerInjectable; /** * Retrieves the matched transactions. @@ -20,7 +25,7 @@ export class GetMatchedTransactionsByInvoices { public async getMatchedTransactions( tenantId: number, filter: GetMatchedTransactionsFilter - ) { + ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); const invoices = await SaleInvoice.query(); @@ -31,4 +36,27 @@ export class GetMatchedTransactionsByInvoices { new GetMatchedTransactionInvoicesTransformer() ); } + + /** + * + * @param {number} tenantId + * @param {number} transactionId + * @returns + */ + public async getMatchedTransaction( + tenantId: number, + transactionId: number + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + console.log(transactionId); + + const invoice = await SaleInvoice.query().findById(transactionId); + + return this.transformer.transform( + tenantId, + invoice, + new GetMatchedTransactionInvoicesTransformer() + ); + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts index ec33732eb..252ff1c82 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts @@ -1,17 +1,20 @@ import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; import { Inject, Service } from 'typedi'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; import { GetMatchedTransactionsFilter } from './types'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() -export class GetMatchedTransactionsByManualJournals { - @Inject() - private tenancy: HasTenancyService; - +export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType { @Inject() private transformer: TransformerInjectable; + /** + * Retrieve the matched transactions of manual journals. + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ async getMatchedTransactions( tenantId: number, filter: GetMatchedTransactionsFilter @@ -26,4 +29,24 @@ export class GetMatchedTransactionsByManualJournals { new GetMatchedTransactionManualJournalsTransformer() ); } + + /** + * Retrieves the matched transaction of manual journals. + * @param {number} tenantId + * @param {number} transactionId + * @returns + */ + async getMatchedTransaction(tenantId: number, transactionId: number) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const manualJournal = await ManualJournal.query() + .findById(transactionId) + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + manualJournal, + new GetMatchedTransactionManualJournalsTransformer() + ); + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts new file mode 100644 index 000000000..2d3ed07f8 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts @@ -0,0 +1,65 @@ +import { Knex } from 'knex'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, + MatchedTransactionPOJO, + MatchedTransactionsPOJO, +} from './types'; +import { Inject, Service } from 'typedi'; + +// @Service() +export abstract class GetMatchedTransactionsByType { + @Inject() + protected tenancy: HasTenancyService; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + public async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ): Promise { + throw new Error( + 'The `getMatchedTransactions` method is not defined for the transaction type.' + ); + } + + /** + * Retrieves the matched transaction details. + * @param {number} tenantId - + * @param {number} transactionId - + */ + public async getMatchedTransaction( + tenantId: number, + transactionId: number + ): Promise { + throw new Error( + 'The `getMatchedTransaction` method is not defined for the transaction type.' + ); + } + + /** + * + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionDTO} matchTransactionDTO + * @param {Knex.Transaction} trx + */ + public async createMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionDTO: IMatchTransactionDTO, + trx?: Knex.Transaction + ) { + const { MatchedBankTransaction } = this.tenancy.models(tenantId); + + await MatchedBankTransaction.query(trx).insert({ + uncategorizedTransactionId, + referenceType: matchTransactionDTO.referenceType, + referenceId: matchTransactionDTO.referenceId, + }); + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index 8f9ac6098..eedbd80d7 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -1,3 +1,4 @@ +import { sumBy } from 'lodash'; import { PromisePool } from '@supercharge/promise-pool'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import HasTenancyService from '@/services/Tenancy/TenancyService'; @@ -6,10 +7,13 @@ import events from '@/subscribers/events'; import { Knex } from 'knex'; import { Inject, Service } from 'typedi'; import { + ERRORS, IBankTransactionMatchedEventPayload, IBankTransactionMatchingEventPayload, - IMatchTransactionDTO, + IMatchTransactionsDTO, } from './types'; +import { MatchTransactionsTypes } from './MatchTransactionsTypes'; +import { ServiceError } from '@/exceptions'; @Service() export class MatchBankTransactions { @@ -22,22 +26,90 @@ export class MatchBankTransactions { @Inject() private eventPublisher: EventPublisher; + @Inject() + private matchedBankTransactions: MatchTransactionsTypes; + + /** + * Validates the match bank transactions DTO. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionsDTO} matchTransactionsDTO + */ + async validate( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionsDTO + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + const { matchedTransactions } = matchTransactionsDTO; + + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // 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( + tenantId, + 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 = sumBy( + validatationResult.results, + 'amount' + ); + // Validates the total given matching transcations whether is not equal + // uncategorized transaction amount. + if (totalMatchedTranasctions !== uncategorizedTransaction.amount) { + throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID); + } + } + /** * Matches the given uncategorized transaction to the given references. * @param {number} tenantId * @param {number} uncategorizedTransactionId */ - public matchTransaction( + public async matchTransaction( tenantId: number, uncategorizedTransactionId: number, - matchTransactionsDTO: IMatchTransactionDTO + matchTransactionsDTO: IMatchTransactionsDTO ) { const { matchedTransactions } = matchTransactionsDTO; - const { MatchBankTransaction } = this.tenancy.models(tenantId); - // + // Validates the given matching transactions DTO. + await this.validate( + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO + ); return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { - // Triggers the event `onSaleInvoiceCreated`. + // Triggers the event `onBankTransactionMatching`. await this.eventPublisher.emitAsync(events.bankMatch.onMatching, { tenantId, uncategorizedTransactionId, @@ -45,19 +117,23 @@ export class MatchBankTransactions { trx, } as IBankTransactionMatchingEventPayload); - // Matches the given transactions under promise pool concurrency controlling. + // Matches the given transactions under promise pool concurrency controlling. await PromisePool.withConcurrency(10) .for(matchedTransactions) .process(async (matchedTransaction) => { - await MatchBankTransaction.query(trx).insert({ + const getMatchedTransactionsService = + this.matchedBankTransactions.registry.get( + matchedTransaction.referenceType + ); + await getMatchedTransactionsService.createMatchedTransaction( + tenantId, uncategorizedTransactionId, - referenceType: matchedTransaction.referenceType, - referenceId: matchedTransaction.referenceId, - amount: matchedTransaction.amount, - }); + matchedTransaction, + trx + ); }); - // Triggers the event `onSaleInvoiceCreated`. + // Triggers the event `onBankTransactionMatched`. await this.eventPublisher.emitAsync(events.bankMatch.onMatched, { tenantId, uncategorizedTransactionId, diff --git a/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts new file mode 100644 index 000000000..6c5c938d4 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts @@ -0,0 +1,57 @@ +import Container, { Service } from 'typedi'; +import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; +import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; +import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; +import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry'; +import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices'; + +@Service() +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, + }, + ]; + } + + /** + * 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; + } + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts b/packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts new file mode 100644 index 000000000..a64ff0d6b --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts @@ -0,0 +1,50 @@ +import { camelCase, upperFirst } from 'lodash'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; + +export class MatchTransactionsTypesRegistry { + private static instance: MatchTransactionsTypesRegistry; + private importables: Record; + + 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)); + } +} diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts index c26e273ec..6fedf862f 100644 --- a/packages/server/src/services/Banking/Matching/types.ts +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -3,14 +3,14 @@ import { Knex } from 'knex'; export interface IBankTransactionMatchingEventPayload { tenantId: number; uncategorizedTransactionId: number; - matchTransactionsDTO: IMatchTransactionDTO; + matchTransactionsDTO: IMatchTransactionsDTO; trx?: Knex.Transaction; } export interface IBankTransactionMatchedEventPayload { tenantId: number; uncategorizedTransactionId: number; - matchTransactionsDTO: IMatchTransactionDTO; + matchTransactionsDTO: IMatchTransactionsDTO; trx?: Knex.Transaction; } @@ -23,11 +23,12 @@ export interface IBankTransactionUnmatchedEventPayload { } export interface IMatchTransactionDTO { - matchedTransactions: Array<{ - referenceType: string; - referenceId: number; - amount: number; - }>; + referenceType: string; + referenceId: number; +} + +export interface IMatchTransactionsDTO { + matchedTransactions: Array; } export interface GetMatchedTransactionsFilter { @@ -37,3 +38,24 @@ export interface GetMatchedTransactionsFilter { maxAmount: number; transactionType: string; } + +export interface MatchedTransactionPOJO { + amount: number; + amountFormatted: string; + date: string; + dateFormatted: string; + referenceNo: string; + transactionNo: string; + transactionId: number; +} + +export type MatchedTransactionsPOJO = Array; + +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', +};