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>
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
// 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 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;
|
||
}
|