From 9399d2988949158eb571fe4a2b8303f72995e0a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 15:39:49 +0000 Subject: [PATCH] Add formula component breakdown to income-required popup; alphabetize states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Income Required for Full Utilization" info popup now lists the value of each formula term (§461(l) deduction, $3K used, start-of-year NOL ÷ 80% gross-up, and the netted-out taxable ST/LT gains) under "Your value", so the planning target is auditable. The components are computed once in the engine (core.ts / sensitivity.ts) as incomeRequiredComponents and consumed by the popup — no parallel calculation. A reconciliation test asserts the terms net back to the headline figure. Also reordered the STATES list alphabetically by name (custom "Other" entry pinned last) so every state dropdown displays in alphabetical order. --- src/InfoPopup.test.tsx | 48 +++++++++++++++ src/InfoPopup.tsx | 105 +++++++++++++++++++++----------- src/calculations.test.ts | 16 +++++ src/calculations/core.ts | 29 ++++++--- src/calculations/sensitivity.ts | 21 +++++-- src/index.css | 38 ++++++++++++ src/lossBreakdown.test.ts | 8 +++ src/taxData.ts | 23 ++++--- src/types.ts | 15 +++++ src/workspace/WorkspaceTab.tsx | 28 +++++++++ 10 files changed, 270 insertions(+), 61 deletions(-) create mode 100644 src/InfoPopup.test.tsx diff --git a/src/InfoPopup.test.tsx b/src/InfoPopup.test.tsx new file mode 100644 index 0000000..1edb88d --- /dev/null +++ b/src/InfoPopup.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InfoText } from './InfoPopup'; + +describe('InfoText breakdown', () => { + it('renders each formula term with its value when a breakdown is provided', () => { + render( + + Income to Fully Utilize + + ); + + // Open the popup. + fireEvent.click(screen.getByText('Income to Fully Utilize')); + + // Headline value still shows. + expect(screen.getByText('$1,934,500')).toBeInTheDocument(); + + // Every component term and value is surfaced. + expect(screen.getByText('§461(l) Deduction')).toBeInTheDocument(); + expect(screen.getByText('$512,000')).toBeInTheDocument(); + expect(screen.getByText('Start-of-Year NOL ($1,000,000) ÷ 80%')).toBeInTheDocument(); + expect(screen.getByText('$1,250,000')).toBeInTheDocument(); + + // The netted-out term renders as a subtraction. + expect(screen.getByText('−$24,000')).toBeInTheDocument(); + }); + + it('omits the breakdown list when none is provided', () => { + render( + + Income to Fully Utilize + + ); + fireEvent.click(screen.getByText('Income to Fully Utilize')); + expect(screen.getByText('$100,000')).toBeInTheDocument(); + expect(document.querySelector('.field-breakdown')).toBeNull(); + }); +}); diff --git a/src/InfoPopup.tsx b/src/InfoPopup.tsx index 69b6e27..9240bb1 100644 --- a/src/InfoPopup.tsx +++ b/src/InfoPopup.tsx @@ -47,35 +47,79 @@ export function InfoPopup({ title, children }: InfoPopupProps) { ); } +// A single labeled term of a formula, shown as a "Your value" sub-breakdown. +// `negative` renders the term as a subtraction (the netted-out piece). +export interface PopupBreakdownItem { + label: string; + value: string; + negative?: boolean; +} + +// Shared body for the field-level popups (FieldInfoPopup + InfoText) so the +// definition / formula / value / breakdown / impact layout stays in one place. +interface FieldPopupBodyContent { + definition: string; + formula?: string; + impact: string; +} + +function FieldPopupBody({ + content, + currentValue, + breakdown, +}: { + content: FieldPopupBodyContent; + currentValue?: string; + breakdown?: PopupBreakdownItem[]; +}) { + return ( +
+

{content.definition}

+ {content.formula && ( +
+ Formula: + {content.formula} +
+ )} + {currentValue && ( +
+ Your value: {currentValue} +
+ )} + {breakdown && breakdown.length > 0 && ( +
+ {breakdown.map(item => ( +
+
{item.label}
+
+ {item.negative ? '−' : ''} + {item.value} +
+
+ ))} +
+ )} +

+ Impact: {content.impact} +

+
+ ); +} + // Field-level info popup - shows definition, formula, and impact interface FieldInfoPopupProps { contentKey: string; currentValue?: string; + breakdown?: PopupBreakdownItem[]; } -export function FieldInfoPopup({ contentKey, currentValue }: FieldInfoPopupProps) { +export function FieldInfoPopup({ contentKey, currentValue, breakdown }: FieldInfoPopupProps) { const content = getPopupContent(contentKey); if (!content) return null; return ( -
-

{content.definition}

- {content.formula && ( -
- Formula: - {content.formula} -
- )} - {currentValue && ( -
- Your value: {currentValue} -
- )} -

- Impact: {content.impact} -

-
+
); } @@ -85,9 +129,10 @@ interface InfoTextProps { contentKey: string; children: React.ReactNode; currentValue?: string; + breakdown?: PopupBreakdownItem[]; } -export function InfoText({ contentKey, children, currentValue }: InfoTextProps) { +export function InfoText({ contentKey, children, currentValue, breakdown }: InfoTextProps) { const [isOpen, setIsOpen] = useState(false); const content = getPopupContent(contentKey); @@ -130,23 +175,11 @@ export function InfoText({ contentKey, children, currentValue }: InfoTextProps)
-
-

{content.definition}

- {content.formula && ( -
- Formula: - {content.formula} -
- )} - {currentValue && ( -
- Your value: {currentValue} -
- )} -

- Impact: {content.impact} -

-
+
, diff --git a/src/calculations.test.ts b/src/calculations.test.ts index 43be003..ce180c0 100644 --- a/src/calculations.test.ts +++ b/src/calculations.test.ts @@ -144,6 +144,22 @@ describe('incomeRequiredForFullUtilization', () => { const below = calculateWithOverrides(inputs, DEFAULT_SETTINGS, mkOverride(required * 0.5)); expect(below.years[1].nolUsedThisYear).toBeLessThan(startNolY2); }); + + it('exposes formula components that reconcile to the headline figure', () => { + // Use a case that exercises every term: a large core builds NOL, so Year 2 + // has a nonzero §461(l) deduction, NOL gross-up, and netted gains. + const result = calculate(createInputs({ collateralAmount: 10000000, annualIncome: 200000 })); + for (const year of result.years) { + const c = year.incomeRequiredComponents; + const reconstructed = Math.max( + 0, + c.section461Deduction + c.capitalLossUsed + c.nolGrossUp - c.netTaxableGains + ); + expect(reconstructed).toBeCloseTo(year.incomeRequiredForFullUtilization, 2); + // The gross-up is the start-of-year NOL divided by the 80% offset limit. + expect(c.nolGrossUp).toBeCloseTo(c.nolLimit > 0 ? c.startOfYearNol / c.nolLimit : 0, 2); + } + }); }); describe('redeployQfafProceeds', () => { diff --git a/src/calculations/core.ts b/src/calculations/core.ts index e760fca..3ab7b26 100644 --- a/src/calculations/core.ts +++ b/src/calculations/core.ts @@ -883,16 +883,30 @@ export function calculateYear( // start-of-year NOL balance. Capital-gain income already contributes, so // it is netted out; nolCarryforward here is the start-of-year balance. const nolLimitForRequired = settings.nolOffsetLimit ?? NOL_OFFSET_PERCENTAGE; + // Decomposition of the income-required formula, surfaced in the metric's info + // popup so advisors can see where the planning target comes from. These are + // the literal terms of the formula below — do not recompute them elsewhere. + const incomeRequiredComponents = { + // §461(l) ordinary deduction that must be absorbed by income this year + section461Deduction: safeNumber(allowedOrdinaryLoss), + // Capital loss applied against ordinary income this year (≤ $3K / $1.5K MFS) + capitalLossUsed: safeNumber(capitalLossUsedAgainstIncome), + // Start-of-year NOL balance the 80% limit must consume + startOfYearNol: safeNumber(nolCarryforward), + // The 80% (or overridden) NOL offset limit applied to the gross-up + nolLimit: nolLimitForRequired, + // Start-of-year NOL grossed up by the 80% limit (income needed to use it all) + nolGrossUp: safeNumber(nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0), + // Capital-gain income the strategy already generates, netted out of the target + netTaxableGains: safeNumber(taxableSt + taxableLt + eventTaxableSt + eventTaxableLt), + }; const incomeRequiredForFullUtilization = safeNumber( Math.max( 0, - allowedOrdinaryLoss + - capitalLossUsedAgainstIncome + - (nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0) - - taxableSt - - taxableLt - - eventTaxableSt - - eventTaxableLt + incomeRequiredComponents.section461Deduction + + incomeRequiredComponents.capitalLossUsed + + incomeRequiredComponents.nolGrossUp - + incomeRequiredComponents.netTaxableGains ) ); @@ -936,6 +950,7 @@ export function calculateYear( incomeOffsetAmount, maxIncomeOffsetCapacity, incomeRequiredForFullUtilization, + incomeRequiredComponents, gainEventAmount: safeNumber(gainEventAmount), gainEventTax, gainEventTaxWithoutStrategy, diff --git a/src/calculations/sensitivity.ts b/src/calculations/sensitivity.ts index 55e6d69..75007f7 100644 --- a/src/calculations/sensitivity.ts +++ b/src/calculations/sensitivity.ts @@ -623,16 +623,24 @@ function calculateYearWithSensitivity( usableOrdinaryLoss + nolUsed + capitalLossUsedAgainstIncome ); - // Minimum W-2 income to fully utilize this year's shelter (see core.ts) + // Minimum W-2 income to fully utilize this year's shelter (see core.ts). + // The grid has no gain events, so the netted-gains term is ST/LT only. const nolLimitForRequired = settings.nolOffsetLimit ?? NOL_OFFSET_PERCENTAGE; + const incomeRequiredComponents = { + section461Deduction: safeNumber(allowedOrdinaryLoss), + capitalLossUsed: safeNumber(capitalLossUsedAgainstIncome), + startOfYearNol: safeNumber(nolCarryforward), + nolLimit: nolLimitForRequired, + nolGrossUp: safeNumber(nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0), + netTaxableGains: safeNumber(taxableSt + taxableLt), + }; const incomeRequiredForFullUtilization = safeNumber( Math.max( 0, - allowedOrdinaryLoss + - capitalLossUsedAgainstIncome + - (nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0) - - taxableSt - - taxableLt + incomeRequiredComponents.section461Deduction + + incomeRequiredComponents.capitalLossUsed + + incomeRequiredComponents.nolGrossUp - + incomeRequiredComponents.netTaxableGains ) ); @@ -671,6 +679,7 @@ function calculateYearWithSensitivity( incomeOffsetAmount, maxIncomeOffsetCapacity, incomeRequiredForFullUtilization, + incomeRequiredComponents, gainEventAmount: 0, gainEventTax: 0, gainEventTaxWithoutStrategy: 0, diff --git a/src/index.css b/src/index.css index 9081d23..8beb1c4 100644 --- a/src/index.css +++ b/src/index.css @@ -3878,6 +3878,44 @@ tfoot td { margin-right: 0.5rem; } +/* Per-term breakdown of the "Your value" figure (e.g. the income-required + formula split into its §461(l) / $3K / NOL / netted-gains components). */ +.field-popup .field-breakdown { + margin: 0 0 0.875rem 0; + padding: 0.5rem 0.875rem; + background: var(--bg); + border-radius: 6px; +} + +.field-popup .field-breakdown-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + padding: 0.25rem 0; +} + +.field-popup .field-breakdown-row + .field-breakdown-row { + border-top: 1px solid var(--border); +} + +.field-popup .field-breakdown-row dt { + color: var(--text-light); + font-size: 0.8rem; +} + +.field-popup .field-breakdown-row dd { + margin: 0; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.field-popup .field-breakdown-row dd.negative { + color: var(--danger, #dc2626); +} + .field-popup .field-impact { margin: 0; padding: 0.75rem 1rem; diff --git a/src/lossBreakdown.test.ts b/src/lossBreakdown.test.ts index 111e5c2..892e700 100644 --- a/src/lossBreakdown.test.ts +++ b/src/lossBreakdown.test.ts @@ -41,6 +41,14 @@ function makeYear(overrides: Partial = {}): YearResult { qfafCashReturned: 0, financingCostPaid: 0, incomeRequiredForFullUtilization: 0, + incomeRequiredComponents: { + section461Deduction: 0, + capitalLossUsed: 0, + startOfYearNol: 0, + nolLimit: 0.8, + nolGrossUp: 0, + netTaxableGains: 0, + }, gainEventAmount: 0, gainEventTax: 0, gainEventTaxWithoutStrategy: 0, diff --git a/src/taxData.ts b/src/taxData.ts index 987d0e3..871ae6b 100644 --- a/src/taxData.ts +++ b/src/taxData.ts @@ -3,20 +3,11 @@ import { CalculatorInputs } from './types'; // All 50 US States + DC with 2026 top marginal income tax rates // Sources: Tax Foundation, state tax authorities // Note: Rates shown are top marginal rates for high-income earners +// Display order: alphabetical by name, with the custom "Other" entry pinned +// last. States with a 0 rate levy no income tax (e.g. FL, TX, WA). export const STATES = [ - // No income tax states - { code: 'AK', name: 'Alaska', rate: 0 }, - { code: 'FL', name: 'Florida', rate: 0 }, - { code: 'NV', name: 'Nevada', rate: 0 }, - { code: 'NH', name: 'New Hampshire', rate: 0 }, // No tax on earned income (only interest/dividends, phasing out) - { code: 'SD', name: 'South Dakota', rate: 0 }, - { code: 'TN', name: 'Tennessee', rate: 0 }, - { code: 'TX', name: 'Texas', rate: 0 }, - { code: 'WA', name: 'Washington', rate: 0 }, // No income tax (has capital gains tax of 7% on gains > $270k) - { code: 'WY', name: 'Wyoming', rate: 0 }, - - // States with income tax (alphabetical) { code: 'AL', name: 'Alabama', rate: 0.05 }, + { code: 'AK', name: 'Alaska', rate: 0 }, { code: 'AZ', name: 'Arizona', rate: 0.025 }, // Flat rate as of 2023 { code: 'AR', name: 'Arkansas', rate: 0.039 }, // Reduced in 2026 { code: 'CA', name: 'California', rate: 0.133 }, // Highest in nation (12.3% + 1% mental health surtax >$1M) @@ -24,6 +15,7 @@ export const STATES = [ { code: 'CT', name: 'Connecticut', rate: 0.0699 }, { code: 'DE', name: 'Delaware', rate: 0.066 }, { code: 'DC', name: 'District of Columbia', rate: 0.1075 }, + { code: 'FL', name: 'Florida', rate: 0 }, { code: 'GA', name: 'Georgia', rate: 0.0519 }, // Reduced from 5.39% mid-2025 { code: 'HI', name: 'Hawaii', rate: 0.11 }, { code: 'ID', name: 'Idaho', rate: 0.058 }, // Flat rate @@ -42,6 +34,8 @@ export const STATES = [ { code: 'MO', name: 'Missouri', rate: 0.048 }, // Reduced from 4.95% { code: 'MT', name: 'Montana', rate: 0.059 }, // Reduced from 6.75% { code: 'NE', name: 'Nebraska', rate: 0.0455 }, // Reduced from 5.2% in 2026 + { code: 'NV', name: 'Nevada', rate: 0 }, + { code: 'NH', name: 'New Hampshire', rate: 0 }, // No tax on earned income (only interest/dividends, phasing out) { code: 'NJ', name: 'New Jersey', rate: 0.1075 }, { code: 'NM', name: 'New Mexico', rate: 0.059 }, { code: 'NY', name: 'New York', rate: 0.109 }, // Top rate with temporary high-earner surtax extended @@ -53,11 +47,16 @@ export const STATES = [ { code: 'PA', name: 'Pennsylvania', rate: 0.0307 }, // Flat rate { code: 'RI', name: 'Rhode Island', rate: 0.0599 }, { code: 'SC', name: 'South Carolina', rate: 0.064 }, // Being phased down + { code: 'SD', name: 'South Dakota', rate: 0 }, + { code: 'TN', name: 'Tennessee', rate: 0 }, + { code: 'TX', name: 'Texas', rate: 0 }, { code: 'UT', name: 'Utah', rate: 0.0465 }, // Flat rate { code: 'VT', name: 'Vermont', rate: 0.0875 }, { code: 'VA', name: 'Virginia', rate: 0.0575 }, + { code: 'WA', name: 'Washington', rate: 0 }, // No income tax (has capital gains tax of 7% on gains > $270k) { code: 'WV', name: 'West Virginia', rate: 0.047 }, // Reduced from 5.12% { code: 'WI', name: 'Wisconsin', rate: 0.0765 }, + { code: 'WY', name: 'Wyoming', rate: 0 }, // Custom entry option { code: 'OTHER', name: 'Other (enter rate)', rate: 0 }, diff --git a/src/types.ts b/src/types.ts index a08dae3..f7150e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -196,6 +196,21 @@ export interface YearResult { // deduction AND lets the 80% limit consume the entire start-of-year NOL // balance (planning target for bonuses/option exercises) incomeRequiredForFullUtilization: number; + /** + * Decomposition of `incomeRequiredForFullUtilization` into the literal terms + * of its formula (§461(l) deduction + $3K used + start-of-year NOL ÷ limit − + * net taxable gains). Surfaced in the metric's info popup so the planning + * target is auditable. Not a separate analysis — these are the same values + * core.ts nets into the headline figure. + */ + incomeRequiredComponents: { + section461Deduction: number; + capitalLossUsed: number; + startOfYearNol: number; + nolLimit: number; + nolGrossUp: number; + netTaxableGains: number; + }; // Tax benefit breakdown (gross benefits and costs) ordinaryLossBenefit: number; // usableOrdinaryLoss × combined ST rate diff --git a/src/workspace/WorkspaceTab.tsx b/src/workspace/WorkspaceTab.tsx index 3dddda4..951d716 100644 --- a/src/workspace/WorkspaceTab.tsx +++ b/src/workspace/WorkspaceTab.tsx @@ -837,6 +837,33 @@ export function WorkspaceTab() { { amount: 0, year: 1 } ); const incomeReqYears = results.years.filter(y => y.incomeRequiredForFullUtilization > 0.5); + // Decompose the peak income-required figure into its formula terms for the + // info popup, straight from the engine (no parallel calc) so advisors can see + // where the planning target comes from. + const peakIncomeReqYear = results.years.find(y => y.year === peakIncomeReq.year); + const incomeReqBreakdown = peakIncomeReqYear + ? [ + { + label: '§461(l) Deduction', + value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.section461Deduction), + }, + { + label: '$3K Used', + value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.capitalLossUsed), + }, + { + label: `Start-of-Year NOL (${formatCurrency( + peakIncomeReqYear.incomeRequiredComponents.startOfYearNol + )}) ÷ ${formatPercent(peakIncomeReqYear.incomeRequiredComponents.nolLimit, 0)}`, + value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.nolGrossUp), + }, + { + label: 'Net Taxable ST/LT Gains', + value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.netTaxableGains), + negative: true, + }, + ] + : undefined; const year1 = results.years[0]?.taxSavings ?? 0; const year2 = results.years[1]?.taxSavings ?? 0; const projectionYears = settings.projectionYears ?? 10; @@ -2079,6 +2106,7 @@ export function WorkspaceTab() { Income to Fully Utilize