lead-to-policy/src/lib/recommend.ts
Bhanu Prakash Sai Potteri 0bccb9ad96 feat: dynamic riders in Recommend form, driven by product allowed_riders
Riders multiselect filters to the picked product's allowed_riders (newly
exposed on the product lookup). Disabled w/ hint until a product is chosen;
auto-prunes now-invalid selections on switch w/ a note. Legacy product values
(no allowed_riders key) fall back to all options. Brace-safe rider normalizer.
MultiSelect gains disabled + hint props.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:20:07 +05:30

116 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Faithful port of the Recommend Product form's designer `custom_js`
// (activity_data_fields → recommended_product_input / sum_assured_input).
// The standard renderer runs that JS on field-change and writes
// `sa_valid_input` + the rate-card `premium_amount_input`. The bespoke
// RecommendBody must replicate it, or the Meeting Scheduled → Product
// Recommended stage gate (rule d44a9000: `sa_valid is_true`) never passes and
// the lead is silently stuck. Verified against workflow e29c3c33 / app 385.
import type { ProductLookup } from '../api/types';
export interface RecommendCalc {
/** Rate-card (or income-pct) premium — what the designer writes to premium_amount_input. */
premium: number;
/** Drives the stage gate. True only when age, sum-assured bounds and income multiple all pass. */
saValid: boolean;
age: number | null;
okAge: boolean;
okBounds: boolean;
okMult: boolean;
/** True once a plan + sum assured are present (so callers can show validity). */
evaluated: boolean;
}
const num = (v: unknown): number => Number(v) || 0;
function ageFromDob(dob?: string | null): number | null {
if (!dob) return null;
const b = new Date(dob);
if (Number.isNaN(b.getTime())) return null;
const n = new Date();
let age = n.getFullYear() - b.getFullYear();
if (n < new Date(n.getFullYear(), b.getMonth(), b.getDate())) age--;
return age;
}
export function recommendCalc(
plan: ProductLookup | null | undefined,
dob: string | null | undefined,
sumAssured: number,
annualIncome: number | null | undefined,
): RecommendCalc {
const age = ageFromDob(dob);
const sa = num(sumAssured);
const inc = num(annualIncome);
const blank: RecommendCalc = { premium: 0, saValid: false, age, okAge: false, okBounds: false, okMult: false, evaluated: false };
if (!plan || typeof plan !== 'object' || age == null || !sa) return blank;
const p = plan as Record<string, unknown>;
let premium = 0;
if (p.premium_basis === 'rate_card') {
let bands: Record<string, number> = {};
try {
bands =
typeof p.term_rate_bands === 'string'
? (JSON.parse(p.term_rate_bands) as Record<string, number>)
: ((p.term_rate_bands as Record<string, number>) ?? {});
} catch {
bands = {};
}
const band = age <= 30 ? '18-30' : age <= 40 ? '31-40' : age <= 50 ? '41-50' : '51-65';
premium = Math.round((sa / 1000) * num(bands[band]));
} else {
premium = Math.round(inc * num(p.premium_income_pct));
}
const okAge =
(p.entry_age_min == null || age >= num(p.entry_age_min)) &&
(p.entry_age_max == null || age <= num(p.entry_age_max));
const okBounds = sa >= num(p.min_sum_assured) && (!p.max_sum_assured || sa <= num(p.max_sum_assured));
const okMult =
!inc || (sa >= inc * num(p.sa_income_multiple_min) && sa <= inc * num(p.sa_income_multiple_max));
return { premium, saValid: okAge && okBounds && okMult, age, okAge, okBounds, okMult, evaluated: true };
}
/**
* Normalize a rider list into clean string tokens. A Postgres text[] can reach
* the client in three shapes: a real JSON array `["critical_illness", …]`, a
* plain CSV string, or an array-literal string `"{critical_illness,accidental_death}"`.
* Strip surrounding braces + per-token quotes, trim, drop empties.
*/
export function normalizeRiderValues(raw: unknown): string[] {
if (raw == null) return [];
const parts = Array.isArray(raw)
? raw.map((v) => String(v))
: String(raw)
.replace(/^\s*\{|\}\s*$/g, '') // strip the surrounding `{ }` of an array literal
.split(',');
return parts
.map((s) => s.trim().replace(/^["']|["']$/g, '').trim()) // drop quotes around tokens
.filter(Boolean);
}
/**
* Derive the rider values a product permits.
* - key ABSENT → null: a legacy value saved before allowed_riders existed; the
* caller should fall back to showing ALL rider options (don't hide saved riders).
* - key PRESENT → the normalized list (possibly empty = the plan truly allows none).
*/
export function allowedRidersFor(product: Record<string, unknown> | null | undefined): string[] | null {
if (!product || typeof product !== 'object') return null;
if (!('allowed_riders' in product)) return null; // legacy value — no key at all
return normalizeRiderValues(product.allowed_riders);
}
/** Human reasons a recommendation fails validation (for an inline hint). */
export function saValidIssues(c: RecommendCalc): string[] {
if (!c.evaluated || c.saValid) return [];
const out: string[] = [];
if (!c.okAge) out.push('applicant age outside the plans entry-age band');
if (!c.okBounds) out.push('sum assured outside the plans min/max');
if (!c.okMult) out.push('sum assured outside the allowed income multiple');
return out;
}