feat: validate the matched linked transacation on deleting.

This commit is contained in:
Ahmed Bouhuolia
2024-06-23 14:34:40 +02:00
parent ca403872b3
commit 589b29bbdd
18 changed files with 307 additions and 20 deletions

View File

@@ -23,11 +23,9 @@ export class BankTransactionsMatchingController extends BaseController {
'/:transactionId', '/:transactionId',
[ [
param('transactionId').exists(), param('transactionId').exists(),
body('matchedTransactions').isArray({ min: 1 }), body('matchedTransactions').isArray({ min: 1 }),
body('matchedTransactions.*.reference_type').exists(), body('matchedTransactions.*.reference_type').exists(),
body('matchedTransactions.*.reference_id').isNumeric().toInt(), body('matchedTransactions.*.reference_id').isNumeric().toInt(),
body('matchedTransactions.*.amount').exists().isNumeric().toFloat(),
], ],
this.validationResult, this.validationResult,
this.matchBankTransaction.bind(this) this.matchBankTransaction.bind(this)

View File

@@ -50,6 +50,8 @@ export class BankingRulesController extends BaseController {
body('assign_account_id').isInt({ min: 0 }), body('assign_account_id').isInt({ min: 0 }),
body('assign_payee').isString().optional({ nullable: true }), body('assign_payee').isString().optional({ nullable: true }),
body('assign_memo').isString().optional({ nullable: true }), body('assign_memo').isString().optional({ nullable: true }),
body('recognition').isBoolean().toBoolean().optional({ nullable: true }),
]; ];
} }

View File

@@ -2,7 +2,7 @@ exports.up = function (knex) {
return knex.schema.createTable('recognized_bank_transactions', (table) => { return knex.schema.createTable('recognized_bank_transactions', (table) => {
table.increments('id'); table.increments('id');
table table
.integer('cashflow_transaction_id') .integer('uncategorized_transaction_id')
.unsigned() .unsigned()
.references('id') .references('id')
.inTable('uncategorized_cashflow_transactions'); .inTable('uncategorized_cashflow_transactions');

View File

@@ -103,6 +103,11 @@ import { AttachmentsOnCreditNote } from '@/services/Attachments/events/Attachmen
import { AttachmentsOnBillPayments } from '@/services/Attachments/events/AttachmentsOnPaymentsMade'; import { AttachmentsOnBillPayments } from '@/services/Attachments/events/AttachmentsOnPaymentsMade';
import { AttachmentsOnSaleEstimates } from '@/services/Attachments/events/AttachmentsOnSaleEstimates'; import { AttachmentsOnSaleEstimates } from '@/services/Attachments/events/AttachmentsOnSaleEstimates';
import { TriggerRecognizedTransactions } from '@/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions'; 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 () => { export default () => {
return new EventPublisher(); return new EventPublisher();
@@ -250,5 +255,12 @@ export const susbcribers = () => {
// Bank Rules // Bank Rules
TriggerRecognizedTransactions, TriggerRecognizedTransactions,
// Validate matching
ValidateMatchingOnCashflowDelete,
ValidateMatchingOnExpenseDelete,
ValidateMatchingOnManualJournalDelete,
ValidateMatchingOnPaymentReceivedDelete,
ValidateMatchingOnPaymentMadeDelete,
]; ];
}; };

View File

@@ -127,8 +127,8 @@ export default class UncategorizedCashflowTransaction extends mixin(
/** /**
* Uncategorized transaction may has association to matched transaction. * Uncategorized transaction may has association to matched transaction.
*/ */
matchedBankTransaction: { matchedBankTransactions: {
relation: Model.BelongsToOneRelation, relation: Model.HasManyRelation,
modelClass: MatchedBankTransaction, modelClass: MatchedBankTransaction,
join: { join: {
from: 'uncategorized_cashflow_transactions.id', from: 'uncategorized_cashflow_transactions.id',

View File

@@ -21,6 +21,7 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
* Retrieves the matched transactions. * Retrieves the matched transactions.
* @param {number} tenantId - * @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter - * @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/ */
public async getMatchedTransactions( public async getMatchedTransactions(
tenantId: number, tenantId: number,
@@ -38,10 +39,10 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
} }
/** /**
* * Retrieves the matched transaction.
* @param {number} tenantId * @param {number} tenantId
* @param {number} transactionId * @param {number} transactionId
* @returns * @returns {Promise<MatchedTransactionPOJO>}
*/ */
public async getMatchedTransaction( public async getMatchedTransaction(
tenantId: number, tenantId: number,
@@ -49,8 +50,6 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
): Promise<MatchedTransactionPOJO> { ): Promise<MatchedTransactionPOJO> {
const { SaleInvoice } = this.tenancy.models(tenantId); const { SaleInvoice } = this.tenancy.models(tenantId);
console.log(transactionId);
const invoice = await SaleInvoice.query().findById(transactionId); const invoice = await SaleInvoice.query().findById(transactionId);
return this.transformer.transform( return this.transformer.transform(

View File

@@ -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 { PromisePool } from '@supercharge/promise-pool';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork'; import UnitOfWork from '@/services/UnitOfWork';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { import {
ERRORS, ERRORS,
IBankTransactionMatchedEventPayload, IBankTransactionMatchedEventPayload,
@@ -34,6 +34,7 @@ export class MatchBankTransactions {
* @param {number} tenantId * @param {number} tenantId
* @param {number} uncategorizedTransactionId * @param {number} uncategorizedTransactionId
* @param {IMatchTransactionsDTO} matchTransactionsDTO * @param {IMatchTransactionsDTO} matchTransactionsDTO
* @returns {Promise<void>}
*/ */
async validate( async validate(
tenantId: number, tenantId: number,
@@ -43,11 +44,21 @@ export class MatchBankTransactions {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const { matchedTransactions } = matchTransactionsDTO; const { matchedTransactions } = matchTransactionsDTO;
// Validates the uncategorized transaction existance.
const uncategorizedTransaction = const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query() await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId) .findById(uncategorizedTransactionId)
.withGraphFetched('matchedBankTransactions')
.throwIfNotFound(); .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. // Validates the given matched transaction.
const validateMatchedTransaction = async (matchedTransaction) => { const validateMatchedTransaction = async (matchedTransaction) => {
const getMatchedTransactionsService = const getMatchedTransactionsService =

View File

@@ -1,8 +1,8 @@
import { Inject, Service } from 'typedi';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork'; import UnitOfWork from '@/services/UnitOfWork';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { Inject, Service } from 'typedi';
import { IBankTransactionUnmatchingEventPayload } from './types'; import { IBankTransactionUnmatchingEventPayload } from './types';
@Service() @Service()
@@ -16,10 +16,16 @@ export class UnmatchMatchedBankTransaction {
@Inject() @Inject()
private eventPublisher: EventPublisher; private eventPublisher: EventPublisher;
/**
* Unmatch the matched the given uncategorized bank transaction.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns {Promise<void>}
*/
public unmatchMatchedTransaction( public unmatchMatchedTransaction(
tenantId: number, tenantId: number,
uncategorizedTransactionId: number uncategorizedTransactionId: number
) { ): Promise<void> {
const { MatchedBankTransaction } = this.tenancy.models(tenantId); const { MatchedBankTransaction } = this.tenancy.models(tenantId);
return this.uow.withTransaction(tenantId, async (trx) => { return this.uow.withTransaction(tenantId, async (trx) => {

View File

@@ -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);
}
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -56,6 +56,8 @@ export const ERRORS = {
'RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID', 'RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID',
RESOURCE_ID_MATCHING_TRANSACTION_INVALID: RESOURCE_ID_MATCHING_TRANSACTION_INVALID:
'RESOURCE_ID_MATCHING_TRANSACTION_INVALID', 'RESOURCE_ID_MATCHING_TRANSACTION_INVALID',
TOTAL_MATCHING_TRANSACTIONS_INVALID: 'TOTAL_MATCHING_TRANSACTIONS_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'
}; };

View File

@@ -32,7 +32,7 @@ export class RecognizeTranasctionsService {
trx trx
).insert({ ).insert({
bankRuleId: bankRule.id, bankRuleId: bankRule.id,
cashflowTransactionId: transaction.id, uncategorizedTransactionId: transaction.id,
assignedCategory: bankRule.assignCategory, assignedCategory: bankRule.assignCategory,
assignedAccountId: bankRule.assignAccountId, assignedAccountId: bankRule.assignAccountId,
assignedPayee: bankRule.assignPayee, assignedPayee: bankRule.assignPayee,

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { import {
IBankRuleEventCreatedPayload, IBankRuleEventCreatedPayload,
IBankRuleEventDeletedPayload,
IBankRuleEventEditedPayload, IBankRuleEventEditedPayload,
} from '../../Rules/types'; } from '../../Rules/types';
@@ -20,17 +21,55 @@ export class TriggerRecognizedTransactions {
); );
bus.subscribe( bus.subscribe(
events.bankRules.onEdited, 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. * Triggers the recognize uncategorized transactions job on rule created.
* @param {IBankRuleEventEditedPayload | IBankRuleEventCreatedPayload} payload - * @param {IBankRuleEventCreatedPayload} payload -
*/ */
private async recognizedTransactionsOnRuleCreated({ private async recognizedTransactionsOnRuleCreated({
tenantId, 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 }; const payload = { tenantId };
await this.agenda.now('recognize-uncategorized-transactions-job', payload); await this.agenda.now('recognize-uncategorized-transactions-job', payload);
} }

View File

@@ -70,6 +70,8 @@ export interface IBankRuleCommonDTO {
assignAccountId: number; assignAccountId: number;
assignPayee?: string; assignPayee?: string;
assignMemo?: string; assignMemo?: string;
recognition?: boolean;
} }
export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {} export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {}