// 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; let premium = 0; if (p.premium_basis === 'rate_card') { let bands: Record = {}; try { bands = typeof p.term_rate_bands === 'string' ? (JSON.parse(p.term_rate_bands) as Record) : ((p.term_rate_bands as Record) ?? {}); } 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 | 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 plan’s entry-age band'); if (!c.okBounds) out.push('sum assured outside the plan’s min/max'); if (!c.okMult) out.push('sum assured outside the allowed income multiple'); return out; }