From 2aacef8010338059df04728ab136fc04abf8af37 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Wed, 5 Feb 2025 18:15:27 -0800 Subject: [PATCH] Scheduler E2E tests and accounts validation. --- ...ces-batches-creation.scheduler.e2e-spec.ts | 200 ++++++++++++++++++ ...cas-invoices-batches-creation.scheduler.ts | 31 ++- .../cas-invoice-batch.service.ts | 17 +- .../src/constants/error-code.constants.ts | 11 + 4 files changed, 245 insertions(+), 14 deletions(-) diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-invoices-batches-creation.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-invoices-batches-creation.scheduler.e2e-spec.ts index fb330726e0..1dd09c9cec 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-invoices-batches-creation.scheduler.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-invoices-batches-creation.scheduler.e2e-spec.ts @@ -1,11 +1,13 @@ import { INestApplication } from "@nestjs/common"; import { createE2EDataSources, + createFakeCASInvoiceBatch, createFakeDisbursementValue, E2EDataSources, saveFakeApplicationDisbursements, saveFakeCASSupplier, saveFakeDisbursementReceiptsFromDisbursementSchedule, + saveFakeInvoiceFromDisbursementReceipt, saveFakeStudent, } from "@sims/test-utils"; import { QueueNames } from "@sims/utilities"; @@ -19,6 +21,7 @@ import { CASInvoiceBatchApprovalStatus, CASInvoiceStatus, DisbursementValueType, + OfferingIntensity, SupplierStatus, } from "@sims/sims-db"; import { SystemUsersService } from "@sims/services"; @@ -280,5 +283,202 @@ describe( }); expect(currentInvoiceSequenceNumber.sequenceNumber).toEqual("2"); }); + + it("Should create a new CAS invoice and avoid creating a second invoice when a receipt has already has an invoice associated with.", async () => { + // Arrange + const casSupplier = await saveFakeCASSupplier(db, undefined, { + initialValues: { + supplierStatus: SupplierStatus.VerifiedManually, + }, + }); + const student = await saveFakeStudent(db.dataSource, { casSupplier }); + // Application to have a new invoice associated with. + const applicationWithoutInvoice = await saveFakeApplicationDisbursements( + db.dataSource, + { + student, + disbursementValues: [ + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + "BCAG", + 351, + { effectiveAmount: 350 }, + ), + createFakeDisbursementValue( + DisbursementValueType.BCTotalGrant, + "BCSG", + 351, + { effectiveAmount: 350 }, + ), + ], + }, + ); + const [firstDisbursementScheduleWithoutInvoice] = + applicationWithoutInvoice.currentAssessment.disbursementSchedules; + const { provincial: provincialDisbursementReceiptWithoutInvoice } = + await saveFakeDisbursementReceiptsFromDisbursementSchedule( + db, + firstDisbursementScheduleWithoutInvoice, + ); + // Application to be skipped because already has an invoice associated with. + const applicationWithInvoice = await saveFakeApplicationDisbursements( + db.dataSource, + { + student, + disbursementValues: [ + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + "SBSD", + 101, + { effectiveAmount: 100 }, + ), + createFakeDisbursementValue( + DisbursementValueType.BCTotalGrant, + "BCSG", + 101, + { effectiveAmount: 100 }, + ), + ], + }, + ); + const [firstDisbursementScheduleWithInvoice] = + applicationWithInvoice.currentAssessment.disbursementSchedules; + const { provincial: provincialDisbursementReceiptWithInvoice } = + await saveFakeDisbursementReceiptsFromDisbursementSchedule( + db, + firstDisbursementScheduleWithInvoice, + ); + // Create invoice batch to be associated to the already generated invoice. + const casInvoiceBatch = await db.casInvoiceBatch.save( + createFakeCASInvoiceBatch({ + creator: systemUsersService.systemUser, + }), + ); + // Create invoice and its details associated with th batch + await saveFakeInvoiceFromDisbursementReceipt(db, { + casInvoiceBatch: casInvoiceBatch, + creator: systemUsersService.systemUser, + provincialDisbursementReceipt: provincialDisbursementReceiptWithInvoice, + casSupplier, + }); + + // Queued job. + const mockedJob = mockBullJob(); + + // Act + const result = await processor.processQueue(mockedJob.job); + + // Assert + expect(result).toStrictEqual([ + "Batch created: SIMS-BATCH-1.", + "Invoices created: 1.", + ]); + expect( + mockedJob.containLogMessages([ + "Executing CAS invoices batches creation.", + "Checking for pending receipts.", + "Found 1 pending receipts.", + `Creating invoice for receipt ID ${provincialDisbursementReceiptWithoutInvoice.id}.`, + `Invoice SIMS-INVOICE-1-${casSupplier.supplierNumber} created for receipt ID ${provincialDisbursementReceiptWithoutInvoice.id}.`, + `Created invoice detail for award BCAG(CR).`, + `Created invoice detail for award BCAG(DR).`, + `CAS invoices batches creation process executed.`, + ]), + ).toBe(true); + expect( + mockedJob.containLogMessages([ + `Creating invoice for receipt ID ${provincialDisbursementReceiptWithInvoice.id}.`, + ]), + ).toBe(false); + }); + + it("Should interrupt the process when an invoice is trying to be generated but there are no distribution accounts available to create the invoice details.", async () => { + // Arrange + const BC_GRANT_WITHOUT_DISTRIBUTION_ACCOUNT = "BCAG"; + const casSupplier = await saveFakeCASSupplier(db, undefined, { + initialValues: { + supplierStatus: SupplierStatus.VerifiedManually, + }, + }); + const student = await saveFakeStudent(db.dataSource, { casSupplier }); + const application = await saveFakeApplicationDisbursements( + db.dataSource, + { + student, + disbursementValues: [ + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + BC_GRANT_WITHOUT_DISTRIBUTION_ACCOUNT, + 351, + { effectiveAmount: 350 }, + ), + createFakeDisbursementValue( + DisbursementValueType.BCTotalGrant, + "BCSG", + 351, + { effectiveAmount: 350 }, + ), + ], + }, + ); + const [firstDisbursementSchedule] = + application.currentAssessment.disbursementSchedules; + await saveFakeDisbursementReceiptsFromDisbursementSchedule( + db, + firstDisbursementSchedule, + ); + // Change BCAG account to be inactive. + await db.casDistributionAccount.update( + { + awardValueCode: BC_GRANT_WITHOUT_DISTRIBUTION_ACCOUNT, + offeringIntensity: OfferingIntensity.fullTime, + }, + { isActive: false }, + ); + + // Queued job. + const mockedJob = mockBullJob(); + + // Act/Assert + await expect(processor.processQueue(mockedJob.job)).rejects.toThrow( + `No distribution accounts found for award ${BC_GRANT_WITHOUT_DISTRIBUTION_ACCOUNT} and offering intensity ${OfferingIntensity.fullTime}.`, + ); + + // Revert back the BCSG distribution account to be active. + await db.casDistributionAccount.update( + { + awardValueCode: BC_GRANT_WITHOUT_DISTRIBUTION_ACCOUNT, + offeringIntensity: OfferingIntensity.fullTime, + }, + { isActive: true }, + ); + }); + + it("Should finalize the process nicely when there is no pending receipt to process.", async () => { + // Arrange + // Queued job. + const mockedJob = mockBullJob(); + + // Act + const result = await processor.processQueue(mockedJob.job); + + // Assert + expect(result).toStrictEqual(["No batch was generated."]); + expect( + mockedJob.containLogMessages([ + "Executing CAS invoices batches creation.", + "Checking for pending receipts.", + "No pending receipts found.", + "CAS invoices batches creation process executed.", + ]), + ).toBe(true); + const batchSequenceNumberExists = await db.sequenceControl.exists({ + where: { + sequenceName: CAS_INVOICE_BATCH_SEQUENCE_NAME, + }, + }); + // Assert the sequence number was not created. + expect(batchSequenceNumberExists).toBe(false); + }); }, ); diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-invoices-batches-creation.scheduler.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-invoices-batches-creation.scheduler.ts index 7d4f2c4161..df43e5a8e1 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-invoices-batches-creation.scheduler.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-invoices-batches-creation.scheduler.ts @@ -1,6 +1,6 @@ import { InjectQueue, Processor } from "@nestjs/bull"; import { QueueService } from "@sims/services/queue"; -import { QueueNames } from "@sims/utilities"; +import { CustomNamedError, QueueNames } from "@sims/utilities"; import { InjectLogger, LoggerService, @@ -9,6 +9,7 @@ import { import { Job, Queue } from "bull"; import { BaseScheduler } from "../base-scheduler"; import { CASInvoiceBatchService } from "../../../services"; +import { DATABASE_TRANSACTION_CANCELLATION } from "@sims/services/constants"; /** * Scheduler to generate batches for CAS invoices for e-Cert receipts. @@ -36,17 +37,25 @@ export class CASInvoicesBatchesCreationScheduler extends BaseScheduler { processSummary: ProcessSummary, ): Promise { processSummary.info("Executing CAS invoices batches creation."); - const createdBatch = await this.casInvoiceBatchService.createInvoiceBatch( - processSummary, - ); - processSummary.info("CAS invoices batches creation process executed."); - if (!createdBatch) { - return "No batch was generated."; + try { + const createdBatch = await this.casInvoiceBatchService.createInvoiceBatch( + processSummary, + ); + return [ + `Batch created: ${createdBatch.batchName}.`, + `Invoices created: ${createdBatch.casInvoices.length}.`, + ]; + } catch (error: unknown) { + if ( + error instanceof CustomNamedError && + error.name === DATABASE_TRANSACTION_CANCELLATION + ) { + return "No batch was generated."; + } + throw error; + } finally { + processSummary.info("CAS invoices batches creation process executed."); } - return [ - `Batch created: ${createdBatch.batchName}.`, - `Invoices created: ${createdBatch.casInvoices.length}.`, - ]; } @InjectLogger() diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice-batch/cas-invoice-batch.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice-batch/cas-invoice-batch.service.ts index abe02e5f65..5a4642c3e2 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice-batch/cas-invoice-batch.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice-batch/cas-invoice-batch.service.ts @@ -1,5 +1,8 @@ import { Injectable } from "@nestjs/common"; -import { BC_TOTAL_GRANT_AWARD_CODE } from "@sims/services/constants"; +import { + BC_TOTAL_GRANT_AWARD_CODE, + DATABASE_TRANSACTION_CANCELLATION, +} from "@sims/services/constants"; import { CASDistributionAccount, CASInvoice, @@ -15,6 +18,7 @@ import { import { DataSource, EntityManager } from "typeorm"; import { SequenceControlService, SystemUsersService } from "@sims/services"; import { ProcessSummary } from "@sims/utilities/logger"; +import { CustomNamedError } from "@sims/utilities"; /** * Chunk size for inserting invoices. The invoices (and its details) will @@ -57,8 +61,10 @@ export class CASInvoiceBatchService { if (!pendingReceipts.length) { processSummary.info("No pending receipts found."); // Cancels the transaction to rollback the batch number. - await entityManager.queryRunner.rollbackTransaction(); - return null; + throw new CustomNamedError( + "Rollback current database for invoice batch processing.", + DATABASE_TRANSACTION_CANCELLATION, + ); } processSummary.info(`Found ${pendingReceipts.length} pending receipts.`); const now = new Date(); @@ -193,6 +199,11 @@ export class CASInvoiceBatchService { account.awardValueCode === disbursedAward.valueCode && account.offeringIntensity === offeringIntensity, ); + if (!accounts.length) { + throw new Error( + `No distribution accounts found for award ${disbursedAward.valueCode} and offering intensity ${offeringIntensity}.`, + ); + } // Create invoice details for each distribution account. const awardInvoiceDetails = accounts.map((account) => { const invoiceDetail = new CASInvoiceDetail(); diff --git a/sources/packages/backend/libs/services/src/constants/error-code.constants.ts b/sources/packages/backend/libs/services/src/constants/error-code.constants.ts index d44413a9ef..9002eefbf9 100644 --- a/sources/packages/backend/libs/services/src/constants/error-code.constants.ts +++ b/sources/packages/backend/libs/services/src/constants/error-code.constants.ts @@ -32,3 +32,14 @@ export const UNEXPECTED_ERROR_DOWNLOADING_FILE = export const ECE_DISBURSEMENT_DATA_NOT_VALID = "ECE_DISBURSEMENT_DATA_NOT_VALID"; export const FILE_PARSING_ERROR = "FILE_PARSING_ERROR"; + +/** + * Used to cancel a database transaction started using the method as below. + * @example + * await myDataSource.transaction(async (transactionalEntityManager) => { + * // execute queries using transactionalEntityManager + * }) + * @see https://typeorm.io/#/transactions + */ +export const DATABASE_TRANSACTION_CANCELLATION = + "DATABASE_TRANSACTION_CANCELLATION";