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
1 change: 1 addition & 0 deletions backend/src/sync/eligibility/eligibility.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const ELIGIBILITY_CONFIG = {
'MAINTENANCE PAYMENT',
'FIXED RATE',
'VARIABLE RATE',
'VAR RATE',
] as readonly string[],
ELIGIBLE_ORDER_STATUSES: ['CLOSED', 'PROCESSED'] as readonly string[],
MIN_ORDER_AMOUNT: 1549.2,
Expand Down
8 changes: 4 additions & 4 deletions backend/src/sync/eligibility/eligibility.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,12 @@ const CHANGED_CONTACTS_CTE = `
* - changed_contacts (incremental only): contacts with recently changed data
* - eligible_cases: all case rows from staging (filtered by change detection in incremental mode)
* - latest_legal_auth: most recent legal authority per person (DISTINCT ON X_CONTACT_NUM)
* - icm_placements_agg: active/interrupted ICM placements grouped by contact
* - icm_placements_agg: active/interrupted/ended/closed ICM placements grouped by contact
* - icm_orders_agg: ICM orders grouped by contact (via placement -> agreement)
* - icm_agreements_agg: ICM agreements grouped by contact (via placement)
* - mis_payments_agg: MIS payments grouped by contact (via contract -> placement -> PERSON_ID_MIS)
* - mis_contracts_agg: MIS contracts grouped by contact (via placement -> PERSON_ID_MIS)
* - mis_placements_agg: active/interrupted/ended MIS placements grouped by contact (via PERSON_ID_MIS)
* - mis_placements_agg: active/interrupted/ended/closed MIS placements grouped by contact (via PERSON_ID_MIS)
*
* Join keys:
* - ICM data joins on CONTACT_ROW_ID (table-to-table), aggregated by X_CONTACT_NUM (person)
Expand Down Expand Up @@ -190,7 +190,7 @@ export function buildLoadContactProfilesSql(
)) AS data
FROM stg_icm_placements icm_plc
INNER JOIN eligible_cases ON eligible_cases.ROW_ID = icm_plc.CASE_ROW_ID
WHERE UPPER(TRIM(icm_plc.X_STATUS)) IN ('ACTIVE', 'INTERRUPTED')
WHERE UPPER(TRIM(icm_plc.X_STATUS)) IN ('ACTIVE', 'INTERRUPTED', 'ENDED', 'CLOSED')
GROUP BY eligible_cases.X_CONTACT_NUM
),

Expand Down Expand Up @@ -339,7 +339,7 @@ export function buildLoadContactProfilesSql(
FROM stg_mis_placements mis_plc
INNER JOIN eligible_cases
ON mis_plc.person_id_mis = eligible_cases.PERSON_ID_MIS
WHERE UPPER(TRIM(mis_plc.status)) IN ('ACTIVE', 'INTERRUPTED', 'ENDED')
WHERE UPPER(TRIM(mis_plc.status)) IN ('ACTIVE', 'INTERRUPTED', 'ENDED', 'CLOSED')
GROUP BY eligible_cases.X_CONTACT_NUM
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { ContactProfile, OrderRecord } from '../../eligibility.types'
import { makeContact, makeOrder as makeBaseOrder } from '../../test-helpers'
import { makeOrder as makeBaseOrder, makeContact } from '../../test-helpers'
import { EligibilityContext } from '../rule.interface'
import { step6_OrderPaymentCheck } from './step6-order-payment-check'

Expand Down Expand Up @@ -90,12 +90,16 @@ describe('step6_OrderPaymentCheck', () => {
expect(result!.step).toBe(7)
})

it('should accept MIS order types (Fixed Rate, Variable Rate)', () => {
const ctx = makeCtx({
it('should accept MIS order types (Fixed Rate, Variable Rate, Var Rate)', () => {
const fixedRate = makeCtx({
orders: [makeOrder({ orderType: 'Fixed Rate', source: 'MIS' })],
})
const result = step6_OrderPaymentCheck.evaluate(ctx)
expect(result!.step).toBe(7)
expect(step6_OrderPaymentCheck.evaluate(fixedRate)!.step).toBe(7)

const varRate = makeCtx({
orders: [makeOrder({ orderType: 'Var Rate', source: 'MIS' })],
})
expect(step6_OrderPaymentCheck.evaluate(varRate)!.step).toBe(7)
})

it('should handle variant casing and whitespace in order type and status', () => {
Expand All @@ -115,7 +119,6 @@ describe('step6_OrderPaymentCheck', () => {
})

it('should check order effective start date is in previous month', () => {
// REF_DATE is Feb 2026, so previous month is Jan 2026
const ctx = makeCtx({
orders: [makeOrder({ effectiveStartDate: new Date('2025-12-15') })],
})
Expand Down Expand Up @@ -169,4 +172,62 @@ describe('step6_OrderPaymentCheck', () => {
const result = step6_OrderPaymentCheck.evaluate(ctx)
expect(result!.step).toBe(8)
})

describe('ICM precedence over MIS', () => {
it('should use ICM orders when ICM has prev-month orders, ignoring MIS', () => {
const ctx = makeCtx({
orders: [
makeOrder({ source: 'ICM', amount: 1000 }),
makeOrder({ source: 'MIS', amount: 1600 }),
],
})
const result = step6_OrderPaymentCheck.evaluate(ctx)
expect(result!.step).toBe(8)
})

it('should fall back to MIS when no ICM orders in previous month', () => {
const ctx = makeCtx({
orders: [
makeOrder({ source: 'ICM', effectiveStartDate: new Date('2025-12-15') }),
makeOrder({ source: 'MIS', amount: 1600 }),
],
})
const result = step6_OrderPaymentCheck.evaluate(ctx)
expect(result!.step).toBe(7)
})

it('should fall back to MIS when no ICM orders exist at all', () => {
const ctx = makeCtx({
orders: [makeOrder({ source: 'MIS', amount: 1600 })],
})
const result = step6_OrderPaymentCheck.evaluate(ctx)
expect(result!.step).toBe(7)
})

it('should only use prev-month ICM orders when mixed with non-prev-month ICM orders', () => {
const ctx = makeCtx({
orders: [
makeOrder({ source: 'ICM', effectiveStartDate: new Date('2025-12-15'), amount: 2000 }),
makeOrder({ source: 'ICM', amount: 1000 }),
makeOrder({ source: 'MIS', amount: 1600 }),
],
})
const result = step6_OrderPaymentCheck.evaluate(ctx)
expect(result!.step).toBe(8)
})

it('should use ICM even when ICM fails and MIS would succeed', () => {
const ctx = makeCtx(
{
orders: [
makeOrder({ source: 'ICM', orderType: 'Invalid Type' }),
makeOrder({ source: 'MIS', amount: 2000 }),
],
},
{ hasNonPlacement: false },
)
const result = step6_OrderPaymentCheck.evaluate(ctx)
expect(result!.step).toBe(9)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ 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):
* 1. Filter to previous month orders only
* 2. Check ALL orders against the 4 criteria (type, status, date, amount)
* ICM takes precedence over MIS (per FDD):
* 1. Link orders to eligible placements via contractNumber or agreementRowId
* 2. Check ICM orders for previous month first
* 3. Only fall back to MIS payments if no ICM orders found in previous month
* 4. Evaluate against 4 criteria (type, status, date, amount)
*
* Any order matches all 4 -> Step 7 (eligible)
* Best match only fails on amount (or no order found) -> Step 8 (eligible_tbd)
* Best match only fails on amount -> Step 8 (eligible_tbd)
* More than one criterion fails -> Step 8 if hasNonPlacement, Step 9 otherwise
* No matching orders at all -> Step 8 (eligible_tbd)
*/
Expand All @@ -38,9 +40,18 @@ export const step6_OrderPaymentCheck: EligibilityRule = {
}

const prevMonth = getPreviousMonth(ctx.referenceDate)
const previousMonthOrders = matchingOrders.filter((order) =>
isInMonth(order.effectiveStartDate, prevMonth),
)

// ICM first: check ICM orders in previous month
const icmOrders = matchingOrders.filter((order) => order.source === 'ICM')
const icmPrevMonth = icmOrders.filter((order) => isInMonth(order.effectiveStartDate, prevMonth))

// Fall back to MIS only if no ICM orders in previous month
const previousMonthOrders =
icmPrevMonth.length > 0
? icmPrevMonth
: matchingOrders
.filter((order) => order.source === 'MIS')
.filter((order) => isInMonth(order.effectiveStartDate, prevMonth))

if (previousMonthOrders.length === 0) {
return hasNonPlacement
Expand Down
Loading