diff --git a/backend/src/sync/eligibility/eligibility.config.ts b/backend/src/sync/eligibility/eligibility.config.ts index b6804c3f..0d165e60 100644 --- a/backend/src/sync/eligibility/eligibility.config.ts +++ b/backend/src/sync/eligibility/eligibility.config.ts @@ -9,6 +9,8 @@ export const PROTECTED_STATUSES = [ CSA_STATUS.BATCH_SENT_CANCELLATION, CSA_STATUS.APPLICATION_REFUSED_CRA, CSA_STATUS.CANCELLATION_REFUSED_CRA, + CSA_STATUS.CRA_ERROR_APPLICATION, + CSA_STATUS.CRA_ERROR_CANCELLATION, CSA_STATUS.OVER_18, ] as const diff --git a/backend/src/sync/eligibility/eligibility.service.spec.ts b/backend/src/sync/eligibility/eligibility.service.spec.ts index b525528b..9712bf7a 100644 --- a/backend/src/sync/eligibility/eligibility.service.spec.ts +++ b/backend/src/sync/eligibility/eligibility.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing' import { PrismaService } from 'src/common/database/prisma.service' import { AppLogger } from 'src/common/logger/app-logger' +import { CSA_STATUS } from 'src/common/state-machine/constants/csa-status.constants' import { pacificToday } from 'src/common/utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PROTECTED_STATUSES, PROTECTED_STATUSES_SQL } from './eligibility.config' @@ -22,6 +23,11 @@ describe('PROTECTED_STATUSES_SQL', () => { const count = PROTECTED_STATUSES_SQL.split(',').length expect(count).toBe(PROTECTED_STATUSES.length) }) + + it('should include CRA error statuses as protected', () => { + expect(PROTECTED_STATUSES).toContain(CSA_STATUS.CRA_ERROR_APPLICATION) + expect(PROTECTED_STATUSES).toContain(CSA_STATUS.CRA_ERROR_CANCELLATION) + }) }) describe('EligibilityService', () => { diff --git a/backend/src/sync/eligibility/eligibility.service.ts b/backend/src/sync/eligibility/eligibility.service.ts index d9496845..8f3418ef 100644 --- a/backend/src/sync/eligibility/eligibility.service.ts +++ b/backend/src/sync/eligibility/eligibility.service.ts @@ -85,6 +85,8 @@ export function selectPrimaryRecords( } const primaryPlacement = selectPrimaryPlacement(profile.placements) + const placementContractKey = normalize(primaryPlacement?.contractNumber) + const placementProviderKey = normalize(primaryPlacement?.providerId) // Primary Order: match via primary placement's link key let primaryOrder: OrderRecord | null = null @@ -92,11 +94,18 @@ export function selectPrimaryRecords( primaryOrder = profile.orders.find((order) => order.agreementRowId === primaryPlacement.agreementRowId) ?? null + if (!primaryOrder && placementContractKey) { + primaryOrder = + profile.orders.find( + (order) => + order.source === 'MIS' && normalize(order.contractNumber) === placementContractKey, + ) ?? null + } } else if (primaryPlacement?.source === 'MIS' && primaryPlacement.contractNumber) { primaryOrder = profile.orders.find( (order) => - order.source === 'MIS' && order.contractNumber === primaryPlacement.contractNumber, + order.source === 'MIS' && normalize(order.contractNumber) === placementContractKey, ) ?? null } @@ -106,12 +115,22 @@ export function selectPrimaryRecords( primaryAgreement = profile.agreements.find((agreement) => agreement.rowId === primaryPlacement.agreementRowId) ?? null + if (!primaryAgreement && placementContractKey) { + primaryAgreement = + profile.agreements.find( + (agreement) => + agreement.source === 'MIS' && + normalize(agreement.contractNumber) === placementContractKey && + normalize(agreement.providerId) === placementProviderKey, + ) ?? null + } } else if (primaryPlacement?.source === 'MIS' && primaryPlacement.contractNumber) { primaryAgreement = profile.agreements.find( (agreement) => agreement.source === 'MIS' && - agreement.contractNumber === primaryPlacement.contractNumber, + normalize(agreement.contractNumber) === placementContractKey && + normalize(agreement.providerId) === placementProviderKey, ) ?? null } @@ -132,16 +151,30 @@ function selectOocPrimaryRecords( primaryOrder: OrderRecord | null primaryAgreement: AgreementRecord | null } { - const primaryAgreement = selectOocPrimaryAgreement(profile.agreements) - const primaryOrder = - primaryAgreement?.rowId != null - ? findClosedIcmOrderPreviousMonth(profile.orders, primaryAgreement.rowId, referenceDate) - : null + const primaryAgreement = selectOocPrimaryAgreement(profile.agreements, profile.placements) + let primaryOrder: OrderRecord | null = null + + if (primaryAgreement?.source === 'ICM' && primaryAgreement.rowId != null) { + primaryOrder = findClosedIcmOrderPreviousMonth( + profile.orders, + primaryAgreement.rowId, + referenceDate, + ) + } else if (primaryAgreement?.source === 'MIS' && primaryAgreement.contractNumber != null) { + primaryOrder = findClosedMisPaymentPreviousMonth( + profile.orders, + primaryAgreement.contractNumber, + referenceDate, + ) + } return { primaryPlacement: null, primaryOrder, primaryAgreement } } -function selectOocPrimaryAgreement(agreements: AgreementRecord[]): AgreementRecord | null { +function selectOocPrimaryAgreement( + agreements: AgreementRecord[], + placements: PlacementRecord[], +): AgreementRecord | null { const oocAgreements = agreements.filter( (agreement) => agreement.source === 'ICM' && normalize(agreement.agreementType) === 'OUT OF CARE', @@ -154,6 +187,46 @@ function selectOocPrimaryAgreement(agreements: AgreementRecord[]): AgreementReco if (active) return active const withEndDate = oocAgreements.filter((agreement) => agreement.agreementEndDate != null) + if (withEndDate.length > 0) { + return withEndDate.reduce((latest, current) => + current.agreementEndDate!.getTime() > latest.agreementEndDate!.getTime() ? current : latest, + ) + } + + return selectOocMisAgreement(agreements, placements) +} + +function selectOocMisAgreement( + agreements: AgreementRecord[], + placements: PlacementRecord[], +): AgreementRecord | null { + const misPlacementKeys = new Set( + placements + .filter((placement) => placement.source === 'MIS') + .map((placement) => { + const contractNumber = normalize(placement.contractNumber) + const providerId = normalize(placement.providerId) + if (!contractNumber || !providerId) return null + return `${contractNumber}::${providerId}` + }) + .filter((key): key is string => key != null), + ) + if (misPlacementKeys.size === 0) return null + + const misContracts = agreements.filter( + (agreement) => + agreement.source === 'MIS' && + agreement.contractNumber != null && + misPlacementKeys.has( + `${normalize(agreement.contractNumber)}::${normalize(agreement.providerId)}`, + ), + ) + if (misContracts.length === 0) return null + + const active = misContracts.find((agreement) => normalize(agreement.agreementStatus) === 'ACTIVE') + if (active) return active + + const withEndDate = misContracts.filter((agreement) => agreement.agreementEndDate != null) if (withEndDate.length === 0) return null return withEndDate.reduce((latest, current) => @@ -181,6 +254,29 @@ function findClosedIcmOrderPreviousMonth( ) } +function findClosedMisPaymentPreviousMonth( + orders: OrderRecord[], + contractNumber: string, + referenceDate: Date, +): OrderRecord | null { + const prevMonth = getPreviousMonth(referenceDate) + const normalizedContractNumber = normalize(contractNumber) + if (!normalizedContractNumber) return null + + const matching = orders.filter( + (order) => + order.source === 'MIS' && + normalize(order.contractNumber) === normalizedContractNumber && + normalize(order.orderStatus) === 'CLOSED' && + isInMonth(order.effectiveStartDate, prevMonth), + ) + if (matching.length === 0) return null + + return matching.reduce((highest, current) => + current.amount > highest.amount ? current : highest, + ) +} + const SOURCE_TYPE_PRIORITY: Array<{ source: 'ICM' | 'MIS'; type: string }> = [ { source: 'ICM', type: 'PLACEMENT' }, { source: 'ICM', type: 'NON-PLACEMENT LOCATION' }, diff --git a/backend/src/sync/eligibility/select-primary-records.spec.ts b/backend/src/sync/eligibility/select-primary-records.spec.ts index 87d89903..7f21def8 100644 --- a/backend/src/sync/eligibility/select-primary-records.spec.ts +++ b/backend/src/sync/eligibility/select-primary-records.spec.ts @@ -253,6 +253,24 @@ describe('selectPrimaryRecords', () => { ) expect(result.primaryOrder).toBeNull() }) + + it('falls back to contractNumber when ICM agreementRowId has no matching order', () => { + const placement = makePlacement({ + source: 'ICM', + agreementRowId: 'AGR-MISSING', + contractNumber: ' CON-1 ', + }) + const matchingMisOrder = makeOrder({ + source: 'MIS', + contractNumber: 'con-1', + orderNumber: 'MIS-MATCH', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], orders: [matchingMisOrder] }), + ) + expect(result.primaryOrder!.orderNumber).toBe('MIS-MATCH') + }) }) describe('agreement matching', () => { @@ -294,6 +312,46 @@ describe('selectPrimaryRecords', () => { ) expect(result.primaryAgreement).toBeNull() }) + + it('falls back to contractNumber when ICM agreementRowId has no matching agreement', () => { + const placement = makePlacement({ + source: 'ICM', + agreementRowId: 'AGR-MISSING', + contractNumber: ' CON-1 ', + providerId: 'RE-1', + }) + const matchingMisAgreement = makeAgreement({ + source: 'MIS', + contractNumber: 'con-1', + providerId: 're-1', + agreementType: 'MIS-CONTRACT', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], agreements: [matchingMisAgreement] }), + ) + expect(result.primaryAgreement!.agreementType).toBe('MIS-CONTRACT') + }) + + it('does not match MIS agreement with different providerId on fallback', () => { + const placement = makePlacement({ + source: 'ICM', + agreementRowId: 'AGR-MISSING', + contractNumber: 'CON-1', + providerId: 'RE-1', + }) + const wrongProviderAgreement = makeAgreement({ + source: 'MIS', + contractNumber: 'CON-1', + providerId: 'RE-2', + agreementType: 'WRONG-PROVIDER', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], agreements: [wrongProviderAgreement] }), + ) + expect(result.primaryAgreement).toBeNull() + }) }) describe('end-to-end: placement drives order and agreement selection', () => { @@ -521,6 +579,132 @@ describe('selectPrimaryRecords', () => { expect(result.primaryOrder).toBeNull() }) + it('falls back to MIS active contract when no ICM OOC agreement exists', () => { + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + contractNumber: 'CON-MIS', + providerId: 'RE-1', + }) + const misAgreement = makeAgreement({ + source: 'MIS', + contractNumber: 'CON-MIS', + providerId: 'RE-1', + agreementStatus: 'Active', + agreementType: '003', + }) + const misPayment = makeOrder({ + source: 'MIS', + contractNumber: 'CON-MIS', + orderStatus: 'Closed', + orderNumber: 'PMT-APR', + effectiveStartDate: new Date('2026-04-10T00:00:00Z'), + }) + + const result = selectPrimaryRecords( + makeProfile({ + misLegalAuthCode: 'OPC', + placements: [misPlacement], + agreements: [misAgreement], + orders: [misPayment], + }), + referenceDate, + ) + + expect(result.primaryPlacement).toBeNull() + expect(result.primaryAgreement!.source).toBe('MIS') + expect(result.primaryAgreement!.contractNumber).toBe('CON-MIS') + expect(result.primaryOrder!.orderNumber).toBe('PMT-APR') + }) + + it('falls back to MIS latest ended contract when no active MIS contract exists', () => { + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + contractNumber: 'CON-MIS', + providerId: 'RE-1', + }) + const olderContract = makeAgreement({ + source: 'MIS', + contractNumber: 'CON-MIS', + providerId: 'RE-1', + agreementStatus: 'Ended', + agreementEndDate: new Date('2026-01-01T00:00:00Z'), + }) + const newerContract = makeAgreement({ + source: 'MIS', + contractNumber: 'CON-MIS', + providerId: 'RE-1', + agreementStatus: 'Ended', + agreementEndDate: new Date('2026-03-01T00:00:00Z'), + }) + const misPayment = makeOrder({ + source: 'MIS', + contractNumber: 'CON-MIS', + orderStatus: 'Closed', + orderNumber: 'PMT-APR', + effectiveStartDate: new Date('2026-04-15T00:00:00Z'), + }) + + const result = selectPrimaryRecords( + makeProfile({ + misLegalAuthCode: 'OPO', + placements: [misPlacement], + agreements: [olderContract, newerContract], + orders: [misPayment], + }), + referenceDate, + ) + + expect(result.primaryPlacement).toBeNull() + expect(result.primaryAgreement!.source).toBe('MIS') + expect(result.primaryAgreement!.agreementEndDate).toEqual(new Date('2026-03-01T00:00:00Z')) + expect(result.primaryOrder!.orderNumber).toBe('PMT-APR') + }) + + it('does not select MIS contract when contract matches but providerId differs', () => { + const result = selectPrimaryRecords( + makeProfile({ + misLegalAuthCode: 'OPO', + placements: [ + makePlacement({ + source: 'MIS', + contractNumber: 'CON-MIS', + providerId: 'RE-1', + }), + ], + agreements: [ + makeAgreement({ + source: 'MIS', + contractNumber: 'CON-MIS', + providerId: 'RE-2', + agreementStatus: 'Active', + }), + ], + }), + referenceDate, + ) + + expect(result.primaryAgreement).toBeNull() + expect(result.primaryOrder).toBeNull() + }) + + it('keeps agreement and product blank when OOC has no ICM and no MIS contracts', () => { + const result = selectPrimaryRecords( + makeProfile({ + misLegalAuthCode: 'OPT', + placements: [makePlacement({ source: 'MIS', contractNumber: 'CON-MIS' })], + agreements: [], + orders: [makeOrder({ source: 'MIS', contractNumber: 'CON-MIS', orderStatus: 'Closed' })], + }), + referenceDate, + ) + + expect(result.primaryPlacement).toBeNull() + expect(result.primaryAgreement).toBeNull() + expect(result.primaryOrder).toBeNull() + }) + it('treats whitespace MIS legal codes as OOC', () => { const activeAgreement = makeAgreement({ source: 'ICM', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18243a26..6bebddf4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -173,8 +173,9 @@ const BATCH_STATUS_FILTER_OPTIONS = [ const BATCH_DETAILS_STATUS_FILTER_OPTIONS = [ { value: 'Pending', label: 'Pending' }, { value: 'In Progress', label: 'In Progress' }, + { value: 'Approved', label: 'Approved' }, + { value: 'Refused', label: 'Refused' }, { value: 'Error', label: 'Error' }, - { value: 'Processed', label: 'Processed' }, ] // Initiated By options for filter dropdown (used in Batch Requests)