Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/InfoPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InfoText
contentKey="col-income-required"
currentValue="$1,934,500"
breakdown={[
{ label: '§461(l) Deduction', value: '$512,000' },
{ label: '$3K Used', value: '$3,000' },
{ label: 'Start-of-Year NOL ($1,000,000) ÷ 80%', value: '$1,250,000' },
{ label: 'Net Taxable ST/LT Gains', value: '$24,000', negative: true },
]}
>
Income to Fully Utilize
</InfoText>
);

// 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(
<InfoText contentKey="col-income-required" currentValue="$100,000">
Income to Fully Utilize
</InfoText>
);
fireEvent.click(screen.getByText('Income to Fully Utilize'));
expect(screen.getByText('$100,000')).toBeInTheDocument();
expect(document.querySelector('.field-breakdown')).toBeNull();
});
});
105 changes: 69 additions & 36 deletions src/InfoPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="field-popup">
<p className="field-definition">{content.definition}</p>
{content.formula && (
<div className="field-formula">
<strong>Formula:</strong>
<code>{content.formula}</code>
</div>
)}
{currentValue && (
<div className="field-current-value">
<strong>Your value:</strong> {currentValue}
</div>
)}
{breakdown && breakdown.length > 0 && (
<dl className="field-breakdown">
{breakdown.map(item => (
<div key={item.label} className="field-breakdown-row">
<dt>{item.label}</dt>
<dd className={item.negative ? 'negative' : undefined}>
{item.negative ? '−' : ''}
{item.value}
</dd>
</div>
))}
</dl>
)}
<p className="field-impact">
<strong>Impact:</strong> {content.impact}
</p>
</div>
);
}

// 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 (
<InfoPopup title={content.title}>
<div className="field-popup">
<p className="field-definition">{content.definition}</p>
{content.formula && (
<div className="field-formula">
<strong>Formula:</strong>
<code>{content.formula}</code>
</div>
)}
{currentValue && (
<div className="field-current-value">
<strong>Your value:</strong> {currentValue}
</div>
)}
<p className="field-impact">
<strong>Impact:</strong> {content.impact}
</p>
</div>
<FieldPopupBody content={content} currentValue={currentValue} breakdown={breakdown} />
</InfoPopup>
);
}
Expand All @@ -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);

Expand Down Expand Up @@ -130,23 +175,11 @@ export function InfoText({ contentKey, children, currentValue }: InfoTextProps)
</button>
</div>
<div className="popup-body">
<div className="field-popup">
<p className="field-definition">{content.definition}</p>
{content.formula && (
<div className="field-formula">
<strong>Formula:</strong>
<code>{content.formula}</code>
</div>
)}
{currentValue && (
<div className="field-current-value">
<strong>Your value:</strong> {currentValue}
</div>
)}
<p className="field-impact">
<strong>Impact:</strong> {content.impact}
</p>
</div>
<FieldPopupBody
content={content}
currentValue={currentValue}
breakdown={breakdown}
/>
</div>
</div>
</div>,
Expand Down
16 changes: 16 additions & 0 deletions src/calculations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@
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', () => {
Expand Down Expand Up @@ -877,7 +893,7 @@
// Net ST gains: fed 40.8% + MA 12.5% (8.5% + 4% surtax)
const leakYear = result.years.find(y => y.netStGainLoss > 1000);
expect(leakYear).toBeDefined();
expect(leakYear!.remainingStGainCost).toBeCloseTo(leakYear!.netStGainLoss * (0.408 + 0.125), 0);

Check warning on line 896 in src/calculations.test.ts

View workflow job for this annotation

GitHub Actions / checks

Forbidden non-null assertion

Check warning on line 896 in src/calculations.test.ts

View workflow job for this annotation

GitHub Actions / checks

Forbidden non-null assertion
});

it('applies the WA 7% LTCG excise above the annual exemption', () => {
Expand Down Expand Up @@ -964,8 +980,8 @@
expect(yearWithNol).toBeDefined();

const expectedCombinedOrdinaryRate = 0.37 + 0.133; // top bracket + CA, NO NIIT
expect(yearWithNol!.nolUsageBenefit).toBeCloseTo(

Check warning on line 983 in src/calculations.test.ts

View workflow job for this annotation

GitHub Actions / checks

Forbidden non-null assertion
yearWithNol!.nolUsedThisYear * expectedCombinedOrdinaryRate,

Check warning on line 984 in src/calculations.test.ts

View workflow job for this annotation

GitHub Actions / checks

Forbidden non-null assertion
2
);
});
Expand All @@ -979,8 +995,8 @@
expect(leakYear).toBeDefined();

const expectedCombinedStRate = 0.32 + 0.038 + 0.133; // bracket + NIIT + CA
expect(leakYear!.remainingStGainCost).toBeCloseTo(

Check warning on line 998 in src/calculations.test.ts

View workflow job for this annotation

GitHub Actions / checks

Forbidden non-null assertion
leakYear!.netStGainLoss * expectedCombinedStRate,

Check warning on line 999 in src/calculations.test.ts

View workflow job for this annotation

GitHub Actions / checks

Forbidden non-null assertion
2
);
});
Expand Down
29 changes: 22 additions & 7 deletions src/calculations/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
);

Expand Down Expand Up @@ -936,6 +950,7 @@ export function calculateYear(
incomeOffsetAmount,
maxIncomeOffsetCapacity,
incomeRequiredForFullUtilization,
incomeRequiredComponents,
gainEventAmount: safeNumber(gainEventAmount),
gainEventTax,
gainEventTaxWithoutStrategy,
Expand Down
21 changes: 15 additions & 6 deletions src/calculations/sensitivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
);

Expand Down Expand Up @@ -671,6 +679,7 @@ function calculateYearWithSensitivity(
incomeOffsetAmount,
maxIncomeOffsetCapacity,
incomeRequiredForFullUtilization,
incomeRequiredComponents,
gainEventAmount: 0,
gainEventTax: 0,
gainEventTaxWithoutStrategy: 0,
Expand Down
38 changes: 38 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/lossBreakdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ function makeYear(overrides: Partial<YearResult> = {}): 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,
Expand Down
Loading
Loading