diff --git a/backend/package-lock.json b/backend/package-lock.json index f9eff01a..cf022b30 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -5710,9 +5710,9 @@ "license": "MIT" }, "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6392,9 +6392,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", - "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", "funding": [ { "type": "github", @@ -6404,8 +6404,8 @@ "license": "MIT", "dependencies": { "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.1.2" + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6586,9 +6586,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -8964,9 +8964,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", - "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", "funding": [ { "type": "github", @@ -10437,9 +10437,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", diff --git a/backend/package.json b/backend/package.json index 7c462f5a..4743cd17 100644 --- a/backend/package.json +++ b/backend/package.json @@ -101,6 +101,7 @@ "@angular-devkit/core": { "ajv": "8.18.0" }, - "lodash": "4.17.23" + "lodash": "4.17.23", + "effect": "^3.20.0" } } diff --git a/backend/src/sync/eligibility/eligibility.config.ts b/backend/src/sync/eligibility/eligibility.config.ts index b4b21957..76a6614d 100644 --- a/backend/src/sync/eligibility/eligibility.config.ts +++ b/backend/src/sync/eligibility/eligibility.config.ts @@ -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, diff --git a/backend/src/sync/eligibility/eligibility.queries.ts b/backend/src/sync/eligibility/eligibility.queries.ts index 4c84c75a..a61e68da 100644 --- a/backend/src/sync/eligibility/eligibility.queries.ts +++ b/backend/src/sync/eligibility/eligibility.queries.ts @@ -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) @@ -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 ), @@ -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 ) 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 c273eaa1..953d2a0b 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,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' @@ -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', () => { @@ -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') })], }) @@ -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) + }) + }) }) 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 1ae489c5..f1709327 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,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) */ @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67622537..959294ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6845,9 +6845,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" },