diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/e-cert-utils.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/e-cert-utils.ts new file mode 100644 index 0000000000..a31cd998b7 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/e-cert-utils.ts @@ -0,0 +1,108 @@ +import { DisbursementSchedule, DisbursementValue } from "@sims/sims-db"; +import { E2EDataSources } from "@sims/test-utils"; +import { In } from "typeorm"; + +/** + * Load disbursement awards for further validations. + * The method {@link awardAssert} can be used in conjunction with this. + * @param dataSource application dataSource. + * @param disbursementId disbursement id to have the awards loaded. + * @param options load options. + * - `valueCode` award value code to be filtered. + * @returns disbursement awards. + */ +export async function loadAwardValues( + dataSource: E2EDataSources, + disbursementId: number, + options?: { + valueCode: string[]; + }, +): Promise { + return dataSource.disbursementValue.find({ + select: { + valueCode: true, + valueAmount: true, + effectiveAmount: true, + restrictionAmountSubtracted: true, + restrictionSubtracted: { + id: true, + }, + }, + relations: { + restrictionSubtracted: true, + }, + where: { + disbursementSchedule: { id: disbursementId }, + valueCode: options?.valueCode.length ? In(options.valueCode) : undefined, + }, + }); +} + +/** + * Check the award updated values to ensure that they were updated as expected. + * @param awards list of awards. + * @param valueCode award code. + * @param options method optional award values to be asserted. + * - `valueAmount` eligible award value. + * - `effectiveAmount` value calculated to be added to the e-Cert. + * - `restrictionAmountSubtracted` amount subtracted from the eligible award + * value due to a restriction. + * @returns true if all assertions were successful. + */ +export function awardAssert( + awards: DisbursementValue[], + valueCode: string, + options: { + valueAmount?: number; + effectiveAmount?: number; + restrictionAmountSubtracted?: number; + }, +): boolean { + const award = awards.find((award) => award.valueCode === valueCode); + if (!award) { + return false; + } + if (options.valueAmount && options.valueAmount !== award.valueAmount) { + return false; + } + if ( + options.effectiveAmount && + options.effectiveAmount !== award.effectiveAmount + ) { + return false; + } + if ( + options.restrictionAmountSubtracted && + (options.restrictionAmountSubtracted !== + award.restrictionAmountSubtracted || + !award.restrictionSubtracted.id) + ) { + // If a restriction is expected, a restriction id should also be present. + return false; + } + return true; +} + +/** + * Load the disbursement schedules for the assessment. + * @param dataSource application dataSource. + * @param studentAssessmentId assessment id. + * @returns disbursement schedules for the assessment. + */ +export async function loadDisbursementSchedules( + dataSource: E2EDataSources, + studentAssessmentId: number, +): Promise { + return dataSource.disbursementSchedule.find({ + select: { + id: true, + disbursementScheduleStatus: true, + }, + where: { + studentAssessment: { + id: studentAssessmentId, + }, + }, + order: { disbursementDate: "ASC" }, + }); +} diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-full-time-process-integration.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-full-time-process-integration.scheduler.e2e-spec.ts index 039230f45c..b7e6de7f23 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-full-time-process-integration.scheduler.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-full-time-process-integration.scheduler.e2e-spec.ts @@ -3,6 +3,7 @@ import { Assessment, COEStatus, DisbursementOverawardOriginType, + DisbursementSchedule, DisbursementScheduleStatus, DisbursementValueType, OfferingIntensity, @@ -14,19 +15,24 @@ import { createFakeDisbursementOveraward, createFakeDisbursementValue, createFakeMSFAANumber, + getUploadedFile, saveFakeApplicationDisbursements, saveFakeStudent, } from "@sims/test-utils"; -import { IsNull, Not } from "typeorm"; +import { IsNull, Like, Not } from "typeorm"; import { createTestingAppModule, describeQueueProcessorRootTest, + mockBullJob, } from "../../../../../../test/helpers"; import { INestApplication } from "@nestjs/common"; -import { QueueNames } from "@sims/utilities"; +import { QueueNames, addDays, getISODateOnlyString } from "@sims/utilities"; import { FullTimeECertProcessIntegrationScheduler } from "../ecert-full-time-process-integration.scheduler"; -import { createMock } from "@golevelup/ts-jest"; -import { Job } from "bull"; +import { DeepMocked } from "@golevelup/ts-jest"; +import * as Client from "ssh2-sftp-client"; +import * as dayjs from "dayjs"; +import { FullTimeCertRecordParser } from "./parsers/full-time-e-cert-record-parser"; +import { awardAssert, loadAwardValues } from "./e-cert-utils"; describe( describeQueueProcessorRootTest(QueueNames.FullTimeECertIntegration), @@ -34,18 +40,22 @@ describe( let app: INestApplication; let processor: FullTimeECertProcessIntegrationScheduler; let db: E2EDataSources; + let sftpClientMock: DeepMocked; beforeAll(async () => { // Env variable required for querying the eligible e-Cert records. process.env.APPLICATION_ARCHIVE_DAYS = "42"; - const { nestApplication, dataSource } = await createTestingAppModule(); + const { nestApplication, dataSource, sshClientMock } = + await createTestingAppModule(); app = nestApplication; + db = createE2EDataSources(dataSource); + sftpClientMock = sshClientMock; // Processor under test. processor = app.get(FullTimeECertProcessIntegrationScheduler); - db = createE2EDataSources(dataSource); }); beforeEach(async () => { + jest.clearAllMocks(); // Ensures that every disbursement on database is cancelled allowing the e-Certs to // be generated with the data created for every specific scenario. await db.disbursementSchedule.update( @@ -54,6 +64,11 @@ describe( }, { disbursementScheduleStatus: DisbursementScheduleStatus.Cancelled }, ); + // Reset sequence number to control the file name generated. + await db.sequenceControl.update( + { sequenceName: Like("ECERT_FT_SENT_FILE_%") }, + { sequenceNumber: "0" }, + ); }); it("Should execute overawards deductions and calculate awards effective value", async () => { @@ -119,12 +134,9 @@ describe( fakeCanadaLoanOverawardBalance.disbursementValueCode = "CSLF"; fakeCanadaLoanOverawardBalance.overawardValue = 4500; await db.disbursementOveraward.save(fakeCanadaLoanOverawardBalance); + // Queued job. - // id and name defined to make the console log looks better only. - const job = createMock>({ - id: "FakeJobId", - name: "FakeJobName", - }); + const { job } = mockBullJob(); // Act const result = await processor.processFullTimeECert(job); @@ -223,5 +235,220 @@ describe( ); expect(hasExpectedBCSG.length).toBe(1); }); + + it("Should disburse BC funding for a close-to-maximum disbursement, reduce BC funding when passing the maximum, and withhold BC Funding when a restriction was applied due to the maximum configured value for the year was reached.", async () => { + // Arrange + const MAX_LIFE_TIME_BC_LOAN_AMOUNT = 50000; + // Ensure the right disbursement order for the 3 disbursements. + const [disbursementDate1, disbursementDate2, disbursementDate3] = [ + getISODateOnlyString(addDays(1)), + getISODateOnlyString(addDays(2)), + getISODateOnlyString(addDays(3)), + ]; + // Eligible COE basic properties. + const eligibleDisbursement: Partial = { + coeStatus: COEStatus.completed, + coeUpdatedAt: new Date(), + }; + // Student with valid SIN. + const student = await saveFakeStudent(db.dataSource); + // Valid MSFAA Number. + const msfaaNumber = await db.msfaaNumber.save( + createFakeMSFAANumber({ student }, { msfaaState: MSFAAStates.Signed }), + ); + // Creates an application with two eligible disbursements. + // First disbursement will be close to the maximum allowed. + // Second disbursement will exceed the maximum and should create a restriction. + const applicationA = await saveFakeApplicationDisbursements( + db.dataSource, + { + student, + msfaaNumber, + firstDisbursementValues: [ + createFakeDisbursementValue( + DisbursementValueType.CanadaLoan, + "CSLF", + 100, + ), + // Force the BCSL to be close to the limit (leave 500 for upcoming disbursement). + createFakeDisbursementValue( + DisbursementValueType.BCLoan, + "BCSL", + MAX_LIFE_TIME_BC_LOAN_AMOUNT - 500, + ), + ], + secondDisbursementValues: [ + // Force the BCSL to exceed the limit by 250 (previous disbursement left 500 room). + createFakeDisbursementValue( + DisbursementValueType.BCLoan, + "BCSL", + 750, + ), + // BC Grants should still be disbursed since BCSL has some value. + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + "BCAG", + 1500, + ), + ], + }, + { + offeringIntensity: OfferingIntensity.fullTime, + applicationStatus: ApplicationStatus.Completed, + currentAssessmentInitialValues: { + assessmentData: { weeks: 5 } as Assessment, + assessmentDate: new Date(), + }, + createSecondDisbursement: true, + firstDisbursementInitialValues: { + ...eligibleDisbursement, + disbursementDate: disbursementDate1, + }, + secondDisbursementInitialValues: { + ...eligibleDisbursement, + disbursementDate: disbursementDate2, + }, + }, + ); + // Second application for the student when all BC funding will be withhold due to BCLM restriction + // that must be created for the application A second disbursement. + const applicationB = await saveFakeApplicationDisbursements( + db.dataSource, + { + student, + msfaaNumber, + firstDisbursementValues: [ + createFakeDisbursementValue( + DisbursementValueType.CanadaLoan, + "CSLF", + 199, + ), + // Should be disbursed because it is a federal grant. + createFakeDisbursementValue( + DisbursementValueType.CanadaGrant, + "CSGP", + 299, + ), + // Should not be disbursed due to BCLM restriction. + createFakeDisbursementValue( + DisbursementValueType.BCLoan, + "BCSL", + 399, + ), + // Should not be disbursed due to BCLM restriction. + createFakeDisbursementValue( + DisbursementValueType.BCGrant, + "BCAG", + 499, + ), + ], + }, + { + offeringIntensity: OfferingIntensity.fullTime, + applicationStatus: ApplicationStatus.Completed, + currentAssessmentInitialValues: { + assessmentData: { weeks: 5 } as Assessment, + assessmentDate: new Date(), + }, + firstDisbursementInitialValues: { + ...eligibleDisbursement, + disbursementDate: disbursementDate3, + }, + }, + ); + + // Application A and B shares the same program year. + // Updating program year maximum to ensure the expect value. + applicationA.programYear.maxLifetimeBCLoanAmount = + MAX_LIFE_TIME_BC_LOAN_AMOUNT; + await db.programYear.save(applicationA.programYear); + + // Queued job. + const mockedJob = mockBullJob(); + + // Act + const result = await processor.processFullTimeECert(mockedJob.job); + + // Assert + expect(result).toStrictEqual(["Process finalized with success."]); + expect( + mockedJob.containLogMessages([ + "New BCLM restriction was added to the student account.", + "Applying restriction for BCAG.", + "Applying restriction for BCSL.", + ]), + ).toBe(true); + + // Assert uploaded file. + const uploadedFile = getUploadedFile(sftpClientMock); + const fileDate = dayjs().format("YYYYMMDD"); + expect(uploadedFile.remoteFilePath).toBe( + `MSFT-Request\\DPBC.EDU.FTECERTS.${fileDate}.001`, + ); + expect(uploadedFile.fileLines).toHaveLength(5); + const [header, record1, record2, record3, footer] = + uploadedFile.fileLines; + // Validate header. + expect(header).toContain("100BC NEW ENTITLEMENT"); + // Validate footer. + expect(footer.substring(0, 3)).toBe("999"); + // Check record 1 when the maximum is about to be reached but not yet. + const record1Parsed = new FullTimeCertRecordParser(record1); + expect(record1Parsed.hasUser(student.user)).toBe(true); + expect(record1Parsed.cslfAmount).toBe(100); + expect(record1Parsed.bcslAmount).toBe(49500); + // Check record 2 when maximum was exceeded and the BC Stop Funding restriction will be added. + const record2Parsed = new FullTimeCertRecordParser(record2); + expect(record2Parsed.hasUser(student.user)).toBe(true); + expect(record2Parsed.cslfAmount).toBe(0); + expect(record2Parsed.bcslAmount).toBe(500); + expect(record2Parsed.grantAmount("BCSG")).toBe(1500); // Check for the total BC grants value. + const [, applicationADisbursement2] = + applicationA.currentAssessment.disbursementSchedules; + // Select the BCSL to validate the values impacted by the restriction. + const record2Awards = await loadAwardValues( + db, + applicationADisbursement2.id, + { valueCode: ["BCSL"] }, + ); + expect( + awardAssert(record2Awards, "BCSL", { + valueAmount: 750, + restrictionAmountSubtracted: 250, + effectiveAmount: 500, + }), + ).toBe(true); + // Check record 3 processing when BC Stop Funding restriction is in place. + const record3Parsed = new FullTimeCertRecordParser(record3); + expect(record3Parsed.hasUser(student.user)).toBe(true); + // Keep federal funding. + expect(record3Parsed.cslfAmount).toBe(199); + expect(record3Parsed.grantAmount("CSGP")).toBe(299); + // Withhold provincial funding. + expect(record3Parsed.bcslAmount).toBe(0); + expect(record3Parsed.grantAmount("BCSG")).toBeUndefined(); + // Select the BCSL/BCAG to validate the values impacted by the restriction. + const [applicationBDisbursement1] = + applicationB.currentAssessment.disbursementSchedules; + const record3Awards = await loadAwardValues( + db, + applicationBDisbursement1.id, + { valueCode: ["BCSL", "BCAG"] }, + ); + expect( + awardAssert(record3Awards, "BCSL", { + valueAmount: 399, + restrictionAmountSubtracted: 399, + effectiveAmount: 0, + }), + ).toBe(true); + expect( + awardAssert(record3Awards, "BCAG", { + valueAmount: 499, + restrictionAmountSubtracted: 499, + effectiveAmount: 0, + }), + ).toBe(true); + }); }, ); diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-part-time-process-integration.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-part-time-process-integration.scheduler.e2e-spec.ts index e777ab6e68..5909ce777c 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-part-time-process-integration.scheduler.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/ecert-part-time-process-integration.scheduler.e2e-spec.ts @@ -18,14 +18,17 @@ import { IsNull, Like, Not } from "typeorm"; import { createTestingAppModule, describeQueueProcessorRootTest, + mockBullJob, } from "../../../../../../test/helpers"; import { INestApplication } from "@nestjs/common"; -import { QueueNames } from "@sims/utilities"; -import { DeepMocked, createMock } from "@golevelup/ts-jest"; -import { Job } from "bull"; +import { QueueNames, addDays, getISODateOnlyString } from "@sims/utilities"; +import { DeepMocked } from "@golevelup/ts-jest"; import { PartTimeECertProcessIntegrationScheduler } from "../ecert-part-time-process-integration.scheduler"; import * as Client from "ssh2-sftp-client"; import * as dayjs from "dayjs"; +import { DISBURSEMENT_FILE_GENERATION_ANTICIPATION_DAYS } from "@sims/services/constants"; +import { PartTimeCertRecordParser } from "./parsers/part-time-e-cert-record-parser"; +import { loadDisbursementSchedules } from "./e-cert-utils"; describe( describeQueueProcessorRootTest(QueueNames.PartTimeECertIntegration), @@ -48,6 +51,7 @@ describe( }); beforeEach(async () => { + jest.clearAllMocks(); // Ensures that every disbursement on database is cancelled allowing the e-Certs to // be generated with the data created for every specific scenario. await db.disbursementSchedule.update( @@ -70,7 +74,15 @@ describe( const student = await saveFakeStudent(db.dataSource); // Valid MSFAA Number. const msfaaNumber = await db.msfaaNumber.save( - createFakeMSFAANumber({ student }, { msfaaState: MSFAAStates.Signed }), + createFakeMSFAANumber( + { student }, + { + msfaaState: MSFAAStates.Signed, + msfaaInitialValues: { + offeringIntensity: OfferingIntensity.partTime, + }, + }, + ), ); // Student application eligible for e-Cert. const application = await saveFakeApplicationDisbursements( @@ -89,11 +101,7 @@ describe( }, ); // Queued job. - // id and name defined to make the console log looks better only. - const job = createMock>({ - id: "FakeJobId", - name: "FakeProcessPartTimeECertJobName", - }); + const { job } = mockBullJob(); // Act const result = await processor.processPartTimeECert(job); @@ -129,5 +137,154 @@ describe( }); expect(scheduleIsSent).toBe(true); }); + + it("Should create an e-Cert with three disbursements for two different students with two disbursements each where three records are eligible.", async () => { + // Arrange + + const coeUpdatedAt = new Date(); + + // Student A with valid SIN. + const studentA = await saveFakeStudent(db.dataSource); + // Valid MSFAA Number. + const msfaaNumberA = await db.msfaaNumber.save( + createFakeMSFAANumber( + { student: studentA }, + { + msfaaState: MSFAAStates.Signed, + msfaaInitialValues: { + offeringIntensity: OfferingIntensity.partTime, + }, + }, + ), + ); + + // Student A with valid SIN. + const studentB = await saveFakeStudent(db.dataSource); + // Valid MSFAA Number. + const msfaaNumberB = await db.msfaaNumber.save( + createFakeMSFAANumber( + { student: studentB }, + { + msfaaState: MSFAAStates.Signed, + msfaaInitialValues: { + offeringIntensity: OfferingIntensity.partTime, + }, + }, + ), + ); + + // Student A application eligible for e-Cert with 2 disbursements. + const applicationStudentA = await saveFakeApplicationDisbursements( + db.dataSource, + { student: studentA, msfaaNumber: msfaaNumberA }, + { + offeringIntensity: OfferingIntensity.partTime, + applicationStatus: ApplicationStatus.Completed, + currentAssessmentInitialValues: { + assessmentData: { weeks: 5 } as Assessment, + assessmentDate: new Date(), + }, + createSecondDisbursement: true, + firstDisbursementInitialValues: { + coeStatus: COEStatus.completed, + coeUpdatedAt, + disbursementDate: getISODateOnlyString(new Date()), + }, + secondDisbursementInitialValues: { + coeStatus: COEStatus.completed, + coeUpdatedAt, + disbursementDate: getISODateOnlyString(new Date()), + }, + }, + ); + + // Student B application eligible for e-Cert with 1 disbursements. + const applicationStudentB = await saveFakeApplicationDisbursements( + db.dataSource, + { student: studentB, msfaaNumber: msfaaNumberB }, + { + offeringIntensity: OfferingIntensity.partTime, + applicationStatus: ApplicationStatus.Completed, + currentAssessmentInitialValues: { + assessmentData: { weeks: 5 } as Assessment, + assessmentDate: new Date(), + }, + createSecondDisbursement: true, + firstDisbursementInitialValues: { + coeStatus: COEStatus.completed, + coeUpdatedAt, + disbursementDate: getISODateOnlyString(new Date()), + }, + // Force the second disbursement to not be eligible due to the disbursement date. + secondDisbursementInitialValues: { + coeStatus: COEStatus.completed, + coeUpdatedAt, + disbursementDate: getISODateOnlyString( + addDays(DISBURSEMENT_FILE_GENERATION_ANTICIPATION_DAYS + 1), + ), + }, + }, + ); + + // Queued job. + const { job } = mockBullJob(); + + // Act + const result = await processor.processPartTimeECert(job); + + // Assert + expect(result).toStrictEqual(["Process finalized with success."]); + + // Assert uploaded file. + const uploadedFile = getUploadedFile(sftpClientMock); + const fileDate = dayjs().format("YYYYMMDD"); + expect(uploadedFile.remoteFilePath).toBe( + `MSFT-Request\\DPBC.EDU.PTCERTS.D${fileDate}.001`, + ); + expect(uploadedFile.fileLines).toHaveLength(5); + const [header, record1, record2, record3, footer] = + uploadedFile.fileLines; + // Validate header. + expect(header).toContain("01BC NEW PT ENTITLEMENT"); + // Validate footer. + expect(footer.substring(0, 2)).toBe("99"); + // Student A + const [studentAFirstSchedule, studentASecondSchedule] = + await loadDisbursementSchedules( + db, + applicationStudentA.currentAssessment.id, + ); + // Disbursement 1. + const studentADisbursement1 = new PartTimeCertRecordParser(record1); + expect(studentADisbursement1.recordType).toBe("02"); + expect(studentADisbursement1.hasUser(studentA.user)).toBe(true); + expect(studentAFirstSchedule.disbursementScheduleStatus).toBe( + DisbursementScheduleStatus.Sent, + ); + // Disbursement 2. + const studentADisbursement2 = new PartTimeCertRecordParser(record2); + expect(studentADisbursement2.recordType).toBe("02"); + expect(studentADisbursement2.hasUser(studentA.user)).toBe(true); + expect(studentASecondSchedule.disbursementScheduleStatus).toBe( + DisbursementScheduleStatus.Sent, + ); + // Student B + const [studentBFirstSchedule, studentBSecondSchedule] = + await loadDisbursementSchedules( + db, + applicationStudentB.currentAssessment.id, + ); + // Disbursement 1. + const studentBDisbursement1 = new PartTimeCertRecordParser(record3); + expect(studentBDisbursement1.recordType).toBe("02"); + expect(studentBDisbursement1.hasUser(studentB.user)).toBe(true); + expect(studentBFirstSchedule.disbursementScheduleStatus).toBe( + DisbursementScheduleStatus.Sent, + ); + // Disbursement 2. + expect(studentBSecondSchedule.disbursementScheduleStatus).toBe( + DisbursementScheduleStatus.Pending, + ); + }); }, ); diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/e-cert-record-parser.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/e-cert-record-parser.ts new file mode 100644 index 0000000000..770c4d4785 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/e-cert-record-parser.ts @@ -0,0 +1,31 @@ +import { User } from "@sims/sims-db"; + +/** + * Parses e-Cert record information. + */ +export abstract class ECertRecordParser { + /** + * Record type. + */ + abstract get recordType(): string; + + /** + * Student's first name. + */ + abstract get firstName(): string; + + /** + * Student's last name. + */ + abstract get lastName(): string; + + /** + * Validate if the first name and last names belongs to the + * provided student user. + * @param user user to be checked. + * @returns true if the users first name and last name matches. + */ + hasUser(user: Pick): boolean { + return user.lastName === this.lastName && user.firstName === this.firstName; + } +} diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/full-time-e-cert-record-parser.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/full-time-e-cert-record-parser.ts new file mode 100644 index 0000000000..d9bf07027f --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/full-time-e-cert-record-parser.ts @@ -0,0 +1,76 @@ +import { ECertRecordParser } from "."; + +/** + * Parses a full-time e-Cert record information. + * The hard-coded numbers along the class represents the position index + * as per the e-Cert documentation. + */ +export class FullTimeCertRecordParser extends ECertRecordParser { + /** + * List of e-Cert awards. All federal grants may potentially be listed + * while only total provincial grants (BCSG) will be available. + */ + private readonly awards: Record = {}; + + /** + * Initializes a new full-time parsed object. + * @param record record to be parsed. + */ + constructor(private readonly record: string) { + super(); + for (let i = 615; i < 715; i += 10) { + const awardCode = record.substring(i, i + 4).trim(); + if (!awardCode) { + // Read till find an empty award. + break; + } + const awardAmount = record.substring(i + 4, i + 14); + this.awards[awardCode] = +awardAmount; + } + } + + /** + * Record type. + */ + get recordType(): string { + return this.record.substring(0, 3); + } + + /** + * Student's first name. + */ + get firstName(): string { + return this.record.substring(176, 190).trim(); + } + + /** + * Student's last name. + */ + get lastName(): string { + return this.record.substring(151, 175).trim(); + } + + /** + * Federal CSLF amount (loan). + */ + get cslfAmount(): number { + return +this.record.substring(85, 91).trim(); + } + + /** + * Provincial BCSL amount (loan). + */ + get bcslAmount(): number { + return +this.record.substring(91, 97).trim(); + } + + /** + * List of e-Cert awards. All federal grants may potentially be listed + * while only total provincial grants (BCSG) will be available. + * @param grantCode grant code, for instance, CSGP, CSGD, CSGF, BCSG. + * @returns the grant amount when available, otherwise undefined. + */ + grantAmount(grantCode: string): number | undefined { + return this.awards[grantCode]; + } +} diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/index.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/index.ts new file mode 100644 index 0000000000..1b1b7c0a84 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/index.ts @@ -0,0 +1,3 @@ +export * from "./e-cert-record-parser"; +export * from "./part-time-e-cert-record-parser"; +export * from "./full-time-e-cert-record-parser"; diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/part-time-e-cert-record-parser.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/part-time-e-cert-record-parser.ts new file mode 100644 index 0000000000..1ca2345ce6 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/esdc-integration/ecert-integration/_tests_/parsers/part-time-e-cert-record-parser.ts @@ -0,0 +1,31 @@ +import { ECertRecordParser } from "./e-cert-record-parser"; + +/** + * Parses a part-time e-Cert record information. + */ +export class PartTimeCertRecordParser extends ECertRecordParser { + constructor(private readonly record: string) { + super(); + } + + /** + * Record type. + */ + get recordType(): string { + return this.record.substring(0, 2); + } + + /** + * Student's first name. + */ + get firstName(): string { + return this.record.substring(27, 42).trim(); + } + + /** + * Student's last name. + */ + get lastName(): string { + return this.record.substring(2, 27).trim(); + } +} diff --git a/sources/packages/backend/apps/queue-consumers/test/helpers/index.ts b/sources/packages/backend/apps/queue-consumers/test/helpers/index.ts index e7dd000f55..75a02fb909 100644 --- a/sources/packages/backend/apps/queue-consumers/test/helpers/index.ts +++ b/sources/packages/backend/apps/queue-consumers/test/helpers/index.ts @@ -1,2 +1,3 @@ export * from "./testing-modules/testing-modules-helper"; export * from "./test-describe-helpers"; +export * from "./mock-utils/job-mock-utils"; diff --git a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/job-mock-utils.ts b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/job-mock-utils.ts new file mode 100644 index 0000000000..75db17a417 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/job-mock-utils.ts @@ -0,0 +1,54 @@ +import { DeepMocked, createMock } from "@golevelup/ts-jest"; +import { Job } from "bull"; + +/** + * Result of the Bull Job mock creation. + */ +export class MockBullJobResult { + constructor( + public readonly job: DeepMocked>, + public readonly logMessages: string[], + ) {} + + /** + * Checks if the logs contains a entry that ends with the provided parameter. + * @param logMessage log message to be found using string endsWith method. + * @returns true if the log message was found. otherwise false. + */ + containLogMessage(logMessage: string): boolean { + return this.logMessages.some((message) => message.endsWith(logMessage)); + } + + /** + * Checks if the logs contains a entry that ends with the provided parameter. + * @param logMessages log messages to be found using string endsWith method. + * @returns true if the log message was found. otherwise false. + */ + containLogMessages(logMessages: string[]): boolean { + for (const message of logMessages) { + if (!this.containLogMessage(message)) { + return false; + } + } + return true; + } +} + +/** + * Creates a mocked Bull Job. + * @param data optional data to start the job. + * @returns mock creation result. + */ +export function mockBullJob(data?: T): MockBullJobResult { + const job = createMock>({ + id: "FakeJobId", + name: "FakeJobName", + data: data as any, + }); + const logMessages: string[] = []; + job.log.mockImplementation((message: string) => { + logMessages.push(message); + return Promise.resolve(); + }); + return new MockBullJobResult(job, logMessages); +} diff --git a/sources/packages/backend/apps/workers/src/controllers/disbursement/_tests_/e2e/disbursement.controller.saveDisbursementSchedules.e2e-spec.ts b/sources/packages/backend/apps/workers/src/controllers/disbursement/_tests_/e2e/disbursement.controller.saveDisbursementSchedules.e2e-spec.ts index e1da57260b..88bf4c94b2 100644 --- a/sources/packages/backend/apps/workers/src/controllers/disbursement/_tests_/e2e/disbursement.controller.saveDisbursementSchedules.e2e-spec.ts +++ b/sources/packages/backend/apps/workers/src/controllers/disbursement/_tests_/e2e/disbursement.controller.saveDisbursementSchedules.e2e-spec.ts @@ -51,7 +51,6 @@ describe("DisbursementController(e2e)-saveDisbursementSchedules", () => { fakeOriginalAssessment.application = savedApplication; // Original assessment - first disbursement (Sent). const firstSchedule = createFakeDisbursementSchedule({ - auditUser: savedUser, disbursementValues: [ createFakeDisbursementValue( DisbursementValueType.CanadaLoan, @@ -74,7 +73,6 @@ describe("DisbursementController(e2e)-saveDisbursementSchedules", () => { firstSchedule.disbursementScheduleStatus = DisbursementScheduleStatus.Sent; // Original assessment - second disbursement (Pending). const secondSchedule = createFakeDisbursementSchedule({ - auditUser: savedUser, disbursementValues: [ createFakeDisbursementValue( DisbursementValueType.CanadaGrant, diff --git a/sources/packages/backend/apps/workers/src/services/disbursement-schedule/disbursement-schedule.service.ts b/sources/packages/backend/apps/workers/src/services/disbursement-schedule/disbursement-schedule.service.ts index aa3988c07b..f5a3848d3e 100644 --- a/sources/packages/backend/apps/workers/src/services/disbursement-schedule/disbursement-schedule.service.ts +++ b/sources/packages/backend/apps/workers/src/services/disbursement-schedule/disbursement-schedule.service.ts @@ -165,7 +165,7 @@ export class DisbursementScheduleService extends RecordDataModelService { - disbursement.dateSent = dateSent; - disbursement.disbursementScheduleStatus = - DisbursementScheduleStatus.Sent; - disbursement.updatedAt = dateSent; - disbursement.modifier = this.systemUserService.systemUser; - }); - await entityManager - .getRepository(DisbursementSchedule) - .save(disbursements); + const disbursementScheduleRepo = + entityManager.getRepository(DisbursementSchedule); + await processInParallel((disbursement) => { + return disbursementScheduleRepo.update( + { id: disbursement.id }, + { + dateSent, + disbursementScheduleStatus: DisbursementScheduleStatus.Sent, + updatedAt: dateSent, + modifier: this.systemUserService.systemUser, + }, + ); + }, disbursements); return { generatedFile: fileInfo.filePath, uploadedRecords: disbursementRecords.length, diff --git a/sources/packages/backend/libs/integrations/src/institution-integration/ier12-integration/utils-service/application-event-code-during-enrolment-and-completed-utils.service.ts b/sources/packages/backend/libs/integrations/src/institution-integration/ier12-integration/utils-service/application-event-code-during-enrolment-and-completed-utils.service.ts index b0ba30fb7c..4d4f52733b 100644 --- a/sources/packages/backend/libs/integrations/src/institution-integration/ier12-integration/utils-service/application-event-code-during-enrolment-and-completed-utils.service.ts +++ b/sources/packages/backend/libs/integrations/src/institution-integration/ier12-integration/utils-service/application-event-code-during-enrolment-and-completed-utils.service.ts @@ -56,7 +56,7 @@ export class ApplicationEventCodeDuringEnrolmentAndCompletedUtilsService { currentDisbursementSchedule, activeRestrictionsActionTypes, ); - case DisbursementScheduleStatus.ReadToSend: + case DisbursementScheduleStatus.ReadyToSend: case DisbursementScheduleStatus.Sent: return this.eventCodeForCompletedApplicationWithSentDisbursement( currentDisbursementSchedule.disbursementValues, diff --git a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts index a3ce5352c3..5d03303e45 100644 --- a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts +++ b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts @@ -1,8 +1,10 @@ +import { RestrictionCode } from "@sims/services"; import { DisbursementSchedule, DisbursementValueType, EducationProgramOffering, RestrictionActionType, + StudentRestriction, } from "@sims/sims-db"; export interface DisbursementValue { @@ -133,52 +135,94 @@ export interface StudentActiveRestriction { * Restriction id. */ id: number; + /** + * Restriction code. + */ + code: RestrictionCode; /** * Actions associated with the restriction. */ actions: RestrictionActionType[]; } +/** + * Offering details needed for an e-Cert calculations. + */ +export type EligibleECertOffering = Pick< + EducationProgramOffering, + "id" | "offeringIntensity" | "actualTuitionCosts" | "programRelatedCosts" +>; + /** * Disbursement eligible to be part of an e-Cert. * The disbursement is the focus of calculations and data changes * while the other properties are supporting information. */ -export interface EligibleECertDisbursement { - /** - * Student id. - */ - studentId: number; - /** - * Indicates if the student has a validated SIN. - */ - hasValidSIN?: boolean; +export class EligibleECertDisbursement { /** - * All active student restrictions actions. - * These action can impact the e-Cert calculations. + * Creates a new instance of a eligible e-Cert to be calculated. + * @param studentId student id. + * @param hasValidSIN indicates if the student has a validated SIN. + * @param assessmentId assessment id. + * @param applicationId application id. + * @param disbursement Eligible schedule that must have the values updated + * calculated for an e-Cert. This database entity model will receive all + * modifications across multiple calculations steps. If all calculations + * are successful this will be used to persist the data to the database. + * @param offering education program offering. + * @param maxLifetimeBCLoanAmount maximum BC loan configured to the assessment's + * program year. + * @param restrictions All active student restrictions actions. These actions can + * impact the e-Cert calculations. + * This is a shared array reference between all the disbursements of a single student. + * Changes to this array should be available for all disbursements of the student. + * If a particular step generates or resolves an active restriction this array should + * be updated using the method {@link refreshActiveStudentRestrictions} to allow all + * steps to have access to the most updated data. */ - activeRestrictions: StudentActiveRestriction[]; - /** - * Assessment id. - */ - assessmentId: number; - /** - * Application id. - */ - applicationId: number; + constructor( + public readonly studentId: number, + public readonly hasValidSIN: boolean, + public readonly assessmentId: number, + public readonly applicationId: number, + public readonly disbursement: DisbursementSchedule, + public readonly offering: EligibleECertOffering, + public readonly maxLifetimeBCLoanAmount: number, + readonly restrictions: StudentActiveRestriction[], + ) {} + /** - * Eligible schedule that must have the values updated calculated for an e-Cert. - * This database entity model will receive all modifications across - * multiple calculations steps. If all calculations are successful - * this will be used to persist the data to the database. + * Refresh the complete list of student restrictions. + * @param activeRestrictions represents the most updated + * snapshot of all student active restrictions. */ - disbursement: DisbursementSchedule; - offering: Pick< - EducationProgramOffering, - "id" | "offeringIntensity" | "actualTuitionCosts" | "programRelatedCosts" - >; + refreshActiveStudentRestrictions( + activeRestrictions: StudentActiveRestriction[], + ) { + this.restrictions.length = 0; + this.restrictions.push(...activeRestrictions); + } + /** - * Maximum BC loan configured to the assessment's program year. + * All student active restrictions. */ - maxLifetimeBCLoanAmount: number; + get activeRestrictions(): ReadonlyArray { + return this.restrictions; + } +} + +/** + * Map student restrictions to the representation of active + * restrictions used along e-Cert calculations. + * @param studentRestrictions student active restrictions to be mapped. + * @returns simplified student active restrictions. + */ +export function mapStudentActiveRestrictions( + studentRestrictions: StudentRestriction[], +): StudentActiveRestriction[] { + return studentRestrictions.map((studentRestriction) => ({ + id: studentRestriction.restriction.id, + code: studentRestriction.restriction.restrictionCode as RestrictionCode, + actions: studentRestriction.restriction.actionType, + })); } diff --git a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-generation.service.ts b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-generation.service.ts index 9ca10d8250..cb3bc0af45 100644 --- a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-generation.service.ts +++ b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-generation.service.ts @@ -8,11 +8,20 @@ import { OfferingIntensity, DisbursementScheduleStatus, Application, + StudentRestriction, } from "@sims/sims-db"; import { InjectRepository } from "@nestjs/typeorm"; -import { EligibleECertDisbursement } from "./disbursement-schedule.models"; +import { + EligibleECertDisbursement, + StudentActiveRestriction, + mapStudentActiveRestrictions, +} from "./disbursement-schedule.models"; import { ConfigService } from "@sims/utilities/config"; +interface GroupedStudentActiveRestrictions { + [studentId: number]: StudentActiveRestriction[]; +} + /** * Manages all the preparation of the disbursements data needed to * generate the e-Cert. Check and execute possible overawards deductions @@ -70,8 +79,12 @@ export class ECertGenerationService { "student.id", "sinValidation.id", "sinValidation.isValidSIN", + // The student active restrictions are initially loaded along side all the student data but they can + // be potentially refreshed along the e-Cert calculations using the method getStudentActiveRestrictions. + // In case the amount of data returned need to be changed please ensure that the method also get updated. "studentRestriction.id", "restriction.id", + "restriction.restrictionCode", "restriction.actionType", "programYear.id", "programYear.maxLifetimeBCLoanAmount", @@ -112,37 +125,90 @@ export class ECertGenerationService { .orderBy("disbursementSchedule.disbursementDate", "ASC") .addOrderBy("disbursementSchedule.createdAt", "ASC") .getMany(); + + // Creates a unique array of active restrictions per student to be shared + // across all disbursements. + const groupedStudentRestrictions = + this.getGroupedStudentRestrictions(eligibleApplications); // Convert the application records to be returned as disbursements to allow // easier processing along the calculation steps. const eligibleDisbursements = eligibleApplications.flatMap((application) => { return application.currentAssessment.disbursementSchedules.map( (disbursement) => { - return { - studentId: application.student.id, - assessmentId: application.currentAssessment.id, - applicationId: application.id, - // Convert the nested restriction in StudentRestriction to a simple object. - activeRestrictions: application.student.studentRestrictions.map( - (studentRestriction) => { - return { - id: studentRestriction.restriction.id, - actions: studentRestriction.restriction.actionType, - }; - }, - ), - hasValidSIN: application.student.sinValidation.isValidSIN, + return new EligibleECertDisbursement( + application.student.id, + !!application.student.sinValidation.isValidSIN, + application.currentAssessment.id, + application.id, disbursement, - offering: application.currentAssessment.offering, - maxLifetimeBCLoanAmount: - application.programYear.maxLifetimeBCLoanAmount, - }; + application.currentAssessment.offering, + application.programYear.maxLifetimeBCLoanAmount, + groupedStudentRestrictions[application.student.id], + ); }, ); }); return eligibleDisbursements; } + /** + * Group student restriction for each student. + * @param eligibleApplications applications with student restrictions. + * @returns grouped student restrictions. + */ + private getGroupedStudentRestrictions( + eligibleApplications: Application[], + ): GroupedStudentActiveRestrictions { + return eligibleApplications.reduce( + (group: GroupedStudentActiveRestrictions, application) => { + const studentId = application.student.id; + if (!group[studentId]) { + // Populates a new student only once. + group[studentId] = mapStudentActiveRestrictions( + application.student.studentRestrictions, + ); + } + return group; + }, + {}, + ); + } + + /** + * Get active student restrictions to support the e-Cert calculations. + * These data is also loaded in bulk by the method {@link getEligibleDisbursements}. + * Case new data is retrieve here please ensure that the method will also be updated. + * @param studentId student to have the active restrictions updated. + * @param entityManager: EntityManager, + * @returns student active restrictions. + */ + async getStudentActiveRestrictions( + studentId: number, + entityManager: EntityManager, + ): Promise { + const studentRestrictions = await entityManager + .getRepository(StudentRestriction) + .find({ + select: { + id: true, + restriction: { + id: true, + restrictionCode: true, + actionType: true, + }, + }, + relations: { + restriction: true, + }, + where: { + student: { id: studentId }, + isActive: true, + }, + }); + return mapStudentActiveRestrictions(studentRestrictions); + } + /** * Get all records that must be part of the e-Cert files and that were not sent yet. * @param offeringIntensity disbursement offering intensity. @@ -226,7 +292,7 @@ export class ECertGenerationService { disbursementValues: true, }, where: { - disbursementScheduleStatus: DisbursementScheduleStatus.ReadToSend, + disbursementScheduleStatus: DisbursementScheduleStatus.ReadyToSend, studentAssessment: { offering: { offeringIntensity, diff --git a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/assert-life-time-maximum-full-time-step.ts b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/assert-life-time-maximum-full-time-step.ts index 3fb6e5d8c4..c7c68daadf 100644 --- a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/assert-life-time-maximum-full-time-step.ts +++ b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/assert-life-time-maximum-full-time-step.ts @@ -16,6 +16,8 @@ import { import { ECertProcessStep } from "./e-cert-steps-models"; import { ProcessSummary } from "@sims/utilities/logger"; import { EligibleECertDisbursement } from "../disbursement-schedule.models"; +import { ECertGenerationService } from "../e-cert-generation.service"; +import { getRestrictionByCode } from "./e-cert-steps-utils"; /** * Check if the student is reaching the full-time BCSL maximum. @@ -27,6 +29,7 @@ export class AssertLifeTimeMaximumFullTimeStep implements ECertProcessStep { private readonly systemUsersService: SystemUsersService, private readonly sfasApplicationService: SFASApplicationService, private readonly disbursementScheduleSharedService: DisbursementScheduleSharedService, + private readonly eCertGenerationService: ECertGenerationService, ) {} /** @@ -42,18 +45,44 @@ export class AssertLifeTimeMaximumFullTimeStep implements ECertProcessStep { log: ProcessSummary, ): Promise { log.info("Checking life time maximums for BC loans."); - for (const disbursementValue of eCertDisbursement.disbursement - .disbursementValues) { - if ( - disbursementValue.valueAmount && - disbursementValue.valueType === DisbursementValueType.BCLoan - ) { - await this.checkLifeTimeMaximumAndAddStudentRestriction( - eCertDisbursement, - disbursementValue, + if (getRestrictionByCode(eCertDisbursement, RestrictionCode.BCLM)) { + log.info( + `Student already has a ${RestrictionCode.BCLM} restriction, hence skipping the check.`, + ); + return true; + } + // Check if the BC Loan is present in the awards to be disbursed. + const bcLoan = eCertDisbursement.disbursement.disbursementValues.find( + (award) => award.valueType === DisbursementValueType.BCLoan, + ); + if (!bcLoan?.valueAmount) { + log.info( + `${bcLoan.valueCode} award not found or there is no amount to be disbursed, hence skipping the check.`, + ); + return true; + } + // Check if a new restriction should be created and award adjusted. + const newRestrictionCreated = + await this.checkLifeTimeMaximumAndAddStudentRestriction( + eCertDisbursement, + bcLoan, + entityManager, + ); + if (newRestrictionCreated) { + // If a new restriction was created refresh the active restrictions list. + const activeRestrictions = + await this.eCertGenerationService.getStudentActiveRestrictions( + eCertDisbursement.studentId, entityManager, ); - } + eCertDisbursement.refreshActiveStudentRestrictions(activeRestrictions); + log.info( + `New ${RestrictionCode.BCLM} restriction was added to the student account.`, + ); + } else { + log.info( + `No ${RestrictionCode.BCLM} restriction was created at this time.`, + ); } return true; } @@ -66,12 +95,13 @@ export class AssertLifeTimeMaximumFullTimeStep implements ECertProcessStep { * @param eCertDisbursement student disbursement that is part of one e-Cert. * @param disbursementValue award value to be verified. * @param entityManager used to execute the commands in the same transaction. + * @returns true if a new restriction was created, otherwise false. */ private async checkLifeTimeMaximumAndAddStudentRestriction( eCertDisbursement: EligibleECertDisbursement, disbursementValue: DisbursementValue, entityManager: EntityManager, - ): Promise { + ): Promise { // Get totals including legacy system. const [totalLegacyBCSLAmount, totalDisbursedBCSLAmount] = await Promise.all( [ @@ -87,31 +117,30 @@ export class AssertLifeTimeMaximumFullTimeStep implements ECertProcessStep { totalLegacyBCSLAmount + totalDisbursedBCSLAmount + disbursementValue.effectiveAmount; - if (totalLifeTimeAmount >= eCertDisbursement.maxLifetimeBCLoanAmount) { - // Amount subtracted when lifetime maximum is reached. - const amountSubtracted = - totalLifeTimeAmount - eCertDisbursement.maxLifetimeBCLoanAmount; - // Ideally disbursementValue.effectiveAmount should be greater or equal to amountSubtracted. - // The flow will not reach here if the ministry ignore the restriction for the previous - // disbursement/application and money went out to the student, even though they reach the maximum. - const newEffectiveAmount = - disbursementValue.effectiveAmount - amountSubtracted; - // Create RestrictionCode.BCLM restriction when lifetime maximum is reached/exceeded. - const bclmRestriction = - await this.studentRestrictionSharedService.createRestrictionToSave( - eCertDisbursement.studentId, - RestrictionCode.BCLM, - this.systemUsersService.systemUser.id, - eCertDisbursement.applicationId, - ); - if (bclmRestriction) { - await entityManager - .getRepository(StudentRestriction) - .save(bclmRestriction); - } - disbursementValue.effectiveAmount = round(newEffectiveAmount); - disbursementValue.restrictionAmountSubtracted = amountSubtracted; - disbursementValue.restrictionSubtracted = bclmRestriction.restriction; + if (totalLifeTimeAmount < eCertDisbursement.maxLifetimeBCLoanAmount) { + // The limit was not reached. + return false; } + // Amount subtracted when lifetime maximum is reached. + const amountSubtracted = + totalLifeTimeAmount - eCertDisbursement.maxLifetimeBCLoanAmount; + // Ideally disbursementValue.effectiveAmount should be greater or equal to amountSubtracted. + // The flow will not reach here if the ministry ignore the restriction for the previous + // disbursement/application and money went out to the student, even though they reach the maximum. + const newEffectiveAmount = + disbursementValue.effectiveAmount - amountSubtracted; + // Create RestrictionCode.BCLM restriction when lifetime maximum is reached/exceeded. + const bclmRestriction = + await this.studentRestrictionSharedService.createRestrictionToSave( + eCertDisbursement.studentId, + RestrictionCode.BCLM, + this.systemUsersService.systemUser.id, + eCertDisbursement.applicationId, + ); + await entityManager.getRepository(StudentRestriction).save(bclmRestriction); + disbursementValue.effectiveAmount = round(newEffectiveAmount); + disbursementValue.restrictionAmountSubtracted = amountSubtracted; + disbursementValue.restrictionSubtracted = bclmRestriction.restriction; + return true; } } diff --git a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/create-bc-total-grants-step.ts b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/create-bc-total-grants-step.ts index 3367432492..4ec25585c5 100644 --- a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/create-bc-total-grants-step.ts +++ b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/create-bc-total-grants-step.ts @@ -21,30 +21,18 @@ export class CreateBCTotalGrantsStep implements ECertProcessStep { * or by overaward deductions or "stop full time BC funding" restrictions. * @param eCertDisbursement eligible disbursement to be potentially added to an e-Cert. * If no BC grants are present the total will still be add as zero. - * @param _entityManager not used for this step. + * @param entityManager used to execute the commands in the same transaction. * @param log cumulative log summary. */ async executeStep( eCertDisbursement: EligibleECertDisbursement, - _entityManager: EntityManager, + entityManager: EntityManager, log: ProcessSummary, ): Promise { log.info( `Create ${DisbursementValueType.BCTotalGrant} (sum of the other BC Grants)`, ); - // For each schedule calculate the total BC grants. - let bcTotalGrant = eCertDisbursement.disbursement.disbursementValues.find( - (disbursementValue) => - disbursementValue.valueType === DisbursementValueType.BCTotalGrant, - ); - if (!bcTotalGrant) { - // If the 'BC Total Grant' is not present, add it. - bcTotalGrant = new DisbursementValue(); - bcTotalGrant.creator = this.systemUsersService.systemUser; - bcTotalGrant.valueCode = BC_TOTAL_GRANT_AWARD_CODE; - bcTotalGrant.valueType = DisbursementValueType.BCTotalGrant; - eCertDisbursement.disbursement.disbursementValues.push(bcTotalGrant); - } + // Calculate the total BC grants. const bcTotalGrantValueAmount = eCertDisbursement.disbursement.disbursementValues // Filter all BC grants. @@ -56,8 +44,14 @@ export class CreateBCTotalGrantsStep implements ECertProcessStep { .reduce((previousValue, currentValue) => { return previousValue + currentValue.effectiveAmount; }, 0); + const bcTotalGrant = new DisbursementValue(); + bcTotalGrant.disbursementSchedule = eCertDisbursement.disbursement; + bcTotalGrant.creator = this.systemUsersService.systemUser; + bcTotalGrant.valueCode = BC_TOTAL_GRANT_AWARD_CODE; + bcTotalGrant.valueType = DisbursementValueType.BCTotalGrant; bcTotalGrant.valueAmount = bcTotalGrantValueAmount; bcTotalGrant.effectiveAmount = bcTotalGrantValueAmount; + await entityManager.getRepository(DisbursementValue).insert(bcTotalGrant); return true; } } diff --git a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/e-cert-steps-utils.ts b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/e-cert-steps-utils.ts index 94f0edd684..54b8ef5f5a 100644 --- a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/e-cert-steps-utils.ts +++ b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/e-cert-steps-utils.ts @@ -8,6 +8,7 @@ import { EligibleECertDisbursement, StudentActiveRestriction, } from "../disbursement-schedule.models"; +import { RestrictionCode } from "@sims/services"; /** * Check active student restrictions by its action type @@ -25,6 +26,22 @@ export function getRestrictionByActionType( ); } +/** + * Check active student restrictions by its + * restriction code in an eligible disbursement. + * @param eCertDisbursement student disbursement to check student restrictions. + * @param code restriction code. + * @returns the first restriction of the requested code. + */ +export function getRestrictionByCode( + eCertDisbursement: EligibleECertDisbursement, + code: RestrictionCode, +): StudentActiveRestriction { + return eCertDisbursement.activeRestrictions?.find( + (restriction) => restriction.code === code, + ); +} + /** * Determine when a BC Full-time funding should not be disbursed. * In this case the e-Cert can still be generated with the federal funding. diff --git a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/persist-calculations-step.ts b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/persist-calculations-step.ts index b0ad81b7f3..ff9055f1a0 100644 --- a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/persist-calculations-step.ts +++ b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/e-cert-processing-steps/persist-calculations-step.ts @@ -3,6 +3,7 @@ import { SystemUsersService } from "@sims/services"; import { DisbursementSchedule, DisbursementScheduleStatus, + DisbursementValue, } from "@sims/sims-db"; import { ECertProcessStep } from "./e-cert-steps-models"; import { EntityManager } from "typeorm"; @@ -20,7 +21,7 @@ export class PersistCalculationsStep implements ECertProcessStep { /** * Persists all calculations executed for the disbursement also changing - * its status to {@link DisbursementScheduleStatus.ReadToSend}. + * its status to {@link DisbursementScheduleStatus.ReadyToSend}. * @param eCertDisbursement eligible disbursement to be potentially added to an e-Cert. * @param entityManager used to execute the commands in the same transaction. * @param log cumulative log summary. @@ -33,14 +34,44 @@ export class PersistCalculationsStep implements ECertProcessStep { log.info("Saving all e-Cert calculations."); const now = new Date(); const disbursement = eCertDisbursement.disbursement; - disbursement.disbursementScheduleStatus = - DisbursementScheduleStatus.ReadToSend; - disbursement.readyToSendDate = now; - disbursement.modifier = this.systemUsersService.systemUser; - disbursement.updatedAt = now; - await entityManager.getRepository(DisbursementSchedule).save(disbursement); + // Persists the changes to the disbursement. + // Using update instead save for better performance. + const disbursementScheduleRepo = + entityManager.getRepository(DisbursementSchedule); + disbursementScheduleRepo.update( + { id: disbursement.id }, + { + disbursementScheduleStatus: DisbursementScheduleStatus.ReadyToSend, + readyToSendDate: now, + tuitionRemittanceEffectiveAmount: + disbursement.tuitionRemittanceEffectiveAmount, + modifier: this.systemUsersService.systemUser, + updatedAt: now, + }, + ); + // Persist the changes to the disbursement values. + // Using update instead save for better performance. + const disbursementValueRepo = + entityManager.getRepository(DisbursementValue); + const disbursementValuesUpdatesPromises = + disbursement.disbursementValues.map((disbursementValue) => { + return disbursementValueRepo.update( + { id: disbursementValue.id }, + { + overawardAmountSubtracted: + disbursementValue.overawardAmountSubtracted, + restrictionAmountSubtracted: + disbursementValue.restrictionAmountSubtracted, + restrictionSubtracted: disbursementValue.restrictionSubtracted, + effectiveAmount: disbursementValue.effectiveAmount, + modifier: this.systemUsersService.systemUser, + updatedAt: now, + }, + ); + }); + await Promise.all(disbursementValuesUpdatesPromises); log.info( - `All calculations were saved and disbursement was set to '${DisbursementScheduleStatus.ReadToSend}'.`, + `All calculations were saved and disbursement was set to '${DisbursementScheduleStatus.ReadyToSend}'.`, ); return true; } diff --git a/sources/packages/backend/libs/services/src/disbursement-schedule/disbursement-schedule-shared.service.ts b/sources/packages/backend/libs/services/src/disbursement-schedule/disbursement-schedule-shared.service.ts index d9985f191d..38b8aa1de0 100644 --- a/sources/packages/backend/libs/services/src/disbursement-schedule/disbursement-schedule-shared.service.ts +++ b/sources/packages/backend/libs/services/src/disbursement-schedule/disbursement-schedule-shared.service.ts @@ -35,7 +35,7 @@ const TRANSACTION_IDLE_TIMEOUT_SECONDS = 60; * as disbursed to the student. */ const DISBURSED_STATUSES = [ - DisbursementScheduleStatus.ReadToSend, + DisbursementScheduleStatus.ReadyToSend, DisbursementScheduleStatus.Sent, ]; diff --git a/sources/packages/backend/libs/services/src/restriction/model/restriction.model.ts b/sources/packages/backend/libs/services/src/restriction/model/restriction.model.ts index 6f4d864583..7343696a53 100644 --- a/sources/packages/backend/libs/services/src/restriction/model/restriction.model.ts +++ b/sources/packages/backend/libs/services/src/restriction/model/restriction.model.ts @@ -1,7 +1,6 @@ /** - * Restriction codes that are used in API. - * * The Restriction table will have more restriction - * * codes, other than below mentioned codes. + * Restriction codes that are used in applications (API, Workers, Queue-Consumers). + * * The Restriction table will have more restriction codes other than below mentioned codes. */ export enum RestrictionCode { /** diff --git a/sources/packages/backend/libs/sims-db/src/entities/disbursement-schedule-status.type.ts b/sources/packages/backend/libs/sims-db/src/entities/disbursement-schedule-status.type.ts index 2c41144aa3..e407a072ea 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/disbursement-schedule-status.type.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/disbursement-schedule-status.type.ts @@ -13,7 +13,7 @@ export enum DisbursementScheduleStatus { * when the system should consider that no further modifications will * be executed to the e-Cert related data. */ - ReadToSend = "Ready to send", + ReadyToSend = "Ready to send", /** * The money values associated with the disbursement schedule * were included in an e-Cert file to be disbursed to the student. diff --git a/sources/packages/backend/libs/test-utils/src/factories/application.ts b/sources/packages/backend/libs/test-utils/src/factories/application.ts index ef0100b335..3773cfdd6a 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/application.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/application.ts @@ -5,7 +5,6 @@ import { ApplicationStatus, COEStatus, DisbursementSchedule, - DisbursementScheduleStatus, DisbursementValue, DisbursementValueType, EducationProgram, @@ -73,7 +72,9 @@ export function createFakeApplication( * @param relations dependencies. * - `institution` related institution. * - `institutionLocation` related location. - * - `disbursementValues` related disbursement schedules. + * - `disbursementValues` shared disbursement values used for first and/or second disbursements. + * - `firstDisbursementValues` first disbursement values. This will take precedence over the disbursementValues parameter. + * - `secondDisbursementValues` second disbursement values. This will take precedence over the disbursementValues parameter. * - `student` related student. * - `msfaaNumber` related MSFAA number. * - `program` related education program. @@ -94,6 +95,8 @@ export async function saveFakeApplicationDisbursements( institution?: Institution; institutionLocation?: InstitutionLocation; disbursementValues?: DisbursementValue[]; + firstDisbursementValues?: DisbursementValue[]; + secondDisbursementValues?: DisbursementValue[]; student?: Student; msfaaNumber?: MSFAANumber; program?: EducationProgram; @@ -121,37 +124,46 @@ export async function saveFakeApplicationDisbursements( await applicationRepo.save(savedApplication); const disbursementSchedules: DisbursementSchedule[] = []; // Original assessment - first disbursement. - const firstSchedule = createFakeDisbursementSchedule({ - auditUser: savedApplication.student.user, - disbursementValues: relations?.disbursementValues ?? [ - createFakeDisbursementValue(DisbursementValueType.CanadaLoan, "CSLF", 1), - ], - }); + const firstSchedule = createFakeDisbursementSchedule( + { + disbursementValues: relations?.firstDisbursementValues ?? + relations?.disbursementValues ?? [ + createFakeDisbursementValue( + DisbursementValueType.CanadaLoan, + "CSLF", + 1, + ), + ], + }, + { initialValues: options?.firstDisbursementInitialValues }, + ); firstSchedule.coeStatus = savedApplication.applicationStatus === ApplicationStatus.Completed ? COEStatus.completed : COEStatus.required; - firstSchedule.disbursementScheduleStatus = - options?.firstDisbursementInitialValues?.disbursementScheduleStatus ?? - DisbursementScheduleStatus.Pending; firstSchedule.msfaaNumber = relations?.msfaaNumber; firstSchedule.studentAssessment = savedApplication.currentAssessment; disbursementSchedules.push(firstSchedule); if (options?.createSecondDisbursement) { // Original assessment - second disbursement. - const secondSchedule = createFakeDisbursementSchedule({ - auditUser: savedApplication.student.user, - disbursementValues: relations?.disbursementValues ?? [ - createFakeDisbursementValue(DisbursementValueType.BCLoan, "BCSL", 1), - ], - }); - secondSchedule.coeStatus = COEStatus.required; - secondSchedule.disbursementScheduleStatus = - options?.secondDisbursementInitialValues?.disbursementScheduleStatus ?? - DisbursementScheduleStatus.Pending; + const secondSchedule = createFakeDisbursementSchedule( + { + disbursementValues: relations?.secondDisbursementValues ?? + relations?.disbursementValues ?? [ + createFakeDisbursementValue( + DisbursementValueType.BCLoan, + "BCSL", + 1, + ), + ], + }, + { initialValues: options?.secondDisbursementInitialValues }, + ); // First schedule is created with the current date as default. // Adding 60 days to create some time between the first and second schedules. - secondSchedule.disbursementDate = getISODateOnlyString(addDays(60)); + secondSchedule.disbursementDate = + options?.secondDisbursementInitialValues?.disbursementDate ?? + getISODateOnlyString(addDays(60)); secondSchedule.msfaaNumber = relations?.msfaaNumber; secondSchedule.studentAssessment = savedApplication.currentAssessment; disbursementSchedules.push(secondSchedule); diff --git a/sources/packages/backend/libs/test-utils/src/factories/disbursement-schedule.ts b/sources/packages/backend/libs/test-utils/src/factories/disbursement-schedule.ts index a48cfd37da..d618389407 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/disbursement-schedule.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/disbursement-schedule.ts @@ -4,33 +4,48 @@ import { DisbursementScheduleStatus, DisbursementValue, StudentAssessment, - User, } from "@sims/sims-db"; import { getISODateOnlyString } from "@sims/utilities"; import * as faker from "faker"; -export function createFakeDisbursementSchedule(relations?: { - studentAssessment?: StudentAssessment; - auditUser?: User; - disbursementValues?: DisbursementValue[]; -}): DisbursementSchedule { +/** + * Creates a disbursement schedule. + * @param relations dependencies. + * - `studentAssessment` student assessment. + * - `disbursementValues` disbursement values to be inserted. + * @param options additional options. + * - `initialValues` initial values. + * @returns + */ +export function createFakeDisbursementSchedule( + relations?: { + studentAssessment?: StudentAssessment; + disbursementValues?: DisbursementValue[]; + }, + options?: { + initialValues?: Partial; + }, +): DisbursementSchedule { const now = new Date(); const nowString = getISODateOnlyString(now); const schedule = new DisbursementSchedule(); // Fake number generated based on the max value that a document number can have as // per e-Cert documentation. Numbers under 1000000 can still be used for E2E tests. schedule.documentNumber = faker.random.number({ min: 1000000, max: 9999999 }); - schedule.disbursementDate = nowString; + schedule.disbursementDate = + options?.initialValues?.disbursementDate ?? nowString; schedule.negotiatedExpiryDate = nowString; schedule.dateSent = null; schedule.disbursementValues = relations?.disbursementValues; - schedule.coeStatus = COEStatus.required; + schedule.coeStatus = options?.initialValues?.coeStatus ?? COEStatus.required; schedule.coeUpdatedBy = null; - schedule.coeUpdatedAt = null; + schedule.coeUpdatedAt = options?.initialValues?.coeUpdatedAt; schedule.coeDeniedReason = null; schedule.coeDeniedOtherDesc = null; schedule.studentAssessment = relations?.studentAssessment; schedule.tuitionRemittanceRequestedAmount = 0; - schedule.disbursementScheduleStatus = DisbursementScheduleStatus.Pending; + schedule.disbursementScheduleStatus = + options?.initialValues?.disbursementScheduleStatus ?? + DisbursementScheduleStatus.Pending; return schedule; }