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
24 changes: 21 additions & 3 deletions src/lib/ai-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
MAX_COLA_YEAR,
MAX_WAGE_INDEX_YEAR,
} from '$lib/constants';
import type { EarningRecord } from '$lib/earning-record';
import { MonthDuration } from '$lib/month-time';
import type { Recipient } from '$lib/recipient';
import { buildStrategyHash, type Gender } from '$lib/url-params';
Expand Down Expand Up @@ -116,6 +117,23 @@ function nameParam(name: string, defaultName: string): string | undefined {
// Per-recipient sections (reused by the single and couple exports)
// ---------------------------------------------------------------------------

/**
* All earnings the AIME is computed from, in year order: the recorded
* (historical) years plus any estimated future years the user added. The AIME
* total blends both (EarningsManager concatenates them before taking the top
* 35), so every table derived from the earnings history must show both — or the
* printed rows can't account for the printed AIME. Future years always fall
* after historical ones; the sort is defensive.
*/
function allEarningsRecords(
recipient: Recipient
): ReadonlyArray<EarningRecord> {
return [
...recipient.earningsRecords,
...recipient.futureEarningsRecords,
].sort((a, b) => a.year - b.year);
}

/** Eligibility: 40 work credits, with a year-by-year breakdown until reached. */
function eligibilitySection(recipient: Recipient): string {
const lines = [
Expand All @@ -141,7 +159,7 @@ function eligibilitySection(recipient: Recipient): string {
// for eligibility).
const rows: string[][] = [];
let cumulative = 0;
for (const record of recipient.earningsRecords) {
for (const record of allEarningsRecords(recipient)) {
if (cumulative >= 40) break;
cumulative = Math.min(40, cumulative + record.credits());
rows.push([
Expand Down Expand Up @@ -187,7 +205,7 @@ function aimeSection(recipient: Recipient): string {
'',
];

const rows = recipient.earningsRecords.map((record) => [
const rows = allEarningsRecords(recipient).map((record) => [
String(record.year),
record.taxedEarnings.wholeDollars(),
record.indexFactor().toFixed(4),
Expand Down Expand Up @@ -477,7 +495,7 @@ const EMBED_BASE = 'https://ssa.tools/embed';

/** Encodes a recipient's earnings history as the `earnings1` URL parameter. */
function earningsParam(recipient: Recipient): string {
return recipient.earningsRecords
return allEarningsRecords(recipient)
.map((r) => `${r.year}:${Math.round(r.taxedEarnings.value())}`)
.join(',');
}
Expand Down
52 changes: 51 additions & 1 deletion src/test/ai-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,22 @@ describe('buildCalculatorAiExport (single, earnings-based)', () => {
// Self-verify the fixture sits in the middle branch.
expect(r.earnedCredits()).toBeLessThan(40);
expect(r.totalCredits()).toBeGreaterThanOrEqual(40);
expect(buildCalculatorAiExport(r)).toMatch(/projected to reach 40/i);
const md = buildCalculatorAiExport(r);
expect(md).toMatch(/projected to reach 40/i);

// The credit table must extend into the estimated future years backing
// that projection, and stop once the cumulative count reaches 40.
const rows = tableLines(
md,
'| Year | Taxed earnings | Credits | Cumulative |'
);
const dataRows = rows.slice(2);
const futureYear = String(r.futureEarningsRecords[0].year);
expect(
dataRows.some((row) => row.split('|')[1].trim() === futureYear)
).toBe(true);
const lastCumulative = dataRows[dataRows.length - 1].split('|')[4].trim();
expect(lastCumulative).toBe('40');
});

it('does not throw for a recipient with no earnings records', () => {
Expand All @@ -205,6 +220,41 @@ describe('buildCalculatorAiExport (single, earnings-based)', () => {
expect(md).toContain(r.monthlyIndexedEarnings().wholeDollars());
});

it('lists estimated future-earnings years in the AIME table (issue #553)', () => {
// Regression: estimated (future) earnings feed the AIME total but were
// omitted from the indexed-earnings table, so the visible rows could not
// account for the printed AIME. The table must show every year the AIME
// is computed from, historical and estimated alike.
const r = eligibleRecipient(); // 1990-2024 historical
r.simulateFutureEarningsYears(3, Money.from(70000));
const futureYears = r.futureEarningsRecords.map((rec) => rec.year);
expect(futureYears.length).toBe(3); // fixture self-check
expect(Math.min(...futureYears)).toBeGreaterThan(2024);

const md = buildCalculatorAiExport(r);
// Isolate the AIME section so we don't match the eligibility credit table,
// which shares the leading "| Year | Taxed earnings |" columns.
const aime = md.slice(
md.indexOf('## Average Indexed Monthly Earnings'),
md.indexOf('## Primary Insurance Amount')
);
const rows = tableLines(aime, '| Year | Taxed earnings | Index factor');
const dataRows = rows.slice(2); // drop header + separator
const yearCells = dataRows.map((row) => row.split('|')[1].trim());

// One row per combined earnings year, and every estimated year present.
expect(dataRows.length).toBe(
r.earningsRecords.length + r.futureEarningsRecords.length
);
for (const year of futureYears) {
expect(yearCells).toContain(String(year));
}

// The chart-embed earnings1= param must carry the same combined series,
// or the linked interactive chart would show a different AIME.
expect(md).toContain(`${futureYears[0]}:70000`);
});

it('includes the worked PIA bend-point formula', () => {
const r = eligibleRecipient();
const md = buildCalculatorAiExport(r);
Expand Down
Loading