Implements §10 Ten-Year Value Comparison
This commit is contained in:
@@ -1,52 +1,128 @@
|
|||||||
/**
|
/**
|
||||||
* TenYearValueChart — upfront vs illustrative 10-yr savings, per option (spec §10).
|
* TenYearValueChart — 10-year value comparison panel (spec §10).
|
||||||
* Savings bars only render when a premium was provided and conservative mode is off.
|
*
|
||||||
|
* Honors the full §10 brief:
|
||||||
|
* - shows ASSUMPTIONS per tier (discount %, service life, hail positioning, value view)
|
||||||
|
* - lets the customer TOGGLE discount scenarios (low / base / high)
|
||||||
|
* - separates upfront cost from lifecycle value (breakeven + lifecycle delta)
|
||||||
|
* - never claims a guaranteed discount; all savings labeled illustrative
|
||||||
|
*
|
||||||
|
* Savings/breakeven/lifecycle only render when a premium was provided and
|
||||||
|
* conservative mode is off; otherwise the panel falls back to assumptions only.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts';
|
||||||
|
|
||||||
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||||
const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' };
|
const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' };
|
||||||
|
const SCENARIOS = [
|
||||||
|
{ key: 'low', label: 'Low' },
|
||||||
|
{ key: 'base', label: 'Base' },
|
||||||
|
{ key: 'high', label: 'High' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const usd = (n) => `$${Number(n).toLocaleString('en-US')}`;
|
||||||
|
|
||||||
const TenYearValueChart = ({ valueModel }) => {
|
const TenYearValueChart = ({ valueModel }) => {
|
||||||
|
const [scenario, setScenario] = useState('base');
|
||||||
|
const showSavings = valueModel.premiumKnown && !valueModel.conservativeMode;
|
||||||
|
|
||||||
const data = valueModel.rows.map((r) => ({
|
const data = valueModel.rows.map((r) => ({
|
||||||
name: PKG_LABEL[r.type],
|
name: PKG_LABEL[r.type],
|
||||||
type: r.type,
|
type: r.type,
|
||||||
Upfront: r.price.base,
|
Upfront: r.price.base,
|
||||||
'Est. 10-yr savings': r.savings ? r.savings.base : 0,
|
'Est. 10-yr savings': r.savings ? r.savings[scenario] : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fmt = (n) => `$${(n / 1000).toFixed(0)}k`;
|
const fmtK = (n) => `$${(n / 1000).toFixed(0)}k`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||||
<h4 className="font-semibold text-zinc-900 dark:text-white text-sm mb-3">10-year value comparison</h4>
|
<div className="flex items-center justify-between gap-2 mb-3 flex-wrap">
|
||||||
<div style={{ width: '100%', height: 220 }}>
|
<h4 className="font-semibold text-zinc-900 dark:text-white text-sm">10-year value comparison</h4>
|
||||||
|
{showSavings && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-400 mr-1">Discount scenario</span>
|
||||||
|
{SCENARIOS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScenario(s.key)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-all
|
||||||
|
${scenario === s.key
|
||||||
|
? 'bg-amber-500 text-white shadow'
|
||||||
|
: 'bg-zinc-100 dark:bg-white/5 text-zinc-500 dark:text-zinc-400 hover:text-amber-600'}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upfront vs illustrative savings */}
|
||||||
|
<div style={{ width: '100%', height: 200 }}>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a1a1aa" />
|
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a1a1aa" />
|
||||||
<YAxis tickFormatter={fmt} tick={{ fontSize: 11 }} stroke="#a1a1aa" width={44} />
|
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} stroke="#a1a1aa" width={44} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(v) => `$${Number(v).toLocaleString('en-US')}`}
|
formatter={(v) => usd(v)}
|
||||||
contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }}
|
contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
<Bar dataKey="Upfront" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="Upfront" radius={[4, 4, 0, 0]}>
|
||||||
{data.map((d) => <Cell key={d.type} fill={BAR_COLOR[d.type]} fillOpacity={0.85} />)}
|
{data.map((d) => <Cell key={d.type} fill={BAR_COLOR[d.type]} fillOpacity={0.85} />)}
|
||||||
</Bar>
|
</Bar>
|
||||||
{valueModel.premiumKnown && !valueModel.conservativeMode && (
|
{showSavings && (
|
||||||
<Bar dataKey="Est. 10-yr savings" radius={[4, 4, 0, 0]} fill="#22c55e" fillOpacity={0.8} />
|
<Bar dataKey="Est. 10-yr savings" radius={[4, 4, 0, 0]} fill="#22c55e" fillOpacity={0.8} />
|
||||||
)}
|
)}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-zinc-400 mt-2">
|
|
||||||
{valueModel.premiumKnown
|
{/* Assumptions + lifecycle metrics per tier */}
|
||||||
? 'Savings shown are illustrative and depend on carrier approval, policy terms, and documentation.'
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 mt-4">
|
||||||
: 'Add your insurance premium earlier in the wizard to see an illustrative 10-year savings scenario.'}
|
{valueModel.rows.map((r) => (
|
||||||
|
<div key={r.type} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
|
||||||
|
<div className="text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">{PKG_LABEL[r.type]}</div>
|
||||||
|
<Metric label="Upfront (base)" value={usd(r.price.base)} />
|
||||||
|
<Metric label="Discount assumption" value={`${r.discountScenario[scenario] ?? 0}%`} />
|
||||||
|
<Metric label="Service life" value={`${r.serviceLifeYears}+ yrs`} />
|
||||||
|
<Metric label="Hail resistance" value={r.hailResistance} />
|
||||||
|
<Metric label="10-yr value view" value={r.valueView} muted />
|
||||||
|
{showSavings && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-zinc-100 dark:border-white/5 my-2" />
|
||||||
|
<Metric label="Est. 10-yr savings" value={r.savings ? usd(r.savings[scenario]) : '—'} accent />
|
||||||
|
{/* Breakeven only when pricing AND discount known (spec §10 display rule). */}
|
||||||
|
<Metric label="Upgrade breakeven" value={r.breakeven[scenario] != null ? `${r.breakeven[scenario]} yrs` : '—'} />
|
||||||
|
<Metric label="Lifecycle value delta" value={usd(r.lifecycleDelta[scenario])} hint="model estimate" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-zinc-400 mt-3 leading-relaxed">
|
||||||
|
{showSavings
|
||||||
|
? 'Savings scenarios are illustrative model estimates — not guaranteed. They depend on carrier approval, policy terms, state rules, and documentation. Lifecycle value delta is a model estimate, not a promise.'
|
||||||
|
: valueModel.conservativeMode
|
||||||
|
? 'Savings scenarios are hidden. Figures show upfront cost and durability assumptions only.'
|
||||||
|
: 'Add your insurance premium earlier in the wizard to see illustrative 10-year savings, breakeven, and lifecycle value scenarios.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Metric = ({ label, value, accent, muted, hint }) => (
|
||||||
|
<div className="flex items-baseline justify-between gap-2 py-0.5">
|
||||||
|
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||||
|
<span className={`text-xs font-semibold text-right ${accent ? 'text-emerald-600 dark:text-emerald-400' : muted ? 'text-zinc-500 dark:text-zinc-400' : 'text-zinc-800 dark:text-zinc-100'}`}>
|
||||||
|
{value}
|
||||||
|
{hint && <span className="block text-[9px] font-normal text-zinc-400 italic">{hint}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default TenYearValueChart;
|
export default TenYearValueChart;
|
||||||
|
|||||||
@@ -3,63 +3,82 @@
|
|||||||
*
|
*
|
||||||
* Makes premium options understandable WITHOUT pretending savings are guaranteed:
|
* Makes premium options understandable WITHOUT pretending savings are guaranteed:
|
||||||
* - 10-yr premium savings scenario = annual premium × assumed discount × 10
|
* - 10-yr premium savings scenario = annual premium × assumed discount × 10
|
||||||
* - shows low/base/high range (carrier discount is never certain)
|
* - computes low/base/high scenarios so the UI can TOGGLE between them (carrier
|
||||||
|
* discount is never certain)
|
||||||
* - upgrade breakeven only when pricing AND discount are known
|
* - upgrade breakeven only when pricing AND discount are known
|
||||||
* - lifecycle value delta is labeled a model estimate, not a promise
|
* - lifecycle value delta is labeled a model estimate, not a promise
|
||||||
|
* - per-tier "10-year value view" positioning (Upfront only / Savings + protection /
|
||||||
|
* Savings + longer lifecycle + protection)
|
||||||
*
|
*
|
||||||
* Pure function. Honors conservativeMode (spec §18) by suppressing savings claims.
|
* Pure function. Honors conservativeMode (spec §18) by suppressing savings claims.
|
||||||
*/
|
*/
|
||||||
import { pricePackage } from './pricing';
|
import { pricePackage } from './pricing';
|
||||||
|
|
||||||
const YEARS = 10;
|
const YEARS = 10;
|
||||||
|
const SCEN_KEYS = ['low', 'base', 'high'];
|
||||||
|
|
||||||
|
// 10-year value view positioning per tier (spec §10 comparison table).
|
||||||
|
const VALUE_VIEW = {
|
||||||
|
GOOD: 'Upfront only',
|
||||||
|
BETTER: 'Savings + protection',
|
||||||
|
BEST: 'Savings + longer lifecycle + protection',
|
||||||
|
};
|
||||||
|
|
||||||
export function computeTenYearValue({ inputs, tenantConfig }) {
|
export function computeTenYearValue({ inputs, tenantConfig }) {
|
||||||
const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig;
|
const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig;
|
||||||
const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null;
|
const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null;
|
||||||
|
const showSavings = !!premium && !conservativeMode;
|
||||||
|
|
||||||
const goodPrice = pricePackage(packages.find((p) => p.type === 'GOOD'), pricing).base;
|
const goodPkg = packages.find((p) => p.type === 'GOOD');
|
||||||
|
const goodPrice = pricePackage(goodPkg, pricing).base;
|
||||||
|
const goodLife = goodPkg?.serviceLifeYears || 28;
|
||||||
|
|
||||||
const rows = packages.map((pkg) => {
|
const rows = packages.map((pkg) => {
|
||||||
const price = pricePackage(pkg, pricing);
|
const price = pricePackage(pkg, pricing);
|
||||||
const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 };
|
const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 };
|
||||||
|
|
||||||
// 10-year premium savings scenario (illustrative).
|
|
||||||
const savings = (premium && !conservativeMode)
|
|
||||||
? {
|
|
||||||
low: Math.round(premium * (scen.low / 100) * YEARS),
|
|
||||||
base: Math.round(premium * (scen.base / 100) * YEARS),
|
|
||||||
high: Math.round(premium * (scen.high / 100) * YEARS),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Upgrade cost vs the Good baseline.
|
// Upgrade cost vs the Good baseline.
|
||||||
const upgradeCost = Math.max(0, price.base - goodPrice);
|
const upgradeCost = Math.max(0, price.base - goodPrice);
|
||||||
|
|
||||||
// Breakeven (years) — only when both pricing and a positive savings scenario exist.
|
// Avoided future roof-cycle reserve proxy: extra service life beyond Good,
|
||||||
const annualSavingsBase = (premium && !conservativeMode) ? premium * (scen.base / 100) : 0;
|
// valued at Good's price prorated (labeled a model estimate downstream).
|
||||||
const breakevenYears = (upgradeCost > 0 && annualSavingsBase > 0)
|
|
||||||
? +(upgradeCost / annualSavingsBase).toFixed(1)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Lifecycle value delta (model estimate): savings + avoided future roof reserve − upgrade cost.
|
|
||||||
// Avoided-reserve proxy: extra service life beyond Good, valued at Good's price prorated.
|
|
||||||
const goodLife = packages.find((p) => p.type === 'GOOD').serviceLifeYears || 28;
|
|
||||||
const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife);
|
const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife);
|
||||||
const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5);
|
const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5);
|
||||||
const lifecycleDelta = (savings ? savings.base : 0) + avoidedReserve - upgradeCost;
|
|
||||||
|
// Per-scenario savings / annual / breakeven / lifecycle delta so the UI can
|
||||||
|
// toggle low/base/high (spec §10: "allow toggling discount scenarios").
|
||||||
|
const savings = {}, annualSavings = {}, breakeven = {}, lifecycleDelta = {};
|
||||||
|
SCEN_KEYS.forEach((k) => {
|
||||||
|
const pct = scen[k] || 0;
|
||||||
|
const annual = showSavings ? premium * (pct / 100) : 0;
|
||||||
|
const tenYr = Math.round(annual * YEARS);
|
||||||
|
annualSavings[k] = Math.round(annual);
|
||||||
|
savings[k] = tenYr;
|
||||||
|
// Breakeven (years) — only when both pricing and a positive discount exist.
|
||||||
|
breakeven[k] = (upgradeCost > 0 && annual > 0) ? +(upgradeCost / annual).toFixed(1) : null;
|
||||||
|
// Lifecycle value delta = savings + avoided reserve − upgrade cost.
|
||||||
|
lifecycleDelta[k] = Math.round(tenYr + avoidedReserve - upgradeCost);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: pkg.type,
|
type: pkg.type,
|
||||||
name: pkg.name,
|
name: pkg.name,
|
||||||
price,
|
price,
|
||||||
|
// Assumptions surfaced to the customer (spec §10 "show assumptions").
|
||||||
|
discountScenario: scen, // { low, base, high } discount % assumption
|
||||||
discountPct: scen.base,
|
discountPct: scen.base,
|
||||||
serviceLifeYears: pkg.serviceLifeYears,
|
serviceLifeYears: pkg.serviceLifeYears,
|
||||||
hailResistance: pkg.hailResistance,
|
hailResistance: pkg.hailResistance,
|
||||||
savings,
|
valueView: VALUE_VIEW[pkg.type] || '',
|
||||||
upgradeCost,
|
upgradeCost,
|
||||||
breakevenYears,
|
|
||||||
avoidedReserve,
|
avoidedReserve,
|
||||||
lifecycleDelta: Math.round(lifecycleDelta),
|
// Per-scenario outputs (null savings when not shown, for back-compat callers).
|
||||||
|
savings: showSavings ? savings : null,
|
||||||
|
annualSavings,
|
||||||
|
breakeven,
|
||||||
|
lifecycleDelta,
|
||||||
|
// Back-compat scalar (base scenario).
|
||||||
|
breakevenYears: breakeven.base,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,6 +86,7 @@ export function computeTenYearValue({ inputs, tenantConfig }) {
|
|||||||
years: YEARS,
|
years: YEARS,
|
||||||
premiumKnown: !!premium,
|
premiumKnown: !!premium,
|
||||||
conservativeMode: !!conservativeMode,
|
conservativeMode: !!conservativeMode,
|
||||||
|
scenarios: SCEN_KEYS,
|
||||||
rows,
|
rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,21 +148,45 @@ export async function generateReportPdf(session, tenantConfig) {
|
|||||||
|
|
||||||
// ── 10-year comparison ──
|
// ── 10-year comparison ──
|
||||||
heading('10-year value comparison');
|
heading('10-year value comparison');
|
||||||
|
|
||||||
|
// Assumptions table (spec §10 "show assumptions").
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: y, margin: { left: MARGIN, right: MARGIN },
|
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||||
head: [['Option', 'Upfront (base)', 'Est. 10-yr savings', 'Service life']],
|
head: [['Option', 'Upfront (base)', 'Discount assumption', 'Service life', 'Hail resistance', '10-yr value view']],
|
||||||
body: m.value.rows.map((r) => [
|
body: m.value.rows.map((r) => [
|
||||||
PKG_LABEL[r.type],
|
PKG_LABEL[r.type],
|
||||||
formatUSD(r.price.base),
|
formatUSD(r.price.base),
|
||||||
r.savings ? `${formatUSD(r.savings.low)}–${formatUSD(r.savings.high)}` : '—',
|
`${r.discountScenario?.base ?? 0}% (if eligible)`,
|
||||||
`${r.serviceLifeYears}+ yrs`,
|
`${r.serviceLifeYears}+ yrs`,
|
||||||
|
r.hailResistance,
|
||||||
|
r.valueView,
|
||||||
]),
|
]),
|
||||||
styles: { fontSize: 8.5, cellPadding: 2 },
|
styles: { fontSize: 8, cellPadding: 1.8 },
|
||||||
|
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
||||||
|
});
|
||||||
|
y = doc.lastAutoTable.finalY + 4;
|
||||||
|
|
||||||
|
// Value outputs table — savings range, breakeven, lifecycle delta (model estimate).
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: y, margin: { left: MARGIN, right: MARGIN },
|
||||||
|
head: [['Option', 'Est. 10-yr savings (low–high)', 'Upgrade breakeven', 'Lifecycle value delta (model est.)']],
|
||||||
|
body: m.value.rows.map((r) => [
|
||||||
|
PKG_LABEL[r.type],
|
||||||
|
r.savings ? `${formatUSD(r.savings.low)}–${formatUSD(r.savings.high)}` : '—',
|
||||||
|
r.breakeven?.base != null ? `${r.breakeven.base} yrs` : '—',
|
||||||
|
r.savings ? formatUSD(r.lifecycleDelta?.base ?? 0) : '—',
|
||||||
|
]),
|
||||||
|
styles: { fontSize: 8, cellPadding: 1.8 },
|
||||||
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
headStyles: { fillColor: [24, 24, 27], textColor: 255 },
|
||||||
});
|
});
|
||||||
y = doc.lastAutoTable.finalY + 6;
|
y = doc.lastAutoTable.finalY + 6;
|
||||||
doc.setFont('helvetica', 'italic'); doc.setFontSize(7.5); doc.setTextColor(120);
|
doc.setFont('helvetica', 'italic'); doc.setFontSize(7.5); doc.setTextColor(120);
|
||||||
text('Savings shown are illustrative and must be confirmed with your insurance carrier.', MARGIN, y); y += 8;
|
doc.splitTextToSize(
|
||||||
|
'Savings scenarios are illustrative model estimates, not guaranteed. They depend on carrier approval, policy '
|
||||||
|
+ 'terms, state rules, and documentation. Breakeven and lifecycle value delta are model estimates, not promises.',
|
||||||
|
W - MARGIN * 2,
|
||||||
|
).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; });
|
||||||
|
y += 4;
|
||||||
|
|
||||||
// ── Selected add-ons ──
|
// ── Selected add-ons ──
|
||||||
heading('Selected add-ons');
|
heading('Selected add-ons');
|
||||||
|
|||||||
Reference in New Issue
Block a user