From 4fe519f1e1dc6c36289aeb1ef371c7326743ad8b Mon Sep 17 00:00:00 2001 From: guru-aot Date: Fri, 21 Feb 2025 10:40:32 -0700 Subject: [PATCH 1/7] Initial commit --- .../1740093886236-AddQueueCASSendInvoices.ts | 18 ++ .../src/sql/Queue/Add-cas-send-invoices.sql | 19 ++ .../Queue/Rollback-add-cas-send-invoices.sql | 4 + .../cas-integration/cas-send-invoices.ts | 57 +++++ .../cas-invoice-batch.service.ts | 2 + .../cas-invoice/cas-invoice.models.ts | 4 + .../cas-invoice/cas-invoice.service.ts | 194 ++++++++++++++++++ .../queue-consumers/src/services/index.ts | 1 + .../libs/integrations/src/cas/cas.service.ts | 32 +++ .../src/cas/models/cas-service.model.ts | 64 ++++++ .../libs/utilities/src/queue.constant.ts | 1 + 11 files changed, 396 insertions(+) create mode 100644 sources/packages/backend/apps/db-migrations/src/migrations/1740093886236-AddQueueCASSendInvoices.ts create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Queue/Add-cas-send-invoices.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Queue/Rollback-add-cas-send-invoices.sql create mode 100644 sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.ts create mode 100644 sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts create mode 100644 sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1740093886236-AddQueueCASSendInvoices.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1740093886236-AddQueueCASSendInvoices.ts new file mode 100644 index 0000000000..df2c8f84ea --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1740093886236-AddQueueCASSendInvoices.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class AddQueueCASSendInvoices1740093886236 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData("Add-cas-send-invoices.sql", "Queue"), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData("Rollback-add-cas-send-invoices.sql", "Queue"), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Queue/Add-cas-send-invoices.sql b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Add-cas-send-invoices.sql new file mode 100644 index 0000000000..69430e9a70 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Add-cas-send-invoices.sql @@ -0,0 +1,19 @@ +INSERT INTO + sims.queue_configurations(queue_name, queue_configuration, queue_settings) +VALUES + ( + 'cas-send-invoices', + '{ + "cron": "0 5,12,17 * * 1-5", + "retry": 3, + "cleanUpPeriod": 2592000000, + "retryInterval": 180000, + "dashboardReadonly": false, + "pollingRecordLimit": 2000 + }', + '{ + "maxStalledCount": 0, + "lockDuration": 60000, + "lockRenewTime": 5000 + }' + ); \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Queue/Rollback-add-cas-send-invoices.sql b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Rollback-add-cas-send-invoices.sql new file mode 100644 index 0000000000..e9aaf38860 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Rollback-add-cas-send-invoices.sql @@ -0,0 +1,4 @@ +DELETE FROM + sims.queue_configurations +WHERE + queue_name = 'cas-send-invoices'; \ No newline at end of file diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.ts new file mode 100644 index 0000000000..93a002ddda --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.ts @@ -0,0 +1,57 @@ +import { InjectQueue, Processor } from "@nestjs/bull"; +import { QueueService } from "@sims/services/queue"; +import { QueueNames } from "@sims/utilities"; +import { + InjectLogger, + LoggerService, + ProcessSummary, +} from "@sims/utilities/logger"; +import { Job, Queue } from "bull"; +import { BaseScheduler } from "../base-scheduler"; +import { CASInvoiceService } from "../../../services"; + +/** + * Scheduler to send invoices to CAS. + */ +@Processor(QueueNames.CASSendInvoices) +export class CASSendInvoicesScheduler extends BaseScheduler { + constructor( + @InjectQueue(QueueNames.CASSendInvoices) + schedulerQueue: Queue, + queueService: QueueService, + private readonly casInvoiceService: CASInvoiceService, + ) { + super(schedulerQueue, queueService); + } + + /** + * Scheduler to send invoices to CAS. + * Checks for pending invoices on the approved batch and send them to CAS. + * @param _job process job. + * @param processSummary process summary for logging. + * @returns process summary. + */ + protected async process( + _job: Job, + processSummary: ProcessSummary, + ): Promise { + processSummary.info("Checking for pending invoices."); + const pendingInvoices = await this.casInvoiceService.getPendingInvoices(); + let invoicesUpdated = 0; + if (!pendingInvoices.length) { + processSummary.info("No pending invoices found."); + } + processSummary.info("Executing CAS send invoices."); + const serviceProcessSummary = new ProcessSummary(); + processSummary.children(serviceProcessSummary); + invoicesUpdated = await this.casInvoiceService.sendInvoices( + serviceProcessSummary, + pendingInvoices, + ); + processSummary.info("CAS send invoices executed."); + return ["Process finalized with success."]; + } + + @InjectLogger() + logger: LoggerService; +} 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 5a4642c3e2..f35226a415 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 @@ -19,6 +19,7 @@ import { DataSource, EntityManager } from "typeorm"; import { SequenceControlService, SystemUsersService } from "@sims/services"; import { ProcessSummary } from "@sims/utilities/logger"; import { CustomNamedError } from "@sims/utilities"; +import { CASService } from "@sims/integrations/cas"; /** * Chunk size for inserting invoices. The invoices (and its details) will @@ -32,6 +33,7 @@ export class CASInvoiceBatchService { private readonly dataSource: DataSource, private readonly sequenceControlService: SequenceControlService, private readonly systemUsersService: SystemUsersService, + private readonly casService: CASService, ) {} /** diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts new file mode 100644 index 0000000000..5650f424d8 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts @@ -0,0 +1,4 @@ +export class PendingInvoiceResult { + isInvoiceUpdated: boolean; + knownErrors?: string[]; +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts new file mode 100644 index 0000000000..712c2f237e --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from "@nestjs/common"; +import { + CASService, + PendingInvoicePayload, + InvoiceLineDetail, +} from "@sims/integrations/cas"; +import { CAS_BAD_REQUEST } from "@sims/integrations/constants"; +import { + CASInvoice, + CASInvoiceBatchApprovalStatus, + CASInvoiceStatus, +} from "@sims/sims-db"; +import { + processInParallel, + CustomNamedError, + getISODateOnlyString, +} from "@sims/utilities"; +import { ProcessSummary } from "@sims/utilities/logger"; +import { PendingInvoiceResult } from "apps/queue-consumers/src/services/cas-invoice/cas-invoice.models"; +import { DataSource } from "typeorm"; + +@Injectable() +export class CASInvoiceService { + constructor( + private readonly dataSource: DataSource, + private readonly casService: CASService, + ) {} + /** + * Get the list of pending invoices to be sent from the approved batch. + * @returns list of pending invoices. + */ + async getPendingInvoices(): Promise { + const pendingInvoices = await this.dataSource + .getRepository(CASInvoice) + .find({ + select: { + id: true, + casSupplier: { + id: true, + supplierNumber: true, + supplierAddress: { supplierSiteCode: true }, + }, + createdAt: true, + invoiceNumber: true, + disbursementReceipt: { + id: true, + fileDate: true, + }, + casInvoiceBatch: { + id: true, + batchName: true, + batchDate: true, + }, + casInvoiceDetails: { + id: true, + valueAmount: true, + casDistributionAccount: { + id: true, + operationCode: true, + awardValueCode: true, + distributionAccount: true, + }, + }, + }, + relations: { + casInvoiceBatch: true, + }, + where: { + casInvoiceBatch: { + approvalStatus: CASInvoiceBatchApprovalStatus.Approved, + }, + invoiceStatus: CASInvoiceStatus.Pending, + }, + }); + return pendingInvoices; + } + + /** + * Send pending invoices to CAS. + * @param parentProcessSummary parent process summary for logging. + * @param pendingInvoices pending invoices. + * @returns number of updated invoices. + */ + + async sendInvoices( + parentProcessSummary: ProcessSummary, + pendingInvoices: CASInvoice[], + ): Promise { + // Process each invoice in parallel. + const processesResults = await processInParallel( + (studentSupplier) => + this.processInvoice(studentSupplier, parentProcessSummary), + pendingInvoices, + ); + // Get the number of updated invoices. + const updatedInvoices = processesResults.filter( + (processResult) => !!processResult?.isInvoiceUpdated, + ).length; + return updatedInvoices; + } + + /** + * Process and send an invoice to CAS. + * @param pendingInvoice pending invoice. + * @param parentProcessSummary parent process summary for logging. + * @returns processor result. + */ + private async processInvoice( + pendingInvoice: CASInvoice, + parentProcessSummary: ProcessSummary, + ): Promise { + const summary = new ProcessSummary(); + parentProcessSummary.children(summary); + // Log information about the pending invoice being processed. + summary.info( + `Processing pending invoice: ${pendingInvoice.invoiceNumber}.`, + ); + const pendingInvoicePayload = await this.getPendingInvoicePayload( + pendingInvoice, + ); + summary.info(`Pending invoice payload: ${pendingInvoicePayload}.`); + try { + const response = this.casService.sendPendingInvoices( + pendingInvoicePayload, + ); + } catch (error: unknown) { + if (error instanceof CustomNamedError) { + if (error.name === CAS_BAD_REQUEST) { + return { + isInvoiceUpdated: false, + knownErrors: error.objectInfo as string[], + }; + } + } + throw error; + } + return { + isInvoiceUpdated: true, + }; + } + + /** + * Generate the pending invoice payload. + * @param pendingInvoice pending invoice. + * @returns pending invoice payload. + */ + async getPendingInvoicePayload( + pendingInvoice: CASInvoice, + ): Promise { + // Fixed values as constants + const INVOICE_TYPE = "Standard"; + const INVOICE_AMOUNT = 0; + const PAY_GROUP = "GEN GLP"; + const REMITTANCE_CODE = "01"; + const SPECIAL_HANDLING = "N"; + const TERMS = "Immediate"; + const EMPTY_STRING = ""; + const CURRENCY_CODE = "CAD"; + const INVOICE_LINE_TYPE = "Item"; + + // Get invoice line details. + const invoiceLineDetails: InvoiceLineDetail[] = + pendingInvoice.casInvoiceDetails.map((invoiceDetail, index) => ({ + invoiceLineType: INVOICE_LINE_TYPE, + invoiceLineNumber: index + 1, + lineCode: invoiceDetail.casDistributionAccount.operationCode, + invoiceLineAmount: invoiceDetail.valueAmount, + defaultDistributionAccount: + invoiceDetail.casDistributionAccount.distributionAccount, + })); + return { + invoiceType: INVOICE_TYPE, + supplierNumber: pendingInvoice.casSupplier.supplierNumber, + supplierSiteNumber: + pendingInvoice.casSupplier.supplierAddress.supplierSiteCode, + invoiceDate: pendingInvoice.disbursementReceipt.createdAt.toISOString(), + invoiceNumber: pendingInvoice.invoiceNumber, + invoiceAmount: INVOICE_AMOUNT, + payGroup: PAY_GROUP, + dateInvoiceReceived: getISODateOnlyString( + pendingInvoice.disbursementReceipt.fileDate, + ), + remittanceCode: REMITTANCE_CODE, + specialHandling: SPECIAL_HANDLING, + terms: TERMS, + remittanceMessage1: EMPTY_STRING, + remittanceMessage2: EMPTY_STRING, + glDate: getISODateOnlyString(pendingInvoice.casInvoiceBatch.createdAt), + invoiceBatchName: pendingInvoice.casInvoiceBatch.batchName, + currencyCode: CURRENCY_CODE, + invoiceLineDetails: invoiceLineDetails, + }; + } +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/index.ts b/sources/packages/backend/apps/queue-consumers/src/services/index.ts index 953c8b88e1..68e0040417 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/index.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/index.ts @@ -9,3 +9,4 @@ export * from "./metrics/metrics.models"; export * from "./student-application-notification/student-application-notification.service"; export * from "./student-application-notification/student-application-notification-processor"; export * from "./cas-invoice-batch/cas-invoice-batch.service"; +export * from "./cas-invoice/cas-invoice.service"; diff --git a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts index 54d90f7d1d..558da82fcf 100644 --- a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts +++ b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts @@ -10,6 +10,8 @@ import { CreateSupplierAndSiteResponse, CreateSupplierAndSiteSubmittedData, CreateSupplierSite, + PendingInvoicePayload, + SendPendingInvoicesResponse, } from "./models/cas-service.model"; import { AxiosError, AxiosRequestConfig } from "axios"; import { HttpService } from "@nestjs/axios"; @@ -272,6 +274,36 @@ export class CASService { throw new Error(defaultMessage, { cause: error }); } + /** + * Send pending invoices to CAS. + * @param supplierData data to be used for supplier and site creation. + * @returns submitted data and CAS response. + */ + async sendPendingInvoices( + pendingInvoicePayload: PendingInvoicePayload, + ): Promise { + const url = `${this.casIntegrationConfig.baseUrl}/cfs/apinvoice/`; + try { + const config = await this.getAuthConfig(); + const response = await this.httpService.axiosRef.post( + url, + pendingInvoicePayload, + config, + ); + return { + response: { + invoice_number: response.data.SUPPLIER_NUMBER, + CAS_RETURNED_MESSAGES: response.data[CAS_RETURNED_MESSAGES], + }, + }; + } catch (error: unknown) { + this.handleBadRequestError( + error, + "Error while sending pending invoices to CAS.", + ); + } + } + @InjectLogger() logger: LoggerService; } diff --git a/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts index 66bfd9169c..ed99ce451b 100644 --- a/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts +++ b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts @@ -137,3 +137,67 @@ export class CreateExistingSupplierSiteResponse { submittedData: CreateExistingSupplierAndSiteSubmittedData; response: CreateSupplierAndSiteResult; } + +/** + * Invoice payload used during the creation of a pending invoice sent to CAS + */ +export class PendingInvoicePayload { + invoiceType: string; // Using constant + supplierNumber: string; // Retrieved from cas_supplier_id + supplierSiteNumber: string; // Retrieved from cas_supplier_id + invoiceDate: string; // Batch creation date or approval date + invoiceNumber: string; // Supplier number concatenated with record id + invoiceAmount: number; // Using constant + payGroup: string; // Using constant + dateInvoiceReceived: string; // Disbursement receipt file date + dateGoodsReceived?: string; // Optional, not provided + remittanceCode: string; // Using constant + specialHandling: string; // Using constant + nameLine1?: string; // Optional, not provided + nameLine2?: string; // Optional, not provided + addressLine1?: string; // Optional, not provided + addressLine2?: string; // Optional, not provided + addressLine3?: string; // Optional, not provided + city?: string; // Optional, not provided + country?: string; // Optional, not provided + province?: string; // Optional, not provided + postalCode?: string; // Optional, not provided + qualifiedReceiver?: string; // Optional, not provided + terms: string; // Using constant + payAloneFlag?: string; // Optional, not provided + paymentAdviceComments?: string; // Optional, not provided + remittanceMessage1: string; // Using constant + remittanceMessage2: string; // Using constant + remittanceMessage3?: string; // Optional, not provided + glDate: string; // Batch creation date + invoiceBatchName: string; // SIMSBATCH + sequence number + currencyCode: string; // Using constant + invoiceLineDetails: InvoiceLineDetail[]; // Array of InvoiceLineDetail objects +} + +/** + * Invoice line detail used during the creation of a pending invoice sent to CAS + */ +export class InvoiceLineDetail { + invoiceLineNumber: number; // Mandatory, incremental number + invoiceLineType: string; // Using constant + lineCode: string; // DR or CR + invoiceLineAmount: number; // Award(grant) value + defaultDistributionAccount: string; // Specific for an award(grant) plus operation type + description?: string; // Optional, not provided + taxClassificationCode?: string; // Optional, not provided + distributionSupplier?: string; // Optional, not provided + info1?: string; // Optional, not provided + info2?: string; // Optional, not provided + info3?: string; // Optional, not provided +} + +/** + * Response from CAS when sending pending invoices. + */ +export class SendPendingInvoicesResponse { + response: { + invoice_number?: string; + CAS_RETURNED_MESSAGES: string | string[]; + }; +} diff --git a/sources/packages/backend/libs/utilities/src/queue.constant.ts b/sources/packages/backend/libs/utilities/src/queue.constant.ts index 97b8364241..3e22bff398 100644 --- a/sources/packages/backend/libs/utilities/src/queue.constant.ts +++ b/sources/packages/backend/libs/utilities/src/queue.constant.ts @@ -30,6 +30,7 @@ export enum QueueNames { StudentLoanBalancesPartTimeIntegration = "student-loan-balances-part-time-integration", CASSupplierIntegration = "cas-supplier-integration", CASInvoicesBatchesCreation = "cas-invoices-batches-creation", + CASSendInvoices = "cas-send-invoices", ApplicationChangesReportIntegration = "application-changes-report-integration", StudentApplicationNotifications = "student-application-notifications", SIMSToSFASIntegration = "sims-to-sfas-integration", From 258959e5ee85c3a59140ec21718b15138ca92216 Mon Sep 17 00:00:00 2001 From: guru-aot Date: Fri, 21 Feb 2025 14:52:01 -0700 Subject: [PATCH 2/7] updated --- .../queue-consumers/src/processors/index.ts | 1 + .../cas-send-invoices.scheduler.e2e-spec.ts | 156 ++++++++++++++++++ ...ices.ts => cas-send-invoices.scheduler.ts} | 0 .../src/queue-consumers.module.ts | 4 + .../cas-invoice-batch.service.ts | 2 - .../cas-invoice/cas-invoice.service.ts | 27 ++- .../queue-consumers/src/services/index.ts | 1 + .../cas.service.sendPendingInvoices.spec.ts | 66 ++++++++ .../libs/integrations/src/cas/cas.service.ts | 60 +++---- .../backend/libs/utilities/src/date-utils.ts | 15 ++ 10 files changed, 291 insertions(+), 41 deletions(-) create mode 100644 sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts rename sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/{cas-send-invoices.ts => cas-send-invoices.scheduler.ts} (100%) create mode 100644 sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.sendPendingInvoices.spec.ts diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/index.ts b/sources/packages/backend/apps/queue-consumers/src/processors/index.ts index 54ef6520b8..482fcf3280 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/index.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/index.ts @@ -31,3 +31,4 @@ export * from "./schedulers/esdc-integration/application-changes-report-integrat export * from "./schedulers/student-application-notifications/student-application-notifications.scheduler"; export * from "./schedulers/sfas-integration/sims-to-sfas-integration.scheduler"; export * from "./schedulers/cas-integration/cas-invoices-batches-creation.scheduler"; +export * from "./schedulers/cas-integration/cas-send-invoices.scheduler"; diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts new file mode 100644 index 0000000000..6eb1fea1bb --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts @@ -0,0 +1,156 @@ +import { INestApplication } from "@nestjs/common"; +import { SystemUsersService } from "@sims/services"; +import { + SupplierStatus, + DisbursementValueType, + CASInvoiceBatchApprovalStatus, + OfferingIntensity, +} from "@sims/sims-db"; +import { + E2EDataSources, + createE2EDataSources, + createFakeDisbursementValue, + createFakeCASInvoiceBatch, + saveFakeInvoiceIntoBatchWithInvoiceDetails, +} from "@sims/test-utils"; +import { getPSTPDTDateTime, QueueNames } from "@sims/utilities"; +import { + createTestingAppModule, + describeProcessorRootTest, + mockBullJob, +} from "../../../../../test/helpers"; +import { CASSendInvoicesScheduler } from "../cas-send-invoices.scheduler"; + +const CAS_INVOICE_BATCH_SEQUENCE_NAME = "CAS_INVOICE_BATCH"; +const CAS_INVOICE_SEQUENCE_NAME = "CAS_INVOICE"; + +describe(describeProcessorRootTest(QueueNames.CASSendInvoices), () => { + let app: INestApplication; + let processor: CASSendInvoicesScheduler; + let db: E2EDataSources; + let systemUsersService: SystemUsersService; + + beforeAll(async () => { + const { nestApplication, dataSource } = await createTestingAppModule(); + app = nestApplication; + systemUsersService = nestApplication.get(SystemUsersService); + db = createE2EDataSources(dataSource); + // Processor under test. + processor = app.get(CASSendInvoicesScheduler); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + // Update existing records to avoid conflicts between tests. + await db.disbursementReceiptValue.update( + { + grantType: "BCSG", + }, + { grantAmount: 0 }, + ); + // Delete all existing invoices and related data. + await db.casInvoiceDetail.delete({}); + await db.casInvoice.delete({}); + await db.casInvoiceBatch.delete({}); + // Reset sequence numbers. + await db.sequenceControl.delete({ + sequenceName: CAS_INVOICE_BATCH_SEQUENCE_NAME, + }); + await db.sequenceControl.delete({ + sequenceName: CAS_INVOICE_SEQUENCE_NAME, + }); + }); + + it.only("Should send invoices.", async () => { + // Create invoice batch to generate the report. + const casInvoiceBatch = await db.casInvoiceBatch.save( + createFakeCASInvoiceBatch( + { + creator: systemUsersService.systemUser, + }, + { + initialValue: { + approvalStatus: CASInvoiceBatchApprovalStatus.Approved, + }, + }, + ), + ); + // Creates full-time application with receipts, and invoices details. + const fullTimeInvoice = await saveFakeInvoiceIntoBatchWithInvoiceDetails( + db, + { + casInvoiceBatch, + creator: systemUsersService.systemUser, + // Full-time BC grants. + disbursementValues: [ + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + "BCAG", + 10, + { + effectiveAmount: 5, + }, + ), + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + "BGPD", + 20, + { + effectiveAmount: 15, + }, + ), + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + "SBSD", + 30, + { + effectiveAmount: 25, + }, + ), + createFakeDisbursementValue( + DisbursementValueType.BCTotalGrant, + "BCSG", + 60, + { effectiveAmount: 45 }, + ), + ], + }, + { + offeringIntensity: OfferingIntensity.fullTime, + casSupplierInitialValues: { + supplierStatus: SupplierStatus.VerifiedManually, + supplierNumber: "111111", + }, + }, + ); + // Creating variables to provide easy access to some nested values. + // Full-time related variables. + const fullTimeStudent = + fullTimeInvoice.disbursementReceipt.disbursementSchedule.studentAssessment + .application.student; + const fullTimeDocumentNumber = + fullTimeInvoice.disbursementReceipt.disbursementSchedule.documentNumber.toString(); + const fullTimeGLDate = getPSTPDTDateTime( + fullTimeInvoice.disbursementReceipt.createdAt, + ); + + // 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.", + ]), + ).toBe(true); + }); +}); diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.scheduler.ts similarity index 100% rename from sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.ts rename to sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.scheduler.ts diff --git a/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts b/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts index d9bfc7ea09..d8c21e12e9 100644 --- a/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts +++ b/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts @@ -31,6 +31,7 @@ import { StudentApplicationNotificationsScheduler, SIMSToSFASIntegrationScheduler, CASInvoicesBatchesCreationScheduler, + CASSendInvoicesScheduler, } from "./processors"; import { DisbursementScheduleSharedService, @@ -75,6 +76,7 @@ import { StudentPDPPDReminderNotification, StudentSecondDisbursementReminderNotification, CASInvoiceBatchService, + CASInvoiceService, } from "./services"; import { SFASIntegrationModule } from "@sims/integrations/sfas-integration"; import { ATBCIntegrationModule } from "@sims/integrations/atbc-integration"; @@ -175,7 +177,9 @@ import { QueuesMetricsModule } from "./queues-metrics.module.module"; StudentPDPPDReminderNotification, StudentSecondDisbursementReminderNotification, CASInvoiceBatchService, + CASInvoiceService, CASInvoicesBatchesCreationScheduler, + CASSendInvoicesScheduler, ], controllers: [HealthController, MetricsController], }) 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 f35226a415..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 @@ -19,7 +19,6 @@ import { DataSource, EntityManager } from "typeorm"; import { SequenceControlService, SystemUsersService } from "@sims/services"; import { ProcessSummary } from "@sims/utilities/logger"; import { CustomNamedError } from "@sims/utilities"; -import { CASService } from "@sims/integrations/cas"; /** * Chunk size for inserting invoices. The invoices (and its details) will @@ -33,7 +32,6 @@ export class CASInvoiceBatchService { private readonly dataSource: DataSource, private readonly sequenceControlService: SequenceControlService, private readonly systemUsersService: SystemUsersService, - private readonly casService: CASService, ) {} /** diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts index 712c2f237e..3edca63a08 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts @@ -13,10 +13,10 @@ import { import { processInParallel, CustomNamedError, - getISODateOnlyString, + getAbbreviatedDateOnlyFormat, } from "@sims/utilities"; import { ProcessSummary } from "@sims/utilities/logger"; -import { PendingInvoiceResult } from "apps/queue-consumers/src/services/cas-invoice/cas-invoice.models"; +import { PendingInvoiceResult } from "./cas-invoice.models"; import { DataSource } from "typeorm"; @Injectable() @@ -64,6 +64,11 @@ export class CASInvoiceService { }, relations: { casInvoiceBatch: true, + disbursementReceipt: true, + casSupplier: true, + casInvoiceDetails: { + casDistributionAccount: true, + }, }, where: { casInvoiceBatch: { @@ -88,8 +93,8 @@ export class CASInvoiceService { ): Promise { // Process each invoice in parallel. const processesResults = await processInParallel( - (studentSupplier) => - this.processInvoice(studentSupplier, parentProcessSummary), + (pendingInvoice) => + this.processInvoice(pendingInvoice, parentProcessSummary), pendingInvoices, ); // Get the number of updated invoices. @@ -120,7 +125,7 @@ export class CASInvoiceService { ); summary.info(`Pending invoice payload: ${pendingInvoicePayload}.`); try { - const response = this.casService.sendPendingInvoices( + const response = await this.casService.sendPendingInvoices( pendingInvoicePayload, ); } catch (error: unknown) { @@ -144,7 +149,7 @@ export class CASInvoiceService { * @param pendingInvoice pending invoice. * @returns pending invoice payload. */ - async getPendingInvoicePayload( + private async getPendingInvoicePayload( pendingInvoice: CASInvoice, ): Promise { // Fixed values as constants @@ -173,11 +178,13 @@ export class CASInvoiceService { supplierNumber: pendingInvoice.casSupplier.supplierNumber, supplierSiteNumber: pendingInvoice.casSupplier.supplierAddress.supplierSiteCode, - invoiceDate: pendingInvoice.disbursementReceipt.createdAt.toISOString(), + invoiceDate: getAbbreviatedDateOnlyFormat( + pendingInvoice.disbursementReceipt.createdAt, + ), invoiceNumber: pendingInvoice.invoiceNumber, invoiceAmount: INVOICE_AMOUNT, payGroup: PAY_GROUP, - dateInvoiceReceived: getISODateOnlyString( + dateInvoiceReceived: getAbbreviatedDateOnlyFormat( pendingInvoice.disbursementReceipt.fileDate, ), remittanceCode: REMITTANCE_CODE, @@ -185,7 +192,9 @@ export class CASInvoiceService { terms: TERMS, remittanceMessage1: EMPTY_STRING, remittanceMessage2: EMPTY_STRING, - glDate: getISODateOnlyString(pendingInvoice.casInvoiceBatch.createdAt), + glDate: getAbbreviatedDateOnlyFormat( + pendingInvoice.casInvoiceBatch.createdAt, + ), invoiceBatchName: pendingInvoice.casInvoiceBatch.batchName, currencyCode: CURRENCY_CODE, invoiceLineDetails: invoiceLineDetails, diff --git a/sources/packages/backend/apps/queue-consumers/src/services/index.ts b/sources/packages/backend/apps/queue-consumers/src/services/index.ts index 68e0040417..b8be8a0bb7 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/index.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/index.ts @@ -10,3 +10,4 @@ export * from "./student-application-notification/student-application-notificati export * from "./student-application-notification/student-application-notification-processor"; export * from "./cas-invoice-batch/cas-invoice-batch.service"; export * from "./cas-invoice/cas-invoice.service"; +export * from "./cas-invoice/cas-invoice.models"; diff --git a/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.sendPendingInvoices.spec.ts b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.sendPendingInvoices.spec.ts new file mode 100644 index 0000000000..9aa82d3b6d --- /dev/null +++ b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.sendPendingInvoices.spec.ts @@ -0,0 +1,66 @@ +import { CASService } from "@sims/integrations/cas"; +import { Mocked } from "@suites/unit"; +import { HttpService } from "@nestjs/axios"; +import { + DEFAULT_CAS_AXIOS_AUTH_HEADER, + initializeService, + mockAuthenticationResponseOnce, +} from "./cas-test.utils"; + +describe("CASService-sendPendingInvoices", () => { + let casService: CASService; + let httpService: Mocked; + + beforeAll(async () => { + [casService, httpService] = await initializeService(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("Should invoke CAS API to send pending invoices when all data was provided as expected in the payload.", async () => { + // Arrange + mockAuthenticationResponseOnce(httpService).mockResolvedValue({ + data: { + invoice_number: "1234567", + "CAS-Returned-Messages": "SUCCEEDED", + }, + }); + const pendingInvoicesPayload = { + invoiceType: "Standard", + supplierNumber: "DUMMY_SUPPLIER_NUMBER", + supplierSiteNumber: "DUMMY_SUPPLIER_SITE_NUMBER", + invoiceDate: "21-FEB-2025", + invoiceNumber: "DUMMY_INVOICE_NUMBER", + invoiceAmount: 0, + payGroup: "GEN GLP", + dateInvoiceReceived: "21-FEB-2025", + remittanceCode: "01", + specialHandling: "N", + terms: "Immediate", + remittanceMessage1: "", + remittanceMessage2: "", + glDate: "21-FEB-2025", + invoiceBatchName: "Dummy invoice batch name", + currencyCode: "CAD", + invoiceLineDetails: [ + { + invoiceLineNumber: 1, + invoiceLineType: "Item", + lineCode: "DUMMY_LINE_CODE", + invoiceLineAmount: 0, + defaultDistributionAccount: "DUMMY_DEFAULT_DISTRIBUTION_ACCOUNT", + }, + ], + }; + // Act + await casService.sendPendingInvoices(pendingInvoicesPayload); + // Assert + expect(httpService.axiosRef.post).toHaveBeenCalledWith( + "cas-url/cfs/apinvoice/", + pendingInvoicesPayload, + DEFAULT_CAS_AXIOS_AUTH_HEADER, + ); + }); +}); diff --git a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts index 558da82fcf..315b30a614 100644 --- a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts +++ b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts @@ -127,6 +127,36 @@ export class CASService { return response?.data; } + /** + * Send pending invoices to CAS. + * @param supplierData data to be used for supplier and site creation. + * @returns submitted data and CAS response. + */ + async sendPendingInvoices( + pendingInvoicePayload: PendingInvoicePayload, + ): Promise { + const url = `${this.casIntegrationConfig.baseUrl}/cfs/apinvoice/`; + try { + const config = await this.getAuthConfig(); + const response = await this.httpService.axiosRef.post( + url, + pendingInvoicePayload, + config, + ); + return { + response: { + invoice_number: response.data.invoice_number, + CAS_RETURNED_MESSAGES: response.data[CAS_RETURNED_MESSAGES], + }, + }; + } catch (error: unknown) { + this.handleBadRequestError( + error, + "Error while sending pending invoices to CAS.", + ); + } + } + /** * Create supplier and site. * @param token authentication token. @@ -274,36 +304,6 @@ export class CASService { throw new Error(defaultMessage, { cause: error }); } - /** - * Send pending invoices to CAS. - * @param supplierData data to be used for supplier and site creation. - * @returns submitted data and CAS response. - */ - async sendPendingInvoices( - pendingInvoicePayload: PendingInvoicePayload, - ): Promise { - const url = `${this.casIntegrationConfig.baseUrl}/cfs/apinvoice/`; - try { - const config = await this.getAuthConfig(); - const response = await this.httpService.axiosRef.post( - url, - pendingInvoicePayload, - config, - ); - return { - response: { - invoice_number: response.data.SUPPLIER_NUMBER, - CAS_RETURNED_MESSAGES: response.data[CAS_RETURNED_MESSAGES], - }, - }; - } catch (error: unknown) { - this.handleBadRequestError( - error, - "Error while sending pending invoices to CAS.", - ); - } - } - @InjectLogger() logger: LoggerService; } diff --git a/sources/packages/backend/libs/utilities/src/date-utils.ts b/sources/packages/backend/libs/utilities/src/date-utils.ts index 9e2661e169..50f4985d9c 100644 --- a/sources/packages/backend/libs/utilities/src/date-utils.ts +++ b/sources/packages/backend/libs/utilities/src/date-utils.ts @@ -25,6 +25,7 @@ export const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss"; export const TIMESTAMP_CONTINUOUS_FORMAT = "YYYY-MM-DD_HH.mm.ss"; export const TIMESTAMP_CONTINUOUS_EXTENDED_FORMAT = "YYYYMMDD-HHmmssSSS"; export const PST_TIMEZONE = "America/Vancouver"; +export const ABBREVIATED_MONTH_DATE_FORMAT = "DD-MMM-YYYY"; /** * get utc date time now @@ -172,6 +173,20 @@ export function getDateOnlyFormat(date?: string | Date): string | undefined { return undefined; } +/** + * Get the abbreviated date format(22 Mar 2021) for the date given + * @param date date to be formatted. + * @returns abbreviated date format like 22 Mar 2021 + */ +export function getAbbreviatedDateOnlyFormat( + date?: string | Date, +): string | undefined { + if (date) { + return dayjs(date).format(ABBREVIATED_MONTH_DATE_FORMAT); + } + return undefined; +} + /** * Get the date formatted as date only with the full month. * @param date date to be formatted. From 987c2df5a837b583bc1e0a73173c151877d38905 Mon Sep 17 00:00:00 2001 From: guru-aot Date: Fri, 21 Feb 2025 15:28:28 -0700 Subject: [PATCH 3/7] updated --- .../_tests_/cas-send-invoices.scheduler.e2e-spec.ts | 12 ++++-------- .../src/services/cas-invoice/cas-invoice.service.ts | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts index 6eb1fea1bb..9f87bc2e59 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts @@ -125,14 +125,10 @@ describe(describeProcessorRootTest(QueueNames.CASSendInvoices), () => { ); // Creating variables to provide easy access to some nested values. // Full-time related variables. - const fullTimeStudent = - fullTimeInvoice.disbursementReceipt.disbursementSchedule.studentAssessment - .application.student; - const fullTimeDocumentNumber = - fullTimeInvoice.disbursementReceipt.disbursementSchedule.documentNumber.toString(); - const fullTimeGLDate = getPSTPDTDateTime( - fullTimeInvoice.disbursementReceipt.createdAt, - ); + fullTimeInvoice.disbursementReceipt.disbursementSchedule.studentAssessment + .application.student; + fullTimeInvoice.disbursementReceipt.disbursementSchedule.documentNumber.toString(); + getPSTPDTDateTime(fullTimeInvoice.disbursementReceipt.createdAt); // Queued job. const mockedJob = mockBullJob(); diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts index 3edca63a08..89543957bf 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts @@ -22,8 +22,8 @@ import { DataSource } from "typeorm"; @Injectable() export class CASInvoiceService { constructor( - private readonly dataSource: DataSource, private readonly casService: CASService, + private readonly dataSource: DataSource, ) {} /** * Get the list of pending invoices to be sent from the approved batch. From acc06ce0120b8edf0591c6da2d2f0e31d930e088 Mon Sep 17 00:00:00 2001 From: guru-aot Date: Fri, 21 Feb 2025 15:38:05 -0700 Subject: [PATCH 4/7] updated --- .../src/services/cas-invoice/cas-invoice.service.ts | 8 +++----- .../backend/libs/integrations/src/cas/cas.service.ts | 10 +++------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts index 89543957bf..18ed4d5795 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts @@ -120,9 +120,7 @@ export class CASInvoiceService { summary.info( `Processing pending invoice: ${pendingInvoice.invoiceNumber}.`, ); - const pendingInvoicePayload = await this.getPendingInvoicePayload( - pendingInvoice, - ); + const pendingInvoicePayload = this.getPendingInvoicePayload(pendingInvoice); summary.info(`Pending invoice payload: ${pendingInvoicePayload}.`); try { const response = await this.casService.sendPendingInvoices( @@ -149,9 +147,9 @@ export class CASInvoiceService { * @param pendingInvoice pending invoice. * @returns pending invoice payload. */ - private async getPendingInvoicePayload( + private getPendingInvoicePayload( pendingInvoice: CASInvoice, - ): Promise { + ): PendingInvoicePayload { // Fixed values as constants const INVOICE_TYPE = "Standard"; const INVOICE_AMOUNT = 0; diff --git a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts index 315b30a614..77478aadc7 100644 --- a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts +++ b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts @@ -136,25 +136,21 @@ export class CASService { pendingInvoicePayload: PendingInvoicePayload, ): Promise { const url = `${this.casIntegrationConfig.baseUrl}/cfs/apinvoice/`; + let response; try { const config = await this.getAuthConfig(); - const response = await this.httpService.axiosRef.post( + response = await this.httpService.axiosRef.post( url, pendingInvoicePayload, config, ); - return { - response: { - invoice_number: response.data.invoice_number, - CAS_RETURNED_MESSAGES: response.data[CAS_RETURNED_MESSAGES], - }, - }; } catch (error: unknown) { this.handleBadRequestError( error, "Error while sending pending invoices to CAS.", ); } + return response?.data; } /** From c9e5b178d2f765da4f5f5692528a3f3ef1c481fd Mon Sep 17 00:00:00 2001 From: guru-aot Date: Fri, 21 Feb 2025 17:07:07 -0700 Subject: [PATCH 5/7] updated --- .../cas-send-invoices.scheduler.e2e-spec.ts | 15 ++++----------- .../helpers/mock-utils/cas-response.factory.ts | 8 ++++++++ .../test/helpers/mock-utils/cas-service.mock.ts | 11 ++++++++++- .../src/cas/models/cas-service.model.ts | 6 ++---- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts index 9f87bc2e59..25e6050c12 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts @@ -137,16 +137,9 @@ describe(describeProcessorRootTest(QueueNames.CASSendInvoices), () => { 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.", - ]), - ).toBe(true); + expect(result).toStrictEqual(["Process finalized with success."]); + expect(mockedJob.containLogMessages(["CAS send invoices executed."])).toBe( + true, + ); }); }); diff --git a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts index 62e9733415..82cfe8d19c 100644 --- a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts +++ b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts @@ -8,6 +8,7 @@ import { CreateExistingSupplierSiteResponse, CreateSupplierAddressSubmittedData, CreateSupplierAndSiteResponse, + SendPendingInvoicesResponse, } from "@sims/integrations/cas/models/cas-service.model"; import { CASSupplierRecordStatus, SupplierAddress } from "@sims/sims-db"; import * as faker from "faker"; @@ -85,6 +86,13 @@ export function createFakeCASNotFoundSupplierResponse(): CASSupplierResponse { }; } +export function createFakePendingInvoicesResponse(): SendPendingInvoicesResponse { + return { + invoice_number: "1234567", + CAS_RETURNED_MESSAGES: "SUCCEEDED", + }; +} + /** * Create a fake CreateSupplierAndSite response. * @param options options. diff --git a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts index 599f2371b9..9f6dc580f5 100644 --- a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts +++ b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts @@ -1,5 +1,8 @@ import { CASService } from "@sims/integrations/cas/cas.service"; -import { createFakeCASSupplierResponse } from "./cas-response.factory"; +import { + createFakeCASSupplierResponse, + createFakePendingInvoicesResponse, +} from "./cas-response.factory"; export const CAS_LOGON_MOCKED_RESULT = { access_token: "token123", @@ -10,6 +13,9 @@ export const CAS_LOGON_MOCKED_RESULT = { export const SUPPLIER_INFO_FROM_CAS_MOCKED_RESULT = createFakeCASSupplierResponse(); +export const SEND_PENDING_INVOICES_MOCKED_RESULT = + createFakePendingInvoicesResponse(); + /** * Creates a CAS service mock. * @returns a CAS service mock. @@ -31,4 +37,7 @@ export function resetCASServiceMock(mockedCASService: CASService): void { mockedCASService.getSupplierInfoFromCAS = jest.fn(() => Promise.resolve(SUPPLIER_INFO_FROM_CAS_MOCKED_RESULT), ); + mockedCASService.sendPendingInvoices = jest.fn(() => + Promise.resolve(SEND_PENDING_INVOICES_MOCKED_RESULT), + ); } diff --git a/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts index ed99ce451b..f58752cf94 100644 --- a/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts +++ b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts @@ -196,8 +196,6 @@ export class InvoiceLineDetail { * Response from CAS when sending pending invoices. */ export class SendPendingInvoicesResponse { - response: { - invoice_number?: string; - CAS_RETURNED_MESSAGES: string | string[]; - }; + invoice_number?: string; + CAS_RETURNED_MESSAGES: string | string[]; } From 10247b42a353cc8ff89a5fd02501df36d583bb0c Mon Sep 17 00:00:00 2001 From: guru-aot Date: Tue, 25 Feb 2025 17:13:26 -0700 Subject: [PATCH 6/7] error handling --- .../cas-send-invoices.scheduler.ts | 5 +- .../cas-invoice/cas-invoice.models.ts | 4 +- .../cas-invoice/cas-invoice.service.ts | 107 +++++++++++++++--- .../mock-utils/cas-response.factory.ts | 3 +- .../libs/integrations/src/cas/cas.service.ts | 5 +- .../src/cas/models/cas-service.model.ts | 4 +- .../src/entities/cas-invoice-status.type.ts | 2 +- 7 files changed, 104 insertions(+), 26 deletions(-) diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.scheduler.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.scheduler.ts index 93a002ddda..2d0ffca201 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.scheduler.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/cas-send-invoices.scheduler.ts @@ -37,18 +37,17 @@ export class CASSendInvoicesScheduler extends BaseScheduler { ): Promise { processSummary.info("Checking for pending invoices."); const pendingInvoices = await this.casInvoiceService.getPendingInvoices(); - let invoicesUpdated = 0; if (!pendingInvoices.length) { processSummary.info("No pending invoices found."); } processSummary.info("Executing CAS send invoices."); const serviceProcessSummary = new ProcessSummary(); processSummary.children(serviceProcessSummary); - invoicesUpdated = await this.casInvoiceService.sendInvoices( + const updatedInvoices = await this.casInvoiceService.sendInvoices( serviceProcessSummary, pendingInvoices, ); - processSummary.info("CAS send invoices executed."); + processSummary.info(`${updatedInvoices} CAS invoice(s) sent.`); return ["Process finalized with success."]; } diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts index 5650f424d8..2c4ddf025b 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.models.ts @@ -1,4 +1,4 @@ export class PendingInvoiceResult { - isInvoiceUpdated: boolean; - knownErrors?: string[]; + invoiceNumber?: string; + casReturnedMessages: string[] | string; } diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts index 18ed4d5795..42ccca9311 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-invoice/cas-invoice.service.ts @@ -3,12 +3,14 @@ import { CASService, PendingInvoicePayload, InvoiceLineDetail, + SendPendingInvoicesResponse, } from "@sims/integrations/cas"; import { CAS_BAD_REQUEST } from "@sims/integrations/constants"; import { CASInvoice, CASInvoiceBatchApprovalStatus, CASInvoiceStatus, + User, } from "@sims/sims-db"; import { processInParallel, @@ -17,12 +19,17 @@ import { } from "@sims/utilities"; import { ProcessSummary } from "@sims/utilities/logger"; import { PendingInvoiceResult } from "./cas-invoice.models"; -import { DataSource } from "typeorm"; +import { DataSource, In, Repository } from "typeorm"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SystemUsersService } from "@sims/services"; @Injectable() export class CASInvoiceService { constructor( + private readonly systemUsersService: SystemUsersService, private readonly casService: CASService, + @InjectRepository(CASInvoice) + private readonly casInvoiceRepo: Repository, private readonly dataSource: DataSource, ) {} /** @@ -45,11 +52,13 @@ export class CASInvoiceService { disbursementReceipt: { id: true, fileDate: true, + createdAt: true, }, casInvoiceBatch: { id: true, batchName: true, batchDate: true, + createdAt: true, }, casInvoiceDetails: { id: true, @@ -97,11 +106,23 @@ export class CASInvoiceService { this.processInvoice(pendingInvoice, parentProcessSummary), pendingInvoices, ); - // Get the number of updated invoices. - const updatedInvoices = processesResults.filter( - (processResult) => !!processResult?.isInvoiceUpdated, - ).length; - return updatedInvoices; + // Get the invoices sent. + const sentInvoices = processesResults.map( + (processResult) => processResult.invoiceNumber, + ); + if (sentInvoices.length > 0) { + await this.dataSource.getRepository(CASInvoice).update( + { + invoiceNumber: In(sentInvoices), + }, + { + invoiceStatus: CASInvoiceStatus.Sent, + invoiceStatusUpdatedOn: new Date(), + modifier: { id: this.systemUsersService.systemUser.id }, + }, + ); + } + return sentInvoices.length; } /** @@ -113,7 +134,7 @@ export class CASInvoiceService { private async processInvoice( pendingInvoice: CASInvoice, parentProcessSummary: ProcessSummary, - ): Promise { + ): Promise { const summary = new ProcessSummary(); parentProcessSummary.children(summary); // Log information about the pending invoice being processed. @@ -121,24 +142,80 @@ export class CASInvoiceService { `Processing pending invoice: ${pendingInvoice.invoiceNumber}.`, ); const pendingInvoicePayload = this.getPendingInvoicePayload(pendingInvoice); - summary.info(`Pending invoice payload: ${pendingInvoicePayload}.`); + summary.info( + `Pending invoice payload invoice number: ${pendingInvoicePayload.invoiceNumber}.`, + ); + let response: SendPendingInvoicesResponse; try { - const response = await this.casService.sendPendingInvoices( + response = await this.casService.sendPendingInvoices( pendingInvoicePayload, ); } catch (error: unknown) { if (error instanceof CustomNamedError) { if (error.name === CAS_BAD_REQUEST) { - return { - isInvoiceUpdated: false, - knownErrors: error.objectInfo as string[], - }; + summary.warn("Known CAS error while sending invoice."); + return this.processBadRequestErrors( + pendingInvoicePayload.invoiceNumber, + summary, + error.objectInfo as string[], + this.systemUsersService.systemUser.id, + ); } } - throw error; + } + if (response.invoiceNumber) { + summary.info(`Invoice sent to CAS ${response.casReturnedMessages}.`); + return { + invoiceNumber: response.invoiceNumber, + casReturnedMessages: response.casReturnedMessages, + }; + } + summary.warn( + `Invoice ${pendingInvoicePayload.invoiceNumber} send to CAS did not succeed. Reason: ${response.casReturnedMessages}.`, + ); + } + + /** + * Process bad request errors from CAS API during invoice sending. + * @param invoiceNumber invoice number. + * @param summary summary for logging. + * @param error error object. + * @param auditUserId audit user id. + * @returns processor result. + */ + async processBadRequestErrors( + invoiceNumber: string, + summary: ProcessSummary, + error: string[], + auditUserId: number, + ): Promise { + summary.warn("A known error occurred during processing."); + const now = new Date(); + const auditUser = { id: auditUserId } as User; + try { + await this.casInvoiceRepo.update( + { + invoiceNumber: invoiceNumber, + }, + { + invoiceStatus: CASInvoiceStatus.ManualIntervention, + invoiceStatusUpdatedOn: now, + modifier: auditUser, + errors: error, + }, + ); + summary.info( + `Set invoice status to ${CASInvoiceStatus.ManualIntervention} due to error.`, + ); + } catch (error: unknown) { + summary.error( + `Failed to update invoice status to ${CASInvoiceStatus.ManualIntervention}.`, + error, + ); } return { - isInvoiceUpdated: true, + invoiceNumber: invoiceNumber, + casReturnedMessages: error, }; } diff --git a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts index 82cfe8d19c..1a9e4e64d8 100644 --- a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts +++ b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts @@ -88,8 +88,7 @@ export function createFakeCASNotFoundSupplierResponse(): CASSupplierResponse { export function createFakePendingInvoicesResponse(): SendPendingInvoicesResponse { return { - invoice_number: "1234567", - CAS_RETURNED_MESSAGES: "SUCCEEDED", + invoiceNumber: "1234567", }; } diff --git a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts index 77478aadc7..476bbad957 100644 --- a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts +++ b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts @@ -144,13 +144,16 @@ export class CASService { pendingInvoicePayload, config, ); + return { + invoiceNumber: response.data.invoice_number, + casReturnedMessages: response.data[CAS_RETURNED_MESSAGES], + }; } catch (error: unknown) { this.handleBadRequestError( error, "Error while sending pending invoices to CAS.", ); } - return response?.data; } /** diff --git a/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts index f58752cf94..a9ddeae743 100644 --- a/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts +++ b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts @@ -196,6 +196,6 @@ export class InvoiceLineDetail { * Response from CAS when sending pending invoices. */ export class SendPendingInvoicesResponse { - invoice_number?: string; - CAS_RETURNED_MESSAGES: string | string[]; + invoiceNumber?: string; + casReturnedMessages?: string[]; } diff --git a/sources/packages/backend/libs/sims-db/src/entities/cas-invoice-status.type.ts b/sources/packages/backend/libs/sims-db/src/entities/cas-invoice-status.type.ts index e2c02e6f38..56fc1e91ba 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/cas-invoice-status.type.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/cas-invoice-status.type.ts @@ -14,5 +14,5 @@ export enum CASInvoiceStatus { * Some error happened while trying to send the invoice to CAS * and a manual intervention is required. */ - Rejected = "Manual intervention", + ManualIntervention = "Manual intervention", } From 9986425cd4369117a66ce8b5f8250b57dbcfc80d Mon Sep 17 00:00:00 2001 From: guru-aot Date: Tue, 25 Feb 2025 17:17:39 -0700 Subject: [PATCH 7/7] updated --- .../_tests_/cas-send-invoices.scheduler.e2e-spec.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts index 25e6050c12..bf37b4eb68 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-send-invoices.scheduler.e2e-spec.ts @@ -13,7 +13,7 @@ import { createFakeCASInvoiceBatch, saveFakeInvoiceIntoBatchWithInvoiceDetails, } from "@sims/test-utils"; -import { getPSTPDTDateTime, QueueNames } from "@sims/utilities"; +import { QueueNames } from "@sims/utilities"; import { createTestingAppModule, describeProcessorRootTest, @@ -76,7 +76,7 @@ describe(describeProcessorRootTest(QueueNames.CASSendInvoices), () => { ), ); // Creates full-time application with receipts, and invoices details. - const fullTimeInvoice = await saveFakeInvoiceIntoBatchWithInvoiceDetails( + await saveFakeInvoiceIntoBatchWithInvoiceDetails( db, { casInvoiceBatch, @@ -123,13 +123,6 @@ describe(describeProcessorRootTest(QueueNames.CASSendInvoices), () => { }, }, ); - // Creating variables to provide easy access to some nested values. - // Full-time related variables. - fullTimeInvoice.disbursementReceipt.disbursementSchedule.studentAssessment - .application.student; - fullTimeInvoice.disbursementReceipt.disbursementSchedule.documentNumber.toString(); - getPSTPDTDateTime(fullTimeInvoice.disbursementReceipt.createdAt); - // Queued job. const mockedJob = mockBullJob();