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