diff --git a/backend/src/common/utils.spec.ts b/backend/src/common/utils.spec.ts index e08c0c84..0a394c5d 100644 --- a/backend/src/common/utils.spec.ts +++ b/backend/src/common/utils.spec.ts @@ -9,6 +9,7 @@ import { isEligibleAge, parseCalendarDate, parseDateAsPacific, + parseISODatePacific, } from './utils' describe('firstDayOfPreviousMonthPacific', () => { @@ -302,6 +303,47 @@ describe('parseDateAsPacific — DST edge cases', () => { }) }) +describe('parseISODatePacific', () => { + it('should parse date-only string as Pacific midnight', () => { + // PST (UTC-8): 2026-01-09T00:00:00-08:00 = 2026-01-09T08:00:00Z + const result = parseISODatePacific('2026-01-09') + expect(result.toISOString()).toBe('2026-01-09T08:00:00.000Z') + }) + + it('should preserve the calendar date in Pacific time', () => { + // The whole point: this should NOT shift to Jan 8 in Pacific + const result = parseISODatePacific('2026-01-09') + expect(result.toISOString().split('T')[0]).toBe('2026-01-09') + }) + + it('should pass through full datetime strings unchanged', () => { + const result = parseISODatePacific('2026-01-09T14:30:00.000Z') + expect(result.toISOString()).toBe('2026-01-09T14:30:00.000Z') + }) + + it('should handle PDT (summer) date-only correctly', () => { + // PDT (UTC-7): 2026-07-15T00:00:00-07:00 = 2026-07-15T07:00:00Z + const result = parseISODatePacific('2026-07-15') + expect(result.toISOString()).toBe('2026-07-15T07:00:00.000Z') + }) + + it('should handle spring-forward date', () => { + const result = parseISODatePacific('2026-03-08') + expect(result.toISOString()).toBe('2026-03-08T08:00:00.000Z') + }) + + it('should handle fall-back date (PST after DST ends)', () => { + // Dec 1 2026 is in PST (UTC-8) + const result = parseISODatePacific('2026-12-01') + expect(result.toISOString()).toBe('2026-12-01T08:00:00.000Z') + }) + + it('should not reinterpret full datetime at midnight UTC as Pacific', () => { + const result = parseISODatePacific('2026-01-09T00:00:00.000Z') + expect(result.toISOString()).toBe('2026-01-09T00:00:00.000Z') + }) +}) + describe('isEligibleAge — timezone boundary cases', () => { it('should handle DOB as Prisma DATE (midnight UTC)', () => { const dob = new Date('2008-02-01T00:00:00.000Z') @@ -362,10 +404,19 @@ describe('enrichLabels', () => { it('should not convert TIMESTAMPTZ fields', () => { const ts = new Date('2025-06-15T14:30:00.000Z') - const record = { csaStatusEffectiveDate: ts, actualStartDate: ts } + const record = { csaStatusEffectiveDate: ts, csaSentDate: ts } const result = enrichLabels(record) expect(result.csaStatusEffectiveDate).toBeInstanceOf(Date) - expect(result.actualStartDate).toBeInstanceOf(Date) + expect(result.csaSentDate).toBeInstanceOf(Date) + }) + + it('should convert placement/agreement date fields to date-only strings', () => { + const ts = new Date('2025-06-15T14:30:00.000Z') + const record = { actualStartDate: ts, agreementStartDate: ts, terminationDate: ts } + const result = enrichLabels(record) + expect(result.actualStartDate).toBe('2025-06-15') + expect(result.agreementStartDate).toBe('2025-06-15') + expect(result.terminationDate).toBe('2025-06-15') }) it('should still add labels and flags alongside date conversion', () => { diff --git a/backend/src/common/utils.ts b/backend/src/common/utils.ts index 80ce20c3..6120ca0a 100644 --- a/backend/src/common/utils.ts +++ b/backend/src/common/utils.ts @@ -17,6 +17,16 @@ export function firstDayOfPreviousMonthPacific(referenceDate: Date = new Date()) const PACIFIC_ZONE = 'America/Vancouver' +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/ +// Date-only strings ('2026-01-09') are interpreted as UTC midnight by new Date(), +// which shifts to the previous day in Pacific time. Parse as Pacific midnight instead. +export function parseISODatePacific(value: string): Date { + if (DATE_ONLY_PATTERN.test(value)) { + return DateTime.fromISO(value, { zone: PACIFIC_ZONE }).toJSDate() + } + return new Date(value) +} + export function formatDatePacific(date: Date): string { return DateTime.fromJSDate(date).setZone(PACIFIC_ZONE).toFormat('MM/dd/yyyy') } @@ -66,6 +76,11 @@ const DATE_ONLY_FIELDS = new Set([ 'orderEffectiveEndDate', 'careEndDate', 'batchDate', + 'actualStartDate', + 'actualEndDate', + 'agreementStartDate', + 'agreementEndDate', + 'terminationDate', ]) export function enrichLabels>(record: T): T { diff --git a/backend/src/sync/eligibility/cancellation/determine-cancellation-reason.ts b/backend/src/sync/eligibility/cancellation/determine-cancellation-reason.ts index 829bb446..246bab98 100644 --- a/backend/src/sync/eligibility/cancellation/determine-cancellation-reason.ts +++ b/backend/src/sync/eligibility/cancellation/determine-cancellation-reason.ts @@ -47,15 +47,15 @@ export function determineCancellationReason(input: CancellationInput): Cancellat // Code 22: Child Missing / AWOL const hasIcmAwol = input.icmPlacements.some( - (p) => - normalize(p.type) === ICM_PLACEMENT.TYPE_NON_PLACEMENT && - normalize(p.serviceType) === ICM_PLACEMENT.SUBTYPE_AWOL && - normalize(p.status) === ICM_PLACEMENT.STATUS_ACTIVE, + (placement) => + normalize(placement.type) === ICM_PLACEMENT.TYPE_NON_PLACEMENT && + normalize(placement.serviceType) === ICM_PLACEMENT.SUBTYPE_AWOL && + normalize(placement.status) === ICM_PLACEMENT.STATUS_ACTIVE, ) const hasMisAwol = input.misPlacements.some( - (p) => - normalize(p.type) === MIS_PLACEMENT.TYPE_AWOL && - normalize(p.status) === MIS_PLACEMENT.STATUS_ACTIVE, + (placement) => + normalize(placement.type) === MIS_PLACEMENT.TYPE_AWOL && + normalize(placement.status) === MIS_PLACEMENT.STATUS_ACTIVE, ) if (hasIcmAwol || hasMisAwol) { return { isIneligible: true, cancelReasonCode: CANCEL_REASON.CHILD_MISSING_AWOL } @@ -63,15 +63,15 @@ export function determineCancellationReason(input: CancellationInput): Cancellat // Code 29: Adoption const hasIcmAdoption = input.icmPlacements.some( - (p) => - normalize(p.type) === ICM_PLACEMENT.TYPE_NON_PLACEMENT && - normalize(p.serviceType) === ICM_PLACEMENT.SUBTYPE_ADOPTION && - normalize(p.status) === ICM_PLACEMENT.STATUS_ACTIVE, + (placement) => + normalize(placement.type) === ICM_PLACEMENT.TYPE_NON_PLACEMENT && + normalize(placement.serviceType) === ICM_PLACEMENT.SUBTYPE_ADOPTION && + normalize(placement.status) === ICM_PLACEMENT.STATUS_ACTIVE, ) const hasMisAdoption = input.misPlacements.some( - (p) => - normalize(p.type) === MIS_PLACEMENT.TYPE_ADOPTION && - normalize(p.status) === MIS_PLACEMENT.STATUS_ACTIVE, + (placement) => + normalize(placement.type) === MIS_PLACEMENT.TYPE_ADOPTION && + normalize(placement.status) === MIS_PLACEMENT.STATUS_ACTIVE, ) if (hasIcmAdoption || hasMisAdoption) { return { isIneligible: true, cancelReasonCode: CANCEL_REASON.ADOPTION } diff --git a/backend/src/sync/eligibility/cancellation/determine-care-end-date.spec.ts b/backend/src/sync/eligibility/cancellation/determine-care-end-date.spec.ts index b9098d33..4205e3d3 100644 --- a/backend/src/sync/eligibility/cancellation/determine-care-end-date.spec.ts +++ b/backend/src/sync/eligibility/cancellation/determine-care-end-date.spec.ts @@ -1,30 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { OrderRecord, PlacementRecord } from '../eligibility.types' +import { makeOrder, makePlacement } from '../test-helpers' import { determineCareEndDate } from './determine-care-end-date' -const makeOrder = (overrides: Partial = {}): OrderRecord => ({ - orderType: 'Variable', - orderStatus: 'Closed', - effectiveStartDate: new Date('2025-01-01'), - effectiveEndDate: null, - amount: 100, - contractNumber: null, - source: 'ICM', - ...overrides, -}) - -const makePlacement = (overrides: Partial = {}): PlacementRecord => ({ - type: 'Placement', - status: 'Active', - startDate: new Date('2025-01-01'), - endDate: null, - contractNumber: null, - agreementRowId: null, - paidUnpaid: null, - source: 'ICM', - ...overrides, -}) - describe('determineCareEndDate', () => { describe('Order/Payment end dates', () => { it('should use ICM order effectiveEndDate when status is Closed', () => { diff --git a/backend/src/sync/eligibility/cancellation/determine-care-end-date.ts b/backend/src/sync/eligibility/cancellation/determine-care-end-date.ts index c3f36ba9..d24e6877 100644 --- a/backend/src/sync/eligibility/cancellation/determine-care-end-date.ts +++ b/backend/src/sync/eligibility/cancellation/determine-care-end-date.ts @@ -21,19 +21,19 @@ export function determineCareEndDate( // latest order/payment end date const orderDates = orders .filter( - (o) => - o.effectiveEndDate != null && - ((o.source === 'ICM' && normalize(o.orderStatus) === 'CLOSED') || - (o.source === 'MIS' && normalize(o.orderStatus) === 'PROCESSED')), + (order) => + order.effectiveEndDate != null && + ((order.source === 'ICM' && normalize(order.orderStatus) === 'CLOSED') || + (order.source === 'MIS' && normalize(order.orderStatus) === 'PROCESSED')), ) - .map((o) => o.effectiveEndDate!.getTime()) + .map((order) => order.effectiveEndDate!.getTime()) const calculatedOrderDate = orderDates.length > 0 ? new Date(Math.max(...orderDates)) : null // latest placement end date const placementDates = placements - .filter((p) => p.endDate != null) - .map((p) => p.endDate!.getTime()) + .filter((placement) => placement.endDate != null) + .map((placement) => placement.endDate!.getTime()) const calculatedPlacementDate = placementDates.length > 0 ? new Date(Math.max(...placementDates)) : null diff --git a/backend/src/sync/eligibility/eligibility.service.ts b/backend/src/sync/eligibility/eligibility.service.ts index 02bc856f..65da7fb5 100644 --- a/backend/src/sync/eligibility/eligibility.service.ts +++ b/backend/src/sync/eligibility/eligibility.service.ts @@ -4,7 +4,13 @@ import { TRANSACTION_TYPES } from 'src/api/contacts/constants' import { PrismaService } from 'src/common/database/prisma.service' import { BATCH_STATUS } from 'src/common/state-machine/constants/batch-status.constants' import { CSA_STATUS } from 'src/common/state-machine/constants/csa-status.constants' -import { getAgeCutoffDate, isEligibleAge, normalize, pacificToday } from 'src/common/utils' +import { + getAgeCutoffDate, + isEligibleAge, + normalize, + pacificToday, + parseISODatePacific, +} from 'src/common/utils' import { JobType } from 'src/jobs/enums/job-type.enum' import { JobsService } from 'src/jobs/jobs.service' import { CANCEL_REASON } from './cancellation/cancellation-reason.constants' @@ -53,6 +59,66 @@ const RULES: EligibilityRule[] = [ step4_FetchAgreementContract, step6_OrderPaymentCheck, ] +// Select one representative placement, order, and agreement to denormalize +// into the master contacts table. +// Priority: ICM placement > ICM non-placement > MIS placement > MIS non-placement +export function selectPrimaryRecords(profile: ContactProfile): { + primaryPlacement: PlacementRecord | null + primaryOrder: OrderRecord | null + primaryAgreement: AgreementRecord | null +} { + const activeRecords = profile.placements.filter((placement) => + ['ACTIVE', 'INTERRUPTED'].includes(normalize(placement.status)), + ) + const primaryPlacement = + activeRecords.find( + (placement) => placement.source === 'ICM' && normalize(placement.type) === 'PLACEMENT', + ) ?? + activeRecords.find( + (placement) => + placement.source === 'ICM' && normalize(placement.type) === 'NON-PLACEMENT LOCATION', + ) ?? + activeRecords.find( + (placement) => placement.source === 'MIS' && normalize(placement.type) === 'PLACEMENT', + ) ?? + activeRecords.find( + (placement) => + placement.source === 'MIS' && normalize(placement.type) === 'NON-PLACEMENT LOCATION', + ) ?? + null + + // Primary Order: match via primary placement's link key + let primaryOrder: OrderRecord | null = null + if (primaryPlacement?.source === 'ICM' && primaryPlacement.agreementRowId) { + primaryOrder = + profile.orders.find((order) => order.agreementRowId === primaryPlacement.agreementRowId) ?? + null + } else if (primaryPlacement?.source === 'MIS' && primaryPlacement.contractNumber) { + primaryOrder = + profile.orders.find( + (order) => + order.source === 'MIS' && order.contractNumber === primaryPlacement.contractNumber, + ) ?? null + } + + // Primary Agreement: match via primary placement's link key + let primaryAgreement: AgreementRecord | null = null + if (primaryPlacement?.source === 'ICM' && primaryPlacement.agreementRowId) { + primaryAgreement = + profile.agreements.find((agreement) => agreement.rowId === primaryPlacement.agreementRowId) ?? + null + } else if (primaryPlacement?.source === 'MIS' && primaryPlacement.contractNumber) { + primaryAgreement = + profile.agreements.find( + (agreement) => + agreement.source === 'MIS' && + agreement.contractNumber === primaryPlacement.contractNumber, + ) ?? null + } + + return { primaryPlacement, primaryOrder, primaryAgreement } +} + interface UpsertContext { profile: ContactProfile result: EligibilityResult @@ -76,181 +142,215 @@ const CONTACT_COLUMNS: ContactColumnDef[] = [ { dbColumn: 'person_id_icm', pgType: 'text', - extract: (c) => c.profile.personIdIcm, + extract: (row) => row.profile.personIdIcm, conflictMode: 'skip', required: true, }, - { dbColumn: 'contact_id_icm', pgType: 'text', extract: (c) => c.profile.contactIdIcm }, - { dbColumn: 'person_id_mis', pgType: 'text', extract: (c) => c.profile.personIdMis }, - { dbColumn: 'first_name', pgType: 'text', extract: (c) => c.profile.firstName, required: true }, - { dbColumn: 'last_name', pgType: 'text', extract: (c) => c.profile.lastName, required: true }, - { dbColumn: 'middle_name', pgType: 'text', extract: (c) => c.profile.middleName }, - { dbColumn: 'aka_first_name', pgType: 'text', extract: (c) => c.profile.akaFirstName ?? '' }, - { dbColumn: 'aka_last_name', pgType: 'text', extract: (c) => c.profile.akaLastName ?? '' }, - { dbColumn: 'date_of_birth', pgType: 'date', extract: (c) => c.profile.dateOfBirth }, - { dbColumn: 'age', pgType: 'integer', extract: (c) => c.profile.age }, - { dbColumn: 'gender', pgType: 'text', extract: (c) => c.profile.gender }, - { dbColumn: 'case_number', pgType: 'text', extract: (c) => c.profile.caseNumber, required: true }, - { dbColumn: 'case_type', pgType: 'text', extract: (c) => c.profile.caseType, required: true }, - { dbColumn: 'case_status', pgType: 'text', extract: (c) => c.profile.caseStatus, required: true }, - { dbColumn: 'case_load', pgType: 'text', extract: (c) => c.profile.caseLoad, required: true }, - { dbColumn: 'legacy_file_number', pgType: 'text', extract: (c) => c.profile.legacyFileNumber }, - { dbColumn: 'service_office', pgType: 'text', extract: (c) => c.profile.serviceOffice }, - { dbColumn: 'assigned_to', pgType: 'text', extract: (c) => c.profile.assignedTo }, - { dbColumn: 'csa_status', pgType: 'text', extract: (c) => c.result.newStatus }, + { dbColumn: 'contact_id_icm', pgType: 'text', extract: (row) => row.profile.contactIdIcm }, + { dbColumn: 'person_id_mis', pgType: 'text', extract: (row) => row.profile.personIdMis }, + { + dbColumn: 'first_name', + pgType: 'text', + extract: (row) => row.profile.firstName, + required: true, + }, + { dbColumn: 'last_name', pgType: 'text', extract: (row) => row.profile.lastName, required: true }, + { dbColumn: 'middle_name', pgType: 'text', extract: (row) => row.profile.middleName }, + { dbColumn: 'aka_first_name', pgType: 'text', extract: (row) => row.profile.akaFirstName ?? '' }, + { dbColumn: 'aka_last_name', pgType: 'text', extract: (row) => row.profile.akaLastName ?? '' }, + { dbColumn: 'date_of_birth', pgType: 'date', extract: (row) => row.profile.dateOfBirth }, + { dbColumn: 'age', pgType: 'integer', extract: (row) => row.profile.age }, + { dbColumn: 'gender', pgType: 'text', extract: (row) => row.profile.gender }, + { + dbColumn: 'case_number', + pgType: 'text', + extract: (row) => row.profile.caseNumber, + required: true, + }, + { dbColumn: 'case_type', pgType: 'text', extract: (row) => row.profile.caseType, required: true }, + { + dbColumn: 'case_status', + pgType: 'text', + extract: (row) => row.profile.caseStatus, + required: true, + }, + { dbColumn: 'case_load', pgType: 'text', extract: (row) => row.profile.caseLoad, required: true }, + { + dbColumn: 'legacy_file_number', + pgType: 'text', + extract: (row) => row.profile.legacyFileNumber, + }, + { dbColumn: 'service_office', pgType: 'text', extract: (row) => row.profile.serviceOffice }, + { dbColumn: 'assigned_to', pgType: 'text', extract: (row) => row.profile.assignedTo }, + { dbColumn: 'csa_status', pgType: 'text', extract: (row) => row.result.newStatus }, { dbColumn: 'csa_status_effective_date', pgType: 'timestamptz', - extract: (c) => c.profile.csaStatusEffectiveDate ?? new Date(), + extract: (row) => row.profile.csaStatusEffectiveDate ?? new Date(), conflictMode: 'skip', }, - { dbColumn: 'din', pgType: 'text', extract: (c) => c.profile.din, conflictMode: 'skip' }, + { dbColumn: 'din', pgType: 'text', extract: (row) => row.profile.din, conflictMode: 'skip' }, { dbColumn: 'csa_sent_date', pgType: 'timestamptz', - extract: (c) => c.profile.csaSentDate, + extract: (row) => row.profile.csaSentDate, conflictMode: 'skip', }, - { dbColumn: 'enroll_for_csa', pgType: 'text', extract: (c) => c.profile.enrollForCsa }, + { dbColumn: 'enroll_for_csa', pgType: 'text', extract: (row) => row.profile.enrollForCsa }, { dbColumn: 'mis_legal_authority_code', pgType: 'text', - extract: (c) => c.profile.misLegalAuthCode, + extract: (row) => row.profile.misLegalAuthCode, }, { dbColumn: 'legal_authority_code', pgType: 'text', - extract: (c) => c.profile.legalAuthorityCode, + extract: (row) => row.profile.legalAuthorityCode, }, { dbColumn: 'effective_legal_status', pgType: 'text', - extract: (c) => c.profile.effectiveLegalStatus, + extract: (row) => row.profile.effectiveLegalStatus, }, - { dbColumn: 'effective_date', pgType: 'date', extract: (c) => c.profile.effectiveDate }, - { dbColumn: 'expiry_date', pgType: 'date', extract: (c) => c.profile.legalExpiryDate }, - { dbColumn: 'birth_city', pgType: 'text', extract: (c) => c.profile.birthCity }, - { dbColumn: 'birth_province', pgType: 'text', extract: (c) => c.profile.birthProvince }, - { dbColumn: 'birth_country', pgType: 'text', extract: (c) => c.profile.birthCountry }, + { dbColumn: 'effective_date', pgType: 'date', extract: (row) => row.profile.effectiveDate }, + { dbColumn: 'expiry_date', pgType: 'date', extract: (row) => row.profile.legalExpiryDate }, + { dbColumn: 'birth_city', pgType: 'text', extract: (row) => row.profile.birthCity }, + { dbColumn: 'birth_province', pgType: 'text', extract: (row) => row.profile.birthProvince }, + { dbColumn: 'birth_country', pgType: 'text', extract: (row) => row.profile.birthCountry }, { dbColumn: 'placement_location', pgType: 'text', - extract: (c) => c.primaryPlacement?.placementNumber ?? null, + extract: (row) => row.primaryPlacement?.placementNumber ?? null, + }, + { + dbColumn: 'location_type', + pgType: 'text', + extract: (row) => row.primaryPlacement?.type ?? null, }, - { dbColumn: 'location_type', pgType: 'text', extract: (c) => c.primaryPlacement?.type ?? null }, { dbColumn: 'location_sub_type', pgType: 'text', - extract: (c) => c.primaryPlacement?.serviceType ?? null, + extract: (row) => row.primaryPlacement?.serviceType ?? null, }, { dbColumn: 'placement_status', pgType: 'text', - extract: (c) => c.primaryPlacement?.status ?? null, + extract: (row) => row.primaryPlacement?.status ?? null, }, { dbColumn: 'actual_start_date', pgType: 'timestamptz', - extract: (c) => c.primaryPlacement?.startDate ?? null, + extract: (row) => row.primaryPlacement?.startDate ?? null, }, { dbColumn: 'actual_end_date', pgType: 'timestamptz', - extract: (c) => c.primaryPlacement?.endDate ?? null, + extract: (row) => row.primaryPlacement?.endDate ?? null, }, { dbColumn: 'paid_unpaid', pgType: 'text', - extract: (c) => c.primaryPlacement?.paidUnpaid ?? null, + extract: (row) => row.primaryPlacement?.paidUnpaid ?? null, }, { dbColumn: 'interrupted_placement', pgType: 'text', - extract: (c) => c.primaryPlacement?.interruptedPlacementId ?? null, + extract: (row) => row.primaryPlacement?.interruptedPlacementId ?? null, }, { dbColumn: 'source_placement', pgType: 'text', - extract: (c) => c.primaryPlacement?.source ?? null, + extract: (row) => row.primaryPlacement?.source ?? null, }, { dbColumn: 'service_provider_name', pgType: 'text', - extract: (c) => - c.primaryPlacement?.serviceProviderName ?? c.primaryAgreement?.serviceProviderName ?? null, + extract: (row) => + row.primaryPlacement?.serviceProviderName ?? + row.primaryAgreement?.serviceProviderName ?? + null, }, { dbColumn: 'provider_id', pgType: 'text', - extract: (c) => c.primaryPlacement?.providerId ?? c.primaryAgreement?.providerId ?? null, + extract: (row) => row.primaryPlacement?.providerId ?? row.primaryAgreement?.providerId ?? null, }, { dbColumn: 'place_of_service_name', pgType: 'text', - extract: (c) => c.primaryPlacement?.placeOfServiceName ?? null, + extract: (row) => row.primaryPlacement?.placeOfServiceName ?? null, }, { dbColumn: 'agreement_type', pgType: 'text', - extract: (c) => c.primaryAgreement?.agreementType ?? null, + extract: (row) => row.primaryAgreement?.agreementType ?? null, }, { dbColumn: 'agreement_status', pgType: 'text', - extract: (c) => c.primaryAgreement?.agreementStatus ?? null, + extract: (row) => row.primaryAgreement?.agreementStatus ?? null, }, { dbColumn: 'agreement_start_date', pgType: 'timestamptz', - extract: (c) => c.primaryAgreement?.agreementStartDate ?? null, + extract: (row) => row.primaryAgreement?.agreementStartDate ?? null, }, { dbColumn: 'agreement_end_date', pgType: 'timestamptz', - extract: (c) => c.primaryAgreement?.agreementEndDate ?? null, + extract: (row) => row.primaryAgreement?.agreementEndDate ?? null, }, { dbColumn: 'termination_date', pgType: 'timestamptz', - extract: (c) => c.primaryAgreement?.terminationDate ?? null, + extract: (row) => row.primaryAgreement?.terminationDate ?? null, }, { dbColumn: 'mcfd_contract', pgType: 'text', - extract: (c) => c.primaryAgreement?.mcfdContract ?? c.primaryPlacement?.contractNumber ?? null, + extract: (row) => + row.primaryAgreement?.mcfdContract ?? row.primaryPlacement?.contractNumber ?? null, + }, + { + dbColumn: 'order_number', + pgType: 'text', + extract: (row) => row.primaryOrder?.orderNumber ?? null, + }, + { dbColumn: 'order_type', pgType: 'text', extract: (row) => row.primaryOrder?.orderType ?? null }, + { + dbColumn: 'order_status', + pgType: 'text', + extract: (row) => row.primaryOrder?.orderStatus ?? null, }, - { dbColumn: 'order_number', pgType: 'text', extract: (c) => c.primaryOrder?.orderNumber ?? null }, - { dbColumn: 'order_type', pgType: 'text', extract: (c) => c.primaryOrder?.orderType ?? null }, - { dbColumn: 'order_status', pgType: 'text', extract: (c) => c.primaryOrder?.orderStatus ?? null }, { dbColumn: 'order_amount', pgType: 'text', - extract: (c) => (c.primaryOrder?.amount != null ? String(c.primaryOrder.amount) : null), + extract: (row) => (row.primaryOrder?.amount != null ? String(row.primaryOrder.amount) : null), }, { dbColumn: 'order_effective_start_date', pgType: 'date', - extract: (c) => c.primaryOrder?.effectiveStartDate ?? null, + extract: (row) => row.primaryOrder?.effectiveStartDate ?? null, }, { dbColumn: 'order_effective_end_date', pgType: 'date', - extract: (c) => c.primaryOrder?.effectiveEndDate ?? null, + extract: (row) => row.primaryOrder?.effectiveEndDate ?? null, }, - { dbColumn: 'product', pgType: 'text', extract: (c) => c.primaryOrder?.product ?? null }, - { dbColumn: 'source_order', pgType: 'text', extract: (c) => c.primaryOrder?.source ?? 'ICM' }, - { dbColumn: 'cancel_reason_code', pgType: 'text', extract: (c) => c.result.cancelReasonCode }, - { dbColumn: 'care_end_date', pgType: 'date', extract: (c) => c.result.careEndDate }, + { dbColumn: 'product', pgType: 'text', extract: (row) => row.primaryOrder?.product ?? null }, + { dbColumn: 'source_order', pgType: 'text', extract: (row) => row.primaryOrder?.source ?? 'ICM' }, + { dbColumn: 'cancel_reason_code', pgType: 'text', extract: (row) => row.result.cancelReasonCode }, + { dbColumn: 'care_end_date', pgType: 'date', extract: (row) => row.result.careEndDate }, { dbColumn: 'is_ineligible', pgType: 'boolean', - extract: (c) => INELIGIBLE_CANCEL_CODES.has(c.result.cancelReasonCode ?? ''), + extract: (row) => INELIGIBLE_CANCEL_CODES.has(row.result.cancelReasonCode ?? ''), }, - { dbColumn: 'is_deceased', pgType: 'text', extract: (c) => c.profile.deceased }, + { dbColumn: 'is_deceased', pgType: 'text', extract: (row) => row.profile.deceased }, ] // Pre-computed list of required columns for validation -const REQUIRED_COLUMNS = CONTACT_COLUMNS.filter((c) => c.required) +const REQUIRED_COLUMNS = CONTACT_COLUMNS.filter((col) => col.required) function getInvalidRequiredFields(row: UpsertContext): string[] { const invalidFields: string[] = [] @@ -264,14 +364,14 @@ function getInvalidRequiredFields(row: UpsertContext): string[] { } // Pre-build SQL from column definitions (computed once at module load) -const COL_LIST = CONTACT_COLUMNS.map((c) => c.dbColumn).join(', ') -const SELECT_LIST = CONTACT_COLUMNS.map((c) => `t.${c.dbColumn}`).join(', ') -const UNNEST_PARAMS = CONTACT_COLUMNS.map((c, i) => `$${i + 1}::${c.pgType}[]`).join(', ') -const UPDATE_SET = CONTACT_COLUMNS.filter((c) => c.conflictMode !== 'skip') - .map((c) => - c.conflictMode === 'coalesce' - ? `${c.dbColumn} = COALESCE(EXCLUDED.${c.dbColumn}, contacts.${c.dbColumn})` - : `${c.dbColumn} = EXCLUDED.${c.dbColumn}`, +const COL_LIST = CONTACT_COLUMNS.map((col) => col.dbColumn).join(', ') +const SELECT_LIST = CONTACT_COLUMNS.map((col) => `t.${col.dbColumn}`).join(', ') +const UNNEST_PARAMS = CONTACT_COLUMNS.map((col, i) => `$${i + 1}::${col.pgType}[]`).join(', ') +const UPDATE_SET = CONTACT_COLUMNS.filter((col) => col.conflictMode !== 'skip') + .map((col) => + col.conflictMode === 'coalesce' + ? `${col.dbColumn} = COALESCE(EXCLUDED.${col.dbColumn}, contacts.${col.dbColumn})` + : `${col.dbColumn} = EXCLUDED.${col.dbColumn}`, ) .join(',\n ') @@ -323,7 +423,7 @@ export class EligibilityService { const rows = await this.prisma.$queryRawUnsafe<{ table_name: string; has_data: boolean }[]>(sql) - const emptyTables = rows.filter((r) => !r.has_data).map((r) => r.table_name) + const emptyTables = rows.filter((row) => !row.has_data).map((row) => row.table_name) if (emptyTables.length > 0) { throw new Error(`Staging validation failed: empty tables [${emptyTables.join(', ')}]`) @@ -448,7 +548,7 @@ export class EligibilityService { const cutoff = getAgeCutoffDate(referenceDate) const { sql, params } = buildFindAgedOutContactIdsSql(cutoff) const rows = await this.prisma.$queryRawUnsafe<{ person_id_icm: string }[]>(sql, ...params) - return rows.map((r) => r.person_id_icm) + return rows.map((row) => row.person_id_icm) } private async loadContactProfiles( @@ -465,8 +565,8 @@ export class EligibilityService { type: placement.type, rawType: null, status: placement.status, - startDate: placement.startDate ? new Date(placement.startDate) : null, - endDate: placement.endDate ? new Date(placement.endDate) : null, + startDate: placement.startDate ? parseISODatePacific(placement.startDate) : null, + endDate: placement.endDate ? parseISODatePacific(placement.endDate) : null, contractNumber: placement.contractNumber, agreementRowId: placement.agreementRowId, paidUnpaid: placement.paidUnpaid, @@ -486,8 +586,8 @@ export class EligibilityService { type: placement.type?.startsWith('PL ') ? 'Placement' : 'Non-Placement Location', rawType: placement.type ?? null, status: placement.status, - startDate: placement.startDate ? new Date(placement.startDate) : null, - endDate: placement.endDate ? new Date(placement.endDate) : null, + startDate: placement.startDate ? parseISODatePacific(placement.startDate) : null, + endDate: placement.endDate ? parseISODatePacific(placement.endDate) : null, contractNumber: placement.contractNumber, agreementRowId: null, paidUnpaid: null, @@ -541,12 +641,14 @@ export class EligibilityService { agreementType: agreement.agreementType ?? null, agreementStatus: agreement.agreementStatus ?? null, agreementStartDate: agreement.agreementStartDate - ? new Date(agreement.agreementStartDate) + ? parseISODatePacific(agreement.agreementStartDate) : null, agreementEndDate: agreement.agreementEndDate - ? new Date(agreement.agreementEndDate) + ? parseISODatePacific(agreement.agreementEndDate) + : null, + terminationDate: agreement.terminationDate + ? parseISODatePacific(agreement.terminationDate) : null, - terminationDate: agreement.terminationDate ? new Date(agreement.terminationDate) : null, mcfdContract: agreement.mcfdContract ?? null, source: 'ICM', }), @@ -559,9 +661,11 @@ export class EligibilityService { contractNumber: contract.contractNumber ?? null, agreementType: contract.type ?? null, agreementStatus: contract.status ?? null, - agreementStartDate: contract.startDate ? new Date(contract.startDate) : null, - agreementEndDate: contract.endDate ? new Date(contract.endDate) : null, - terminationDate: contract.terminationDate ? new Date(contract.terminationDate) : null, + agreementStartDate: contract.startDate ? parseISODatePacific(contract.startDate) : null, + agreementEndDate: contract.endDate ? parseISODatePacific(contract.endDate) : null, + terminationDate: contract.terminationDate + ? parseISODatePacific(contract.terminationDate) + : null, mcfdContract: contract.contractNumber ?? null, serviceProviderName: contract.serviceProviderName ?? null, providerId: contract.providerId ?? null, @@ -616,55 +720,6 @@ export class EligibilityService { }) } - // Select one representative placement, order, and agreement to add - // into the master contacts table. - private selectPrimaryRecords(profile: ContactProfile): { - primaryPlacement: PlacementRecord | null - primaryOrder: OrderRecord | null - primaryAgreement: AgreementRecord | null - } { - // Primary Placement: first active Placement-type record, preferring ICM source - const activePlacements = profile.placements.filter( - (placement) => - normalize(placement.type) === 'PLACEMENT' && - ['ACTIVE', 'INTERRUPTED'].includes(normalize(placement.status)), - ) - const icmPlacements = activePlacements.filter((placement) => placement.source === 'ICM') - const primaryPlacement = icmPlacements[0] ?? activePlacements[0] ?? null - - // Primary Order: match via primary placement's link key - let primaryOrder: OrderRecord | null = null - if (primaryPlacement?.source === 'ICM' && primaryPlacement.agreementRowId) { - primaryOrder = - profile.orders.find((order) => order.agreementRowId === primaryPlacement.agreementRowId) ?? - null - } else if (primaryPlacement?.source === 'MIS' && primaryPlacement.contractNumber) { - primaryOrder = - profile.orders.find( - (order) => - order.source === 'MIS' && order.contractNumber === primaryPlacement.contractNumber, - ) ?? null - } - - // Primary Agreement: match via primary placement's link key - let primaryAgreement: AgreementRecord | null = null - if (primaryPlacement?.source === 'ICM' && primaryPlacement.agreementRowId) { - primaryAgreement = - profile.agreements.find( - (agreement) => agreement.rowId === primaryPlacement.agreementRowId, - ) ?? null - } else if (primaryPlacement?.source === 'MIS' && primaryPlacement.contractNumber) { - primaryAgreement = - profile.agreements.find( - (agreement) => - agreement.source === 'MIS' && - agreement.contractNumber === primaryPlacement.contractNumber, - ) ?? null - } - - return { primaryPlacement, primaryOrder, primaryAgreement } - } - private async upsertContacts( updates: Array<{ profile: ContactProfile; result: EligibilityResult }>, ): Promise<{ skipped: number; validRows: UpsertContext[] }> { @@ -676,7 +731,7 @@ export class EligibilityService { const row: UpsertContext = { profile, result, - ...this.selectPrimaryRecords(profile), + ...selectPrimaryRecords(profile), } const invalidFields = getInvalidRequiredFields(row) if (invalidFields.length > 0) { @@ -702,12 +757,12 @@ export class EligibilityService { validRows: UpsertContext[], ): Promise<{ application: number; cancellation: number }> { const applicationPersonIds = validRows - .filter((r) => r.result.newStatus === CSA_STATUS.ELIGIBLE) - .map((r) => r.profile.personIdIcm) + .filter((row) => row.result.newStatus === CSA_STATUS.ELIGIBLE) + .map((row) => row.profile.personIdIcm) const cancellationPersonIds = validRows - .filter((r) => r.result.newStatus === CSA_STATUS.NOT_ELIGIBLE_IN_PAY) - .map((r) => r.profile.personIdIcm) + .filter((row) => row.result.newStatus === CSA_STATUS.NOT_ELIGIBLE_IN_PAY) + .map((row) => row.profile.personIdIcm) this.logger.log( `Auto-batch candidates: ${applicationPersonIds.length} application, ${cancellationPersonIds.length} cancellation (from ${validRows.length} validRows)`, @@ -723,7 +778,7 @@ export class EligibilityService { `SELECT id, person_id_icm FROM contacts WHERE person_id_icm = ANY($1)`, allPersonIds, ) - const idMap = new Map(contactRows.map((c) => [c.person_id_icm, c.id])) + const idMap = new Map(contactRows.map((row) => [row.person_id_icm, row.id])) const [existingBatch] = await this.prisma.$queryRawUnsafe<{ id: number }[]>( `SELECT id FROM batches WHERE status = $1 LIMIT 1`, @@ -750,7 +805,7 @@ export class EligibilityService { batchId, allDbIds, ) - const alreadyInBatchIds = new Set(alreadyInBatch.map((r) => r.contact_id)) + const alreadyInBatchIds = new Set(alreadyInBatch.map((row) => row.contact_id)) const contactIds: number[] = [] const batchIds: number[] = [] diff --git a/backend/src/sync/eligibility/rules/rule-runner.spec.ts b/backend/src/sync/eligibility/rules/rule-runner.spec.ts index 5edfa1cc..06049d3e 100644 --- a/backend/src/sync/eligibility/rules/rule-runner.spec.ts +++ b/backend/src/sync/eligibility/rules/rule-runner.spec.ts @@ -1,46 +1,11 @@ import { describe, it, expect } from 'vitest' import { runEligibility } from './rule-runner' import { EligibilityRule, EligibilityContext } from './rule.interface' -import { ContactProfile, EligibilityResult } from '../eligibility.types' - -const makeContact = (overrides: Partial = {}): ContactProfile => ({ - personIdIcm: 'ICM-1', - personIdMis: 'MIS-1', - firstName: 'John', - lastName: 'Doe', - middleName: '', - dateOfBirth: new Date('2010-01-15'), - age: 16, - gender: 'M', - caseNumber: 'CS-001', - caseType: 'Child Services', - caseStatus: 'Open', - caseLoad: 'CL-1', - legacyFileNumber: null, - serviceOffice: null, - assignedTo: null, - csaStatus: null, - existingContactId: null, - din: null, - csaSentDate: null, - misLegalAuthCode: null, - enrollForCsa: null, - legalExpiryDate: null, - effectiveLegalStatus: null, - legalAuthorityCode: null, - effectiveDate: null, - birthCity: null, - birthProvince: null, - birthCountry: null, - akaFirstName: null, - akaLastName: null, - isIneligible: false, - deceased: null, - placements: [], - orders: [], - agreements: [], - ...overrides, -}) +import { EligibilityResult } from '../eligibility.types' +import { makeContact, makePlacement, makeOrder } from '../test-helpers' +import { step3_PlacementCheck } from './steps/step3-placement-check' +import { step4_FetchAgreementContract } from './steps/step4-fetch-agreement-contract' +import { step6_OrderPaymentCheck } from './steps/step6-order-payment-check' const REF_DATE = new Date('2026-02-10') @@ -98,3 +63,96 @@ describe('runEligibility', () => { runEligibility(makeContact(), [enricher, reader], REF_DATE) }) }) + +describe('runEligibility integration: step3 → step4 → step6', () => { + const RULES = [step3_PlacementCheck, step4_FetchAgreementContract, step6_OrderPaymentCheck] + + it('MIS-to-ICM migration: ended MIS placement + active ICM placement → eligible (step 7)', () => { + // Reference date: April 15. March payment should match previous month. + const refDate = new Date('2026-04-15') + + const contact = makeContact({ + csaStatus: 'in_pay', + placements: [ + makePlacement({ + source: 'ICM', + status: 'Active', + type: 'Placement', + startDate: new Date('2026-04-01'), + agreementRowId: 'A-ICM-NEW', + }), + makePlacement({ + source: 'MIS', + status: 'Ended', + type: 'Placement', + startDate: new Date('2025-01-01'), + endDate: new Date('2026-03-31'), + contractNumber: 'C-MIS-OLD', + }), + ], + orders: [ + makeOrder({ + source: 'MIS', + contractNumber: 'C-MIS-OLD', + effectiveStartDate: new Date('2026-03-01'), + amount: 2000, + }), + ], + }) + + const result = runEligibility(contact, RULES, refDate) + + expect(result).toEqual({ + step: 7, + newStatus: 'in_pay', + cancelReasonCode: null, + careEndDate: null, + }) + }) + + it('no active placement → step 8 (eligible_tbd), skips step 4 and 6', () => { + const contact = makeContact({ + csaStatus: null, + placements: [makePlacement({ status: 'Ended', type: 'Placement' })], + }) + + const result = runEligibility(contact, RULES, new Date('2026-04-15')) + + expect(result).toEqual({ + step: 8, + newStatus: 'eligible_tbd', + cancelReasonCode: null, + careEndDate: null, + }) + }) + + it('active placement with matching previous-month order → eligible (step 7)', () => { + const refDate = new Date('2026-04-15') + + const contact = makeContact({ + csaStatus: null, + placements: [ + makePlacement({ + status: 'Active', + contractNumber: 'C-100', + }), + ], + orders: [ + makeOrder({ + contractNumber: 'C-100', + effectiveStartDate: new Date('2026-03-15'), + amount: 2000, + }), + ], + }) + + const result = runEligibility(contact, RULES, refDate) + + expect(result).toEqual({ + step: 7, + newStatus: 'eligible', + cancelReasonCode: null, + careEndDate: null, + }) + }) +}) diff --git a/backend/src/sync/eligibility/rules/steps/step1a-age-check.spec.ts b/backend/src/sync/eligibility/rules/steps/step1a-age-check.spec.ts index 4ebca006..c8b1f320 100644 --- a/backend/src/sync/eligibility/rules/steps/step1a-age-check.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/step1a-age-check.spec.ts @@ -1,52 +1,10 @@ import { CSA_STATUS } from 'src/common/state-machine/constants/csa-status.constants' import { describe, expect, it } from 'vitest' import { ContactProfile } from '../../eligibility.types' +import { makeContact } from '../../test-helpers' import { EligibilityContext } from '../rule.interface' import { step1A_AgeCheck } from './step1a-age-check' -const makeContact = (overrides: Partial = {}): ContactProfile => ({ - caseRowId: 'CASE-1', - personIdIcm: 'ICM-1', - personIdMis: 'MIS-1', - firstName: 'John', - lastName: 'Doe', - middleName: '', - akaFirstName: null, - akaLastName: null, - dateOfBirth: new Date('2010-01-15'), - age: 16, - gender: 'M', - caseNumber: 'CS-001', - caseType: 'Child Services', - caseStatus: 'Open', - caseLoad: 'CL-1', - legacyFileNumber: null, - serviceOffice: null, - assignedTo: null, - csaStatus: null, - csaStatusEffectiveDate: null, - existingContactId: null, - din: null, - csaSentDate: null, - misLegalAuthCode: null, - enrollForCsa: null, - legalExpiryDate: null, - effectiveLegalStatus: null, - legalAuthorityCode: null, - effectiveDate: null, - birthCity: null, - birthProvince: null, - birthCountry: null, - isIneligible: false, - deceased: null, - cancelReasonCode: null, - careEndDate: null, - placements: [], - orders: [], - agreements: [], - ...overrides, -}) - // Reference date for all tests const REF_DATE = new Date('2026-02-10') diff --git a/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.spec.ts b/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.spec.ts index 633a3f8b..f1c47278 100644 --- a/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.spec.ts @@ -2,54 +2,12 @@ import { describe, it, expect } from 'vitest' import { step1B_CancellationCheck } from './step1b-cancellation-determination' import { EligibilityContext } from '../rule.interface' import { ContactProfile } from '../../eligibility.types' +import { makeContact } from '../../test-helpers' import { CSA_STATUS } from 'src/common/state-machine/constants/csa-status.constants' import { CANCEL_REASON } from '../../cancellation/cancellation-reason.constants' -const makeContact = (overrides: Partial = {}): ContactProfile => ({ - caseRowId: 'CASE-1', - personIdIcm: 'ICM-1', - personIdMis: 'MIS-1', - firstName: 'John', - lastName: 'Doe', - middleName: '', - akaFirstName: null, - akaLastName: null, - dateOfBirth: new Date('2010-01-15'), - age: 16, - gender: 'M', - caseNumber: 'CS-001', - caseType: 'Child Services', - caseStatus: 'Open', - caseLoad: 'CL-1', - legacyFileNumber: null, - serviceOffice: null, - assignedTo: null, - csaStatus: CSA_STATUS.IN_PAY, - csaStatusEffectiveDate: null, - existingContactId: 1, - din: null, - csaSentDate: null, - misLegalAuthCode: null, - enrollForCsa: null, - legalExpiryDate: null, - effectiveLegalStatus: null, - legalAuthorityCode: null, - effectiveDate: null, - birthCity: null, - birthProvince: null, - birthCountry: null, - isIneligible: false, - deceased: null, - cancelReasonCode: null, - careEndDate: null, - placements: [], - orders: [], - agreements: [], - ...overrides, -}) - const makeCtx = (overrides: Partial = {}): EligibilityContext => ({ - contact: makeContact(overrides), + contact: makeContact({ csaStatus: CSA_STATUS.IN_PAY, existingContactId: 1, ...overrides }), referenceDate: new Date('2026-02-10'), }) diff --git a/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.ts b/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.ts index ce513101..08b46d6a 100644 --- a/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.ts +++ b/backend/src/sync/eligibility/rules/steps/step1b-cancellation-determination.ts @@ -27,11 +27,15 @@ export const step1B_CancellationCheck: EligibilityRule = { const result = determineCancellationReason({ deceased: contact.deceased, icmPlacements: contact.placements - .filter((p) => p.source === 'ICM') - .map((p) => ({ type: p.type, serviceType: p.serviceType ?? null, status: p.status })), + .filter((placement) => placement.source === 'ICM') + .map((placement) => ({ + type: placement.type, + serviceType: placement.serviceType ?? null, + status: placement.status, + })), misPlacements: contact.placements - .filter((p) => p.source === 'MIS') - .map((p) => ({ type: p.rawType, status: p.status })), + .filter((placement) => placement.source === 'MIS') + .map((placement) => ({ type: placement.rawType, status: placement.status })), }) if (result.isIneligible) { diff --git a/backend/src/sync/eligibility/rules/steps/step2-legal-status-check.spec.ts b/backend/src/sync/eligibility/rules/steps/step2-legal-status-check.spec.ts index fee26ffa..bc98acdb 100644 --- a/backend/src/sync/eligibility/rules/steps/step2-legal-status-check.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/step2-legal-status-check.spec.ts @@ -1,50 +1,11 @@ import { describe, expect, it } from 'vitest' import { ContactProfile } from '../../eligibility.types' +import { makeContact as makeBaseContact } from '../../test-helpers' import { EligibilityContext } from '../rule.interface' import { step2_LegalStatusCheck } from './step2-legal-status-check' -const makeContact = (overrides: Partial = {}): ContactProfile => ({ - caseRowId: 'CASE-1', - personIdIcm: 'ICM-1', - personIdMis: 'MIS-1', - firstName: 'John', - lastName: 'Doe', - middleName: '', - dateOfBirth: new Date('2010-01-15'), - age: 16, - gender: 'M', - caseNumber: 'CS-001', - caseType: 'Child Services', - caseStatus: 'Open', - caseLoad: 'CL-1', - legacyFileNumber: null, - serviceOffice: null, - assignedTo: null, - csaStatus: null, - csaStatusEffectiveDate: null, - existingContactId: null, - din: null, - csaSentDate: null, - misLegalAuthCode: null, - enrollForCsa: 'Yes', - legalExpiryDate: null, - effectiveLegalStatus: 'Active', - legalAuthorityCode: null, - effectiveDate: null, - birthCity: null, - birthProvince: null, - birthCountry: null, - akaFirstName: null, - akaLastName: null, - isIneligible: false, - deceased: null, - cancelReasonCode: null, - careEndDate: null, - placements: [], - orders: [], - agreements: [], - ...overrides, -}) +const makeContact = (overrides: Partial = {}) => + makeBaseContact({ enrollForCsa: 'Yes', effectiveLegalStatus: 'Active', ...overrides }) const REF_DATE = new Date('2026-02-10') diff --git a/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts b/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts index d517f22d..14e19d52 100644 --- a/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/step3-placement-check.spec.ts @@ -1,59 +1,9 @@ import { describe, expect, it } from 'vitest' -import { ContactProfile, PlacementRecord } from '../../eligibility.types' +import { ContactProfile } from '../../eligibility.types' +import { makeContact, makePlacement } from '../../test-helpers' import { EligibilityContext } from '../rule.interface' import { step3_PlacementCheck } from './step3-placement-check' -const makeContact = (overrides: Partial = {}): ContactProfile => ({ - personIdIcm: 'ICM-1', - personIdMis: 'MIS-1', - firstName: 'John', - lastName: 'Doe', - middleName: '', - dateOfBirth: new Date('2010-01-15'), - age: 16, - gender: 'M', - caseNumber: 'CS-001', - caseType: 'Child Services', - caseStatus: 'Open', - caseLoad: 'CL-1', - legacyFileNumber: null, - serviceOffice: null, - assignedTo: null, - csaStatus: null, - existingContactId: null, - din: null, - csaSentDate: null, - misLegalAuthCode: null, - enrollForCsa: 'Yes', - legalExpiryDate: null, - effectiveLegalStatus: null, - legalAuthorityCode: null, - effectiveDate: null, - birthCity: null, - birthProvince: null, - birthCountry: null, - akaFirstName: null, - akaLastName: null, - isIneligible: false, - deceased: null, - placements: [], - orders: [], - agreements: [], - ...overrides, -}) - -const makePlacement = (overrides: Partial = {}): PlacementRecord => ({ - type: 'Placement', - status: 'Active', - startDate: new Date('2025-01-01'), - endDate: null, - contractNumber: 'C-100', - agreementRowId: 'A-1', - paidUnpaid: 'Paid', - source: 'ICM', - ...overrides, -}) - const makeCtx = (overrides: Partial = {}): EligibilityContext => ({ contact: makeContact(overrides), referenceDate: new Date('2026-02-10'), diff --git a/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts b/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts index 867a3309..b0ebc370 100644 --- a/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts +++ b/backend/src/sync/eligibility/rules/steps/step3-placement-check.ts @@ -20,11 +20,15 @@ export const step3_PlacementCheck: EligibilityRule = { evaluate(ctx: EligibilityContext): EligibilityResult | null { const { placements } = ctx.contact - const activePlacements = placements.filter((p) => ACTIVE_STATUSES.includes(normalize(p.status))) + const activePlacements = placements.filter((placement) => + ACTIVE_STATUSES.includes(normalize(placement.status)), + ) - const placementRecords = activePlacements.filter((p) => normalize(p.type) === 'PLACEMENT') + const placementRecords = activePlacements.filter( + (placement) => normalize(placement.type) === 'PLACEMENT', + ) const nonPlacementRecords = activePlacements.filter( - (p) => normalize(p.type) === 'NON-PLACEMENT LOCATION', + (placement) => normalize(placement.type) === 'NON-PLACEMENT LOCATION', ) const hasPlacement = placementRecords.length > 0 diff --git a/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.spec.ts b/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.spec.ts index 79fc163e..d8e726bf 100644 --- a/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.spec.ts @@ -1,125 +1,43 @@ import { describe, expect, it } from 'vitest' -import { ContactProfile } from '../../eligibility.types' +import { makeContact, makePlacement } from '../../test-helpers' import { EligibilityContext } from '../rule.interface' import { step4_FetchAgreementContract } from './step4-fetch-agreement-contract' -const makeContact = (overrides: Partial = {}): ContactProfile => ({ - caseRowId: 'CASE-1', - personIdIcm: 'ICM-1', - personIdMis: 'MIS-1', - firstName: 'John', - lastName: 'Doe', - middleName: '', - dateOfBirth: new Date('2010-01-15'), - age: 16, - gender: 'M', - caseNumber: 'CS-001', - caseType: 'Child Services', - caseStatus: 'Open', - caseLoad: 'CL-1', - legacyFileNumber: null, - serviceOffice: null, - assignedTo: null, - csaStatus: null, - csaStatusEffectiveDate: null, - existingContactId: null, - din: null, - csaSentDate: null, - misLegalAuthCode: null, - enrollForCsa: 'Yes', - legalExpiryDate: null, - effectiveLegalStatus: null, - legalAuthorityCode: null, - effectiveDate: null, - birthCity: null, - birthProvince: null, - birthCountry: null, - akaFirstName: null, - akaLastName: null, - isIneligible: false, - deceased: null, - cancelReasonCode: null, - careEndDate: null, - placements: [], - orders: [], - agreements: [], - ...overrides, -}) - describe('step4_FetchAgreementContract', () => { - it('should extract contract numbers from eligible placements and continue chain', () => { + it('should extract contract numbers and agreement row IDs from eligible placements', () => { const ctx: EligibilityContext = { contact: makeContact(), referenceDate: new Date('2026-02-10'), eligiblePlacements: [ - { - type: 'Placement', - status: 'Active', - startDate: null, - endDate: null, - contractNumber: 'C-100', - agreementRowId: 'A-1', - rawType: null, - paidUnpaid: null, - source: 'ICM', - }, - { - type: 'Placement', - rawType: null, - status: 'Interrupted', - startDate: null, - endDate: null, - contractNumber: 'C-200', - agreementRowId: 'A-2', - paidUnpaid: null, - source: 'ICM', - }, + makePlacement({ contractNumber: 'C-100', agreementRowId: 'A-1', status: 'Active' }), + makePlacement({ contractNumber: 'C-200', agreementRowId: 'A-2', status: 'Interrupted' }), ], } const result = step4_FetchAgreementContract.evaluate(ctx) - expect(result).toBeNull() // always continues to Step 6 + expect(result).toBeNull() expect(ctx.contractNumbers).toEqual(['C-100', 'C-200']) expect(ctx.agreementRowIds).toEqual(['A-1', 'A-2']) }) - it('should filter out null contract numbers', () => { + it('should filter out null contract numbers and agreement row IDs', () => { const ctx: EligibilityContext = { contact: makeContact(), referenceDate: new Date('2026-02-10'), eligiblePlacements: [ - { - type: 'Placement', - rawType: null, - status: 'Active', - startDate: null, - endDate: null, - contractNumber: null, - agreementRowId: null, - paidUnpaid: null, - source: 'ICM', - }, - { - type: 'Placement', - rawType: null, - status: 'Active', - startDate: null, - endDate: null, - contractNumber: 'C-300', - agreementRowId: null, - paidUnpaid: null, - source: 'ICM', - }, + makePlacement({ contractNumber: null, agreementRowId: null }), + makePlacement({ contractNumber: 'C-300', agreementRowId: null }), ], } const result = step4_FetchAgreementContract.evaluate(ctx) expect(result).toBeNull() expect(ctx.contractNumbers).toEqual(['C-300']) + expect(ctx.agreementRowIds).toEqual([]) }) - it('should set empty array when no eligible placements in context', () => { + it('should set empty arrays when no eligible placements in context', () => { const ctx: EligibilityContext = { contact: makeContact(), referenceDate: new Date('2026-02-10'), @@ -127,6 +45,7 @@ describe('step4_FetchAgreementContract', () => { step4_FetchAgreementContract.evaluate(ctx) expect(ctx.contractNumbers).toEqual([]) + expect(ctx.agreementRowIds).toEqual([]) }) it('should deduplicate contract numbers', () => { @@ -134,28 +53,8 @@ describe('step4_FetchAgreementContract', () => { contact: makeContact(), referenceDate: new Date('2026-02-10'), eligiblePlacements: [ - { - type: 'Placement', - rawType: null, - status: 'Active', - startDate: null, - endDate: null, - contractNumber: 'C-100', - agreementRowId: null, - paidUnpaid: null, - source: 'ICM', - }, - { - type: 'Placement', - rawType: null, - status: 'Interrupted', - startDate: null, - endDate: null, - contractNumber: 'C-100', - agreementRowId: null, - paidUnpaid: null, - source: 'MIS', - }, + makePlacement({ contractNumber: 'C-100', source: 'ICM', status: 'Active' }), + makePlacement({ contractNumber: 'C-100', source: 'MIS', status: 'Interrupted' }), ], } diff --git a/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts b/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts index 4f900c73..e966cebf 100644 --- a/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts +++ b/backend/src/sync/eligibility/rules/steps/step4-fetch-agreement-contract.ts @@ -2,8 +2,9 @@ import { EligibilityResult } from '../../eligibility.types' import { EligibilityContext, EligibilityRule } from '../rule.interface' /** - * STEP 4: Fetch Agreement/Contract# from Active and/or Interrupted Placement - * Extracts contract numbers and enriches context for Step 6. + * STEP 4: Extract link keys (contractNumbers, agreementRowIds) from eligible placements. + * Used by Step 6 to match ICM orders. MIS orders are matched directly by source in Step 6 + * since they are already scoped to the contact via person_id_mis at the SQL level. * Always continues to the next rule (Step 6). */ export const step4_FetchAgreementContract: EligibilityRule = { @@ -15,16 +16,16 @@ export const step4_FetchAgreementContract: EligibilityRule = { const contractNumbers = [ ...new Set( placements - .map((p) => p.contractNumber) - .filter((c): c is string => c !== null && c !== undefined), + .map((placement) => placement.contractNumber) + .filter((val): val is string => val !== null && val !== undefined), ), ] const agreementRowIds = [ ...new Set( placements - .map((p) => p.agreementRowId) - .filter((a): a is string => a !== null && a !== undefined), + .map((placement) => placement.agreementRowId) + .filter((val): val is string => val !== null && val !== undefined), ), ] diff --git a/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.spec.ts b/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.spec.ts index df55374e..18549631 100644 --- a/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.spec.ts +++ b/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.spec.ts @@ -1,61 +1,17 @@ import { describe, expect, it } from 'vitest' import { ContactProfile, OrderRecord } from '../../eligibility.types' +import { makeContact, makeOrder as makeBaseOrder } from '../../test-helpers' import { EligibilityContext } from '../rule.interface' import { step6_OrderPaymentCheck } from './step6-order-payment-check' -const makeContact = (overrides: Partial = {}): ContactProfile => ({ - caseRowId: 'CASE-1', - personIdIcm: 'ICM-1', - personIdMis: 'MIS-1', - firstName: 'John', - lastName: 'Doe', - middleName: '', - dateOfBirth: new Date('2010-01-15'), - age: 16, - gender: 'M', - caseNumber: 'CS-001', - caseType: 'Child Services', - caseStatus: 'Open', - caseLoad: 'CL-1', - legacyFileNumber: null, - serviceOffice: null, - assignedTo: null, - csaStatus: null, - csaStatusEffectiveDate: null, - existingContactId: null, - din: null, - csaSentDate: null, - misLegalAuthCode: null, - enrollForCsa: 'Yes', - legalExpiryDate: null, - effectiveLegalStatus: null, - legalAuthorityCode: null, - effectiveDate: null, - birthCity: null, - birthProvince: null, - birthCountry: null, - akaFirstName: null, - akaLastName: null, - isIneligible: false, - deceased: null, - cancelReasonCode: null, - careEndDate: null, - placements: [], - orders: [], - agreements: [], - ...overrides, -}) - -const makeOrder = (overrides: Partial = {}): OrderRecord => ({ - orderType: 'Monthly Family Care Rate', - orderStatus: 'Closed', - effectiveStartDate: new Date('2026-01-15'), // previous month - effectiveEndDate: null, - amount: 1600.0, - contractNumber: 'C-100', - source: 'ICM', - ...overrides, -}) +const makeOrder = (overrides: Partial = {}) => + makeBaseOrder({ + effectiveStartDate: new Date('2026-01-15'), + amount: 1600.0, + contractNumber: 'C-100', + source: 'ICM', + ...overrides, + }) const REF_DATE = new Date('2026-02-10') @@ -213,4 +169,40 @@ describe('step6_OrderPaymentCheck', () => { const result = step6_OrderPaymentCheck.evaluate(ctx) expect(result!.step).toBe(8) }) + + it('should match MIS orders by source without needing contract number in context', () => { + const ctx = makeCtx( + { + orders: [makeOrder({ source: 'MIS', contractNumber: 'C-MIS-OLD' })], + }, + { contractNumbers: [], agreementRowIds: [] }, + ) + const result = step6_OrderPaymentCheck.evaluate(ctx) + expect(result!.step).toBe(7) + }) + + it('MIS-to-ICM migration: MIS payment matched by source despite ended MIS placement', () => { + const refDate = new Date('2026-04-15') + const ctx: EligibilityContext = { + contact: makeContact({ + csaStatus: 'in_pay', + orders: [ + makeOrder({ + source: 'MIS', + contractNumber: 'C-MIS-OLD', + effectiveStartDate: new Date('2026-03-01'), + amount: 2000, + }), + ], + }), + referenceDate: refDate, + hasPlacement: true, + hasNonPlacement: false, + contractNumbers: [], + agreementRowIds: ['A-ICM-NEW'], + } + const result = step6_OrderPaymentCheck.evaluate(ctx) + expect(result!.step).toBe(7) + expect(result!.newStatus).toBe('in_pay') + }) }) diff --git a/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.ts b/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.ts index 8f17fd55..4a13da4f 100644 --- a/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.ts +++ b/backend/src/sync/eligibility/rules/steps/step6-order-payment-check.ts @@ -9,7 +9,11 @@ import { step9_UpdateNotEligible } from './step9-update-not-eligible' /** * STEP 6: Order (ICM) / Payment (MIS) check * - * From orders linked to valid placements via contractNumber (MIS) or agreementRowId (ICM): + * Order matching: + * - MIS orders: all included (already scoped to the contact via person_id_mis at SQL level) + * - ICM orders: linked via contractNumber or agreementRowId from Step 4 + * + * Then: * 1. Filter to previous month orders only * 2. Check ALL orders against the 4 criteria (type, status, date, amount) * @@ -28,9 +32,10 @@ export const step6_OrderPaymentCheck: EligibilityRule = { const hasNonPlacement = ctx.hasNonPlacement ?? false const matchingOrders = orders.filter( - (o) => - (o.contractNumber && contractNumbers.includes(o.contractNumber)) || - (o.agreementRowId && agreementRowIds.includes(o.agreementRowId)), + (order) => + order.source === 'MIS' || + (order.contractNumber && contractNumbers.includes(order.contractNumber)) || + (order.agreementRowId && agreementRowIds.includes(order.agreementRowId)), ) if (matchingOrders.length === 0) { @@ -38,8 +43,8 @@ export const step6_OrderPaymentCheck: EligibilityRule = { } const prevMonth = getPreviousMonth(ctx.referenceDate) - const previousMonthOrders = matchingOrders.filter((o) => - isInMonth(o.effectiveStartDate, prevMonth), + const previousMonthOrders = matchingOrders.filter((order) => + isInMonth(order.effectiveStartDate, prevMonth), ) if (previousMonthOrders.length === 0) { diff --git a/backend/src/sync/eligibility/select-primary-records.spec.ts b/backend/src/sync/eligibility/select-primary-records.spec.ts new file mode 100644 index 00000000..66142f70 --- /dev/null +++ b/backend/src/sync/eligibility/select-primary-records.spec.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest' +import { selectPrimaryRecords } from './eligibility.service' +import { makeContact as makeProfile, makePlacement, makeOrder, makeAgreement } from './test-helpers' + +describe('selectPrimaryRecords', () => { + describe('placement priority', () => { + it('returns null when no placements exist', () => { + const result = selectPrimaryRecords(makeProfile()) + expect(result.primaryPlacement).toBeNull() + expect(result.primaryOrder).toBeNull() + expect(result.primaryAgreement).toBeNull() + }) + + it('selects ICM Placement over all others', () => { + const icmPlacement = makePlacement({ + source: 'ICM', + type: 'Placement', + placementNumber: 'ICM-PL', + }) + const icmNonPlacement = makePlacement({ + source: 'ICM', + type: 'Non-Placement Location', + placementNumber: 'ICM-NPL', + }) + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + placementNumber: 'MIS-PL', + }) + const misNonPlacement = makePlacement({ + source: 'MIS', + type: 'Non-Placement Location', + placementNumber: 'MIS-NPL', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [misNonPlacement, misPlacement, icmNonPlacement, icmPlacement] }), + ) + expect(result.primaryPlacement!.placementNumber).toBe('ICM-PL') + }) + + it('falls back to ICM Non-Placement when no ICM Placement exists', () => { + const icmNonPlacement = makePlacement({ + source: 'ICM', + type: 'Non-Placement Location', + placementNumber: 'ICM-NPL', + }) + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + placementNumber: 'MIS-PL', + }) + const misNonPlacement = makePlacement({ + source: 'MIS', + type: 'Non-Placement Location', + placementNumber: 'MIS-NPL', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [misNonPlacement, misPlacement, icmNonPlacement] }), + ) + expect(result.primaryPlacement!.placementNumber).toBe('ICM-NPL') + }) + + it('falls back to MIS Placement when no ICM records exist', () => { + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + placementNumber: 'MIS-PL', + }) + const misNonPlacement = makePlacement({ + source: 'MIS', + type: 'Non-Placement Location', + placementNumber: 'MIS-NPL', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [misNonPlacement, misPlacement] }), + ) + expect(result.primaryPlacement!.placementNumber).toBe('MIS-PL') + }) + + it('falls back to MIS Non-Placement as last resort', () => { + const misNonPlacement = makePlacement({ + source: 'MIS', + type: 'Non-Placement Location', + placementNumber: 'MIS-NPL', + }) + + const result = selectPrimaryRecords(makeProfile({ placements: [misNonPlacement] })) + expect(result.primaryPlacement!.placementNumber).toBe('MIS-NPL') + }) + + it('ignores placements with non-active statuses', () => { + const ended = makePlacement({ + source: 'ICM', + type: 'Placement', + status: 'Ended', + placementNumber: 'ENDED', + }) + const active = makePlacement({ + source: 'MIS', + type: 'Non-Placement Location', + status: 'Active', + placementNumber: 'ACTIVE', + }) + + const result = selectPrimaryRecords(makeProfile({ placements: [ended, active] })) + expect(result.primaryPlacement!.placementNumber).toBe('ACTIVE') + }) + + it('includes Interrupted status placements', () => { + const interrupted = makePlacement({ + source: 'ICM', + type: 'Placement', + status: 'Interrupted', + placementNumber: 'INT', + }) + + const result = selectPrimaryRecords(makeProfile({ placements: [interrupted] })) + expect(result.primaryPlacement!.placementNumber).toBe('INT') + }) + + it('handles case-insensitive type and status matching', () => { + const placement = makePlacement({ + source: 'ICM', + type: ' placement ', + status: ' active ', + placementNumber: 'TRIMMED', + }) + + const result = selectPrimaryRecords(makeProfile({ placements: [placement] })) + expect(result.primaryPlacement!.placementNumber).toBe('TRIMMED') + }) + }) + + describe('order matching', () => { + it('matches ICM order via agreementRowId', () => { + const placement = makePlacement({ source: 'ICM', agreementRowId: 'AGR-1' }) + const matchingOrder = makeOrder({ + source: 'ICM', + agreementRowId: 'AGR-1', + orderNumber: 'MATCH', + }) + const otherOrder = makeOrder({ source: 'ICM', agreementRowId: 'AGR-2', orderNumber: 'OTHER' }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], orders: [otherOrder, matchingOrder] }), + ) + expect(result.primaryOrder!.orderNumber).toBe('MATCH') + }) + + it('matches MIS order via contractNumber', () => { + const placement = makePlacement({ source: 'MIS', type: 'Placement', contractNumber: 'CON-1' }) + const matchingOrder = makeOrder({ + source: 'MIS', + contractNumber: 'CON-1', + orderNumber: 'MATCH', + }) + const icmOrder = makeOrder({ + source: 'ICM', + contractNumber: 'CON-1', + orderNumber: 'ICM-ORDER', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], orders: [icmOrder, matchingOrder] }), + ) + expect(result.primaryOrder!.orderNumber).toBe('MATCH') + }) + + it('returns null order when no link key on placement', () => { + const placement = makePlacement({ source: 'ICM', agreementRowId: null }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], orders: [makeOrder()] }), + ) + expect(result.primaryOrder).toBeNull() + }) + }) + + describe('agreement matching', () => { + it('matches ICM agreement via agreementRowId', () => { + const placement = makePlacement({ source: 'ICM', agreementRowId: 'AGR-1' }) + const matching = makeAgreement({ source: 'ICM', rowId: 'AGR-1', agreementType: 'MATCH' }) + const other = makeAgreement({ source: 'ICM', rowId: 'AGR-2', agreementType: 'OTHER' }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], agreements: [other, matching] }), + ) + expect(result.primaryAgreement!.agreementType).toBe('MATCH') + }) + + it('matches MIS contract via contractNumber', () => { + const placement = makePlacement({ source: 'MIS', type: 'Placement', contractNumber: 'CON-1' }) + const matching = makeAgreement({ + source: 'MIS', + contractNumber: 'CON-1', + agreementType: 'MATCH', + }) + const icmAgreement = makeAgreement({ + source: 'ICM', + contractNumber: 'CON-1', + agreementType: 'ICM', + }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], agreements: [icmAgreement, matching] }), + ) + expect(result.primaryAgreement!.agreementType).toBe('MATCH') + }) + + it('returns null agreement when no link key on placement', () => { + const placement = makePlacement({ source: 'MIS', type: 'Placement', contractNumber: null }) + + const result = selectPrimaryRecords( + makeProfile({ placements: [placement], agreements: [makeAgreement()] }), + ) + expect(result.primaryAgreement).toBeNull() + }) + }) + + describe('end-to-end: placement drives order and agreement selection', () => { + it('selects ICM order/agreement when ICM placement wins', () => { + const icmPlacement = makePlacement({ + source: 'ICM', + type: 'Placement', + agreementRowId: 'AGR-ICM', + }) + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + contractNumber: 'CON-MIS', + }) + const icmOrder = makeOrder({ + source: 'ICM', + agreementRowId: 'AGR-ICM', + orderNumber: 'ICM-ORD', + }) + const misOrder = makeOrder({ + source: 'MIS', + contractNumber: 'CON-MIS', + orderNumber: 'MIS-ORD', + }) + const icmAgreement = makeAgreement({ + source: 'ICM', + rowId: 'AGR-ICM', + agreementType: 'ICM-AGR', + }) + const misAgreement = makeAgreement({ + source: 'MIS', + contractNumber: 'CON-MIS', + agreementType: 'MIS-AGR', + }) + + const result = selectPrimaryRecords( + makeProfile({ + placements: [misPlacement, icmPlacement], + orders: [misOrder, icmOrder], + agreements: [misAgreement, icmAgreement], + }), + ) + + expect(result.primaryPlacement!.source).toBe('ICM') + expect(result.primaryOrder!.orderNumber).toBe('ICM-ORD') + expect(result.primaryAgreement!.agreementType).toBe('ICM-AGR') + }) + + it('selects MIS order/agreement when MIS placement wins (no ICM)', () => { + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + contractNumber: 'CON-MIS', + }) + const misOrder = makeOrder({ + source: 'MIS', + contractNumber: 'CON-MIS', + orderNumber: 'MIS-ORD', + }) + const misAgreement = makeAgreement({ + source: 'MIS', + contractNumber: 'CON-MIS', + agreementType: 'MIS-AGR', + }) + + const result = selectPrimaryRecords( + makeProfile({ + placements: [misPlacement], + orders: [misOrder], + agreements: [misAgreement], + }), + ) + + expect(result.primaryPlacement!.source).toBe('MIS') + expect(result.primaryOrder!.orderNumber).toBe('MIS-ORD') + expect(result.primaryAgreement!.agreementType).toBe('MIS-AGR') + }) + + it('selects ICM non-placement with linked order/agreement over MIS placement', () => { + const icmNonPlacement = makePlacement({ + source: 'ICM', + type: 'Non-Placement Location', + agreementRowId: 'AGR-ICM', + }) + const misPlacement = makePlacement({ + source: 'MIS', + type: 'Placement', + contractNumber: 'CON-MIS', + }) + const icmOrder = makeOrder({ + source: 'ICM', + agreementRowId: 'AGR-ICM', + orderNumber: 'ICM-ORD', + }) + const icmAgreement = makeAgreement({ + source: 'ICM', + rowId: 'AGR-ICM', + agreementType: 'ICM-AGR', + }) + + const result = selectPrimaryRecords( + makeProfile({ + placements: [misPlacement, icmNonPlacement], + orders: [icmOrder], + agreements: [icmAgreement], + }), + ) + + expect(result.primaryPlacement!.source).toBe('ICM') + expect(result.primaryPlacement!.type).toBe('Non-Placement Location') + expect(result.primaryOrder!.orderNumber).toBe('ICM-ORD') + expect(result.primaryAgreement!.agreementType).toBe('ICM-AGR') + }) + }) +}) diff --git a/backend/src/sync/eligibility/test-helpers.ts b/backend/src/sync/eligibility/test-helpers.ts new file mode 100644 index 00000000..9193ee38 --- /dev/null +++ b/backend/src/sync/eligibility/test-helpers.ts @@ -0,0 +1,90 @@ +import { AgreementRecord, ContactProfile, OrderRecord, PlacementRecord } from './eligibility.types' + +export function makeContact(overrides: Partial = {}): ContactProfile { + return { + caseRowId: 'CASE-1', + contactIdIcm: null, + personIdIcm: 'ICM-1', + personIdMis: 'MIS-1', + firstName: 'John', + lastName: 'Doe', + middleName: '', + dateOfBirth: new Date('2010-01-15'), + age: 16, + gender: 'M', + caseNumber: 'CS-001', + caseType: 'Child Services', + caseStatus: 'Open', + caseLoad: 'CL-1', + legacyFileNumber: null, + serviceOffice: null, + assignedTo: null, + csaStatus: null, + csaStatusEffectiveDate: null, + existingContactId: null, + din: null, + csaSentDate: null, + misLegalAuthCode: null, + enrollForCsa: null, + legalExpiryDate: null, + effectiveLegalStatus: null, + legalAuthorityCode: null, + effectiveDate: null, + birthCity: null, + birthProvince: null, + birthCountry: null, + akaFirstName: null, + akaLastName: null, + isIneligible: false, + deceased: null, + cancelReasonCode: null, + careEndDate: null, + placements: [], + orders: [], + agreements: [], + ...overrides, + } +} + +export function makePlacement(overrides: Partial = {}): PlacementRecord { + return { + type: 'Placement', + rawType: null, + status: 'Active', + startDate: null, + endDate: null, + contractNumber: null, + agreementRowId: null, + paidUnpaid: null, + source: 'ICM', + ...overrides, + } +} + +export function makeOrder(overrides: Partial = {}): OrderRecord { + return { + orderType: 'Monthly Family Care Rate', + orderStatus: 'Closed', + effectiveStartDate: null, + effectiveEndDate: null, + amount: 2000, + contractNumber: null, + source: 'MIS', + ...overrides, + } +} + +export function makeAgreement(overrides: Partial = {}): AgreementRecord { + return { + rowId: null, + contractNumber: null, + agreementType: 'SHSS', + agreementStatus: 'Active', + agreementStartDate: new Date('2025-01-01'), + agreementEndDate: null, + terminationDate: null, + mcfdContract: null, + source: 'ICM', + ...overrides, + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 16aa93b7..29527edd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,7 +59,7 @@ import { // Maps to backend CSA_STATUSES constants const VALID_CSA_STATUSES = [ 'eligible_tbd', // Eligible - TBD - 'application_refused', // Application Refused - CRA (note: no _cra suffix in backend) + 'application_refused_cra', // Application Refused - CRA 'not_eligible_ip_tbd', // Not Eligible - IP - TBD 'cancellation_refused_cra', // Cancellation Refused - CRA 'on_hold', // On Hold @@ -102,13 +102,14 @@ const CASE_STATUS_FILTER_OPTIONS = [ ] // Batch Status options for filter dropdown (used in Batch History and Batch Requests) +// Values must match statusLabel display values used in filteredBatchRequests/filteredBatchHistory const BATCH_STATUS_FILTER_OPTIONS = [ - { value: 'pending', label: 'Pending' }, - { value: 'in_progress', label: 'In Progress' }, - { value: 'system_error', label: 'System Error' }, - { value: 'processed_with_errors', label: 'Processed with Errors' }, - { value: 'processed', label: 'Processed' }, - { value: 'error', label: 'Error' }, + { value: 'Pending', label: 'Pending' }, + { value: 'In Progress', label: 'In Progress' }, + { value: 'System Error', label: 'System Error' }, + { value: 'Processed with Errors', label: 'Processed with Errors' }, + { value: 'Processed', label: 'Processed' }, + { value: 'Error', label: 'Error' }, ] // Batch Details Status options for filter dropdown @@ -1679,10 +1680,10 @@ function App() { const getBatchRequestsUniqueValues = (column: string) => { const values = batches.map((batch) => { - // Map API fields to display fields + // Map API fields to display fields - must match filteredBatchRequests transformation switch (column) { case 'batchId': - return `1-${batch.id}` + return String(batch.id) case 'batchDate': return batch.batchDate ? formatDateYMD(batch.batchDate) : '' case 'status':