diff --git a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts index d49fef2ef..437152140 100644 --- a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -23,11 +23,9 @@ export class BankTransactionsMatchingController extends BaseController { '/:transactionId', [ 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) diff --git a/packages/server/src/api/controllers/Banking/BankingRulesController.ts b/packages/server/src/api/controllers/Banking/BankingRulesController.ts index 360dbcad6..0fb91c0b1 100644 --- a/packages/server/src/api/controllers/Banking/BankingRulesController.ts +++ b/packages/server/src/api/controllers/Banking/BankingRulesController.ts @@ -50,6 +50,8 @@ export class BankingRulesController extends BaseController { body('assign_account_id').isInt({ min: 0 }), body('assign_payee').isString().optional({ nullable: true }), body('assign_memo').isString().optional({ nullable: true }), + + body('recognition').isBoolean().toBoolean().optional({ nullable: true }), ]; } diff --git a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js index 223ff403e..aed658e65 100644 --- a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js +++ b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js @@ -2,7 +2,7 @@ exports.up = function (knex) { return knex.schema.createTable('recognized_bank_transactions', (table) => { table.increments('id'); table - .integer('cashflow_transaction_id') + .integer('uncategorized_transaction_id') .unsigned() .references('id') .inTable('uncategorized_cashflow_transactions'); diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 22a5e1988..fd1586db1 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -103,6 +103,11 @@ import { AttachmentsOnCreditNote } from '@/services/Attachments/events/Attachmen import { AttachmentsOnBillPayments } from '@/services/Attachments/events/AttachmentsOnPaymentsMade'; import { AttachmentsOnSaleEstimates } from '@/services/Attachments/events/AttachmentsOnSaleEstimates'; import { TriggerRecognizedTransactions } from '@/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions'; +import { ValidateMatchingOnExpenseDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete'; +import { ValidateMatchingOnManualJournalDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete'; +import { ValidateMatchingOnPaymentReceivedDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete'; +import { ValidateMatchingOnPaymentMadeDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete'; +import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete'; export default () => { return new EventPublisher(); @@ -250,5 +255,12 @@ export const susbcribers = () => { // Bank Rules TriggerRecognizedTransactions, + + // Validate matching + ValidateMatchingOnCashflowDelete, + ValidateMatchingOnExpenseDelete, + ValidateMatchingOnManualJournalDelete, + ValidateMatchingOnPaymentReceivedDelete, + ValidateMatchingOnPaymentMadeDelete, ]; }; diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 97da9eea1..3d70ff7d0 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -127,8 +127,8 @@ export default class UncategorizedCashflowTransaction extends mixin( /** * Uncategorized transaction may has association to matched transaction. */ - matchedBankTransaction: { - relation: Model.BelongsToOneRelation, + matchedBankTransactions: { + relation: Model.HasManyRelation, modelClass: MatchedBankTransaction, join: { from: 'uncategorized_cashflow_transactions.id', diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts index 3e67a2180..89f1c3307 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -21,6 +21,7 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy * Retrieves the matched transactions. * @param {number} tenantId - * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} */ public async getMatchedTransactions( tenantId: number, @@ -38,10 +39,10 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy } /** - * + * Retrieves the matched transaction. * @param {number} tenantId * @param {number} transactionId - * @returns + * @returns {Promise} */ public async getMatchedTransaction( tenantId: number, @@ -49,8 +50,6 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); - console.log(transactionId); - const invoice = await SaleInvoice.query().findById(transactionId); return this.transformer.transform( diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index eedbd80d7..a86a17953 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -1,11 +1,11 @@ -import { sumBy } from 'lodash'; +import { isEmpty, sumBy } from 'lodash'; +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; import { PromisePool } from '@supercharge/promise-pool'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import UnitOfWork from '@/services/UnitOfWork'; import events from '@/subscribers/events'; -import { Knex } from 'knex'; -import { Inject, Service } from 'typedi'; import { ERRORS, IBankTransactionMatchedEventPayload, @@ -34,6 +34,7 @@ export class MatchBankTransactions { * @param {number} tenantId * @param {number} uncategorizedTransactionId * @param {IMatchTransactionsDTO} matchTransactionsDTO + * @returns {Promise} */ async validate( tenantId: number, @@ -43,11 +44,21 @@ export class MatchBankTransactions { const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const { matchedTransactions } = matchTransactionsDTO; + // Validates the uncategorized transaction existance. const uncategorizedTransaction = await UncategorizedCashflowTransaction.query() .findById(uncategorizedTransactionId) + .withGraphFetched('matchedBankTransactions') .throwIfNotFound(); + // Validates the uncategorized transaction is not already matched. + if (!isEmpty(uncategorizedTransaction.matchedBankTransactions)) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED); + } + // Validate the uncategorized transaction is not excluded. + if (uncategorizedTransaction.excluded) { + throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION); + } // Validates the given matched transaction. const validateMatchedTransaction = async (matchedTransaction) => { const getMatchedTransactionsService = diff --git a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts index 98c86d6e6..0f4a1def7 100644 --- a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts +++ b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts @@ -1,8 +1,8 @@ +import { Inject, Service } from 'typedi'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import UnitOfWork from '@/services/UnitOfWork'; import events from '@/subscribers/events'; -import { Inject, Service } from 'typedi'; import { IBankTransactionUnmatchingEventPayload } from './types'; @Service() @@ -16,10 +16,16 @@ export class UnmatchMatchedBankTransaction { @Inject() private eventPublisher: EventPublisher; + /** + * Unmatch the matched the given uncategorized bank transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ public unmatchMatchedTransaction( tenantId: number, uncategorizedTransactionId: number - ) { + ): Promise { const { MatchedBankTransaction } = this.tenancy.models(tenantId); return this.uow.withTransaction(tenantId, async (trx) => { diff --git a/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts new file mode 100644 index 000000000..372ae424c --- /dev/null +++ b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts @@ -0,0 +1,33 @@ +import { ServiceError } from '@/exceptions'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { ERRORS } from './types'; + +@Service() +export class ValidateTransactionMatched { + @Inject() + private tenancy: HasTenancyService; + + /** + * + * @param {number} tenantId + * @param {string} referenceType + * @param {number} referenceId + */ + public async validateTransactionNoMatchLinking( + tenantId: number, + referenceType: string, + referenceId: number + ) { + const { MatchedBankTransaction } = this.tenancy.models(tenantId); + + const foundMatchedTransaction = + await MatchedBankTransaction.query().findOne({ + referenceType, + referenceId, + }); + if (foundMatchedTransaction) { + throw new ServiceError(ERRORS.CANNOT_DELETE_TRANSACTION_MATCHED); + } + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts new file mode 100644 index 000000000..d41f2be11 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts @@ -0,0 +1,36 @@ +import { IManualJournalDeletingPayload } from '@/interfaces'; +import events from '@/subscribers/events'; +import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; +import { Inject, Service } from 'typedi'; + +@Service() +export class ValidateMatchingOnCashflowDelete { + @Inject() + private validateNoMatchingLinkedService: ValidateTransactionMatched; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.cashflow.onTransactionDeleting, + this.validateMatchingOnCashflowDelete.bind(this) + ); + } + + /** + * + * @param {IManualJournalDeletingPayload} + */ + public async validateMatchingOnCashflowDelete({ + tenantId, + oldManualJournal, + trx, + }: IManualJournalDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'ManualJournal', + oldManualJournal.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts new file mode 100644 index 000000000..2e45c0159 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import { IExpenseEventDeletePayload } from '@/interfaces'; +import events from '@/subscribers/events'; +import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; + +@Service() +export class ValidateMatchingOnExpenseDelete { + @Inject() + private validateNoMatchingLinkedService: ValidateTransactionMatched; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.expenses.onDeleting, + this.validateMatchingOnExpenseDelete.bind(this) + ); + } + + /** + * + * @param {IExpenseEventDeletePayload} + */ + public async validateMatchingOnExpenseDelete({ + tenantId, + oldExpense, + trx, + }: IExpenseEventDeletePayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'Expense', + oldExpense.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts new file mode 100644 index 000000000..f4ebfbeca --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import { IManualJournalDeletingPayload } from '@/interfaces'; +import events from '@/subscribers/events'; +import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; + +@Service() +export class ValidateMatchingOnManualJournalDelete { + @Inject() + private validateNoMatchingLinkedService: ValidateTransactionMatched; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.manualJournals.onDeleting, + this.validateMatchingOnManualJournalDelete.bind(this) + ); + } + + /** + * + * @param {IManualJournalDeletingPayload} + */ + public async validateMatchingOnManualJournalDelete({ + tenantId, + oldManualJournal, + trx, + }: IManualJournalDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'ManualJournal', + oldManualJournal.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts new file mode 100644 index 000000000..0188ed9dd --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts @@ -0,0 +1,39 @@ +import { Inject, Service } from 'typedi'; +import { + IBillPaymentEventDeletedPayload, + IPaymentReceiveDeletedPayload, +} from '@/interfaces'; +import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; +import events from '@/subscribers/events'; + +@Service() +export class ValidateMatchingOnPaymentMadeDelete { + @Inject() + private validateNoMatchingLinkedService: ValidateTransactionMatched; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.billPayment.onDeleting, + this.validateMatchingOnPaymentMadeDelete.bind(this) + ); + } + + /** + * + * @param {IPaymentReceiveDeletedPayload} + */ + public async validateMatchingOnPaymentMadeDelete({ + tenantId, + oldBillPayment, + trx, + }: IBillPaymentEventDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'PaymentMade', + oldBillPayment.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts new file mode 100644 index 000000000..e94020bb3 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import { IPaymentReceiveDeletedPayload } from '@/interfaces'; +import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; +import events from '@/subscribers/events'; + +@Service() +export class ValidateMatchingOnPaymentReceivedDelete { + @Inject() + private validateNoMatchingLinkedService: ValidateTransactionMatched; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.paymentReceive.onDeleting, + this.validateMatchingOnPaymentReceivedDelete.bind(this) + ); + } + + /** + * + * @param {IPaymentReceiveDeletedPayload} + */ + public async validateMatchingOnPaymentReceivedDelete({ + tenantId, + oldPaymentReceive, + trx, + }: IPaymentReceiveDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'PaymentReceive', + oldPaymentReceive.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts index 6fedf862f..74b64d0a7 100644 --- a/packages/server/src/services/Banking/Matching/types.ts +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -56,6 +56,8 @@ export const ERRORS = { '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' }; diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts index 74832fbbb..32b8a2542 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -32,7 +32,7 @@ export class RecognizeTranasctionsService { trx ).insert({ bankRuleId: bankRule.id, - cashflowTransactionId: transaction.id, + uncategorizedTransactionId: transaction.id, assignedCategory: bankRule.assignCategory, assignedAccountId: bankRule.assignAccountId, assignedPayee: bankRule.assignPayee, diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts index 3094cc520..bb8c87b43 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts @@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi'; import events from '@/subscribers/events'; import { IBankRuleEventCreatedPayload, + IBankRuleEventDeletedPayload, IBankRuleEventEditedPayload, } from '../../Rules/types'; @@ -20,17 +21,55 @@ export class TriggerRecognizedTransactions { ); bus.subscribe( events.bankRules.onEdited, - this.recognizedTransactionsOnRuleCreated.bind(this) + this.recognizedTransactionsOnRuleEdited.bind(this) + ); + bus.subscribe( + events.bankRules.onDeleted, + this.recognizedTransactionsOnRuleDeleted.bind(this) ); } /** - * Triggers the recognize uncategorized transactions job. - * @param {IBankRuleEventEditedPayload | IBankRuleEventCreatedPayload} payload - + * Triggers the recognize uncategorized transactions job on rule created. + * @param {IBankRuleEventCreatedPayload} payload - */ private async recognizedTransactionsOnRuleCreated({ tenantId, - }: IBankRuleEventEditedPayload | IBankRuleEventCreatedPayload) { + createRuleDTO, + }: IBankRuleEventCreatedPayload) { + const payload = { tenantId }; + + // Cannot run recognition if the option is not enabled. + if (createRuleDTO.recognition) { + return; + } + await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } + + /** + * Triggers the recognize uncategorized transactions job on rule edited. + * @param {IBankRuleEventEditedPayload} payload - + */ + private async recognizedTransactionsOnRuleEdited({ + tenantId, + editRuleDTO, + }: IBankRuleEventEditedPayload) { + const payload = { tenantId }; + + // Cannot run recognition if the option is not enabled. + if (!editRuleDTO.recognition) { + return; + } + await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } + + /** + * Triggers the recognize uncategorized transactions job on rule deleted. + * @param {IBankRuleEventDeletedPayload} payload - + */ + private async recognizedTransactionsOnRuleDeleted({ + tenantId, + }: IBankRuleEventDeletedPayload) { const payload = { tenantId }; await this.agenda.now('recognize-uncategorized-transactions-job', payload); } diff --git a/packages/server/src/services/Banking/Rules/types.ts b/packages/server/src/services/Banking/Rules/types.ts index 0701345f0..a2435204a 100644 --- a/packages/server/src/services/Banking/Rules/types.ts +++ b/packages/server/src/services/Banking/Rules/types.ts @@ -70,6 +70,8 @@ export interface IBankRuleCommonDTO { assignAccountId: number; assignPayee?: string; assignMemo?: string; + + recognition?: boolean; } export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {}