Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions backend/src/common/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isEligibleAge,
parseCalendarDate,
parseDateAsPacific,
parseISODatePacific,
} from './utils'

describe('firstDayOfPreviousMonthPacific', () => {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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', () => {
Expand Down
15 changes: 15 additions & 0 deletions backend/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down Expand Up @@ -66,6 +76,11 @@ const DATE_ONLY_FIELDS = new Set([
'orderEffectiveEndDate',
'careEndDate',
'batchDate',
'actualStartDate',
'actualEndDate',
'agreementStartDate',
'agreementEndDate',
'terminationDate',
])

export function enrichLabels<T extends Record<string, any>>(record: T): T {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,31 +47,31 @@ 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 }
}

// 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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading