diff --git a/src/lib/child-benefit-example.ts b/src/lib/child-benefit-example.ts new file mode 100644 index 00000000..c967e62f --- /dev/null +++ b/src/lib/child-benefit-example.ts @@ -0,0 +1,306 @@ +/** + * Worked examples for the child benefits guide. + * + * The guide follows one parent who turns 62 in the latest year the + * constants cover, with a round Primary Insurance Amount and children of + * given ages. From that it shows how the family maximum splits a fixed pool + * among however many people collect on the record, and what the family + * gives up or gains if the parent claims at 62 instead of waiting for full + * retirement age. + * + * Every figure here is derived from constants.ts and the benefit + * calculator, so the guide's tables follow the annual update. The children's + * ages, the example PIA, and any sentence in the guide naming a percentage + * or a ratio between rows are hand-written and still need a read each year. + * + * The result records below are plain readonly interfaces produced only by + * the functions beside them; they are not meant to be constructed by hand. + */ + +import { benefitAtAge, survivorBenefit } from './benefit-calculator'; +import { Birthdate } from './birthday'; +import * as constants from './constants'; +import { + type FamilyMaximumBendPoints, + familyMaximum, + familyMaximumBendPoints, +} from './family-maximum'; +import { Money } from './money'; +import { MonthDuration } from './month-time'; +import { Recipient } from './recipient'; + +/** A round Primary Insurance Amount the guide can reason about aloud. */ +export const EXAMPLE_PIA: Money = Money.from(2500); + +/** Age of the example parent in the guide's year. */ +const EXAMPLE_PARENT_AGE = 62; + +/** A child's benefit is half the parent's PIA before the family maximum. */ +const CHILD_BENEFIT_FRACTION = 0.5; + +/** Child benefits end at 18 for a child who is not a student or disabled. */ +const CHILD_BENEFIT_END_AGE = MonthDuration.initFromYearsMonths({ + years: 18, + months: 0, +}); + +/** The claiming age the comparison tests against full retirement age. */ +const EARLY_CLAIM_AGE = MonthDuration.initFromYearsMonths({ + years: EXAMPLE_PARENT_AGE, + months: 0, +}); + +/** + * An age at which the example parent has died, used only to place the death + * after both filing ages compared and the spouse past survivor full + * retirement age, so the survivor figure is the unreduced base amount. + */ +const SURVIVOR_EXAMPLE_DEATH_AGE = MonthDuration.initFromYearsMonths({ + years: 75, + months: 0, +}); + +/** + * A parent who turns 62 in the latest year the constants cover, so the + * family maximum is computed from the latest wage index (this relies on + * WAGE_INDICES covering MAX_YEAR - 2) and no cost-of-living adjustment has + * yet applied. Born on the 2nd of the month: only someone born on the 1st + * or 2nd is 62 for their whole birth month, which the comparison's claim + * at exactly 62 requires. The birth year gives a full retirement age of 67, + * which the guide states in prose. + */ +export function exampleParent(): Recipient { + const parent = new Recipient(); + parent.birthdate = Birthdate.FromYMD( + constants.MAX_YEAR - EXAMPLE_PARENT_AGE, + 0, + 2 + ); + parent.setPia(EXAMPLE_PIA); + return parent; +} + +export interface FamilyPayout { + readonly pia: Money; + readonly bendPoints: FamilyMaximumBendPoints; + readonly familyMaximum: Money; + /** What the maximum leaves for everyone other than the worker. */ + readonly auxiliaryPool: Money; + /** People sharing the pool: children, plus a spouse with a child in care. */ + readonly beneficiaries: number; + /** Each person's benefit if the maximum did not bind: half the PIA. */ + readonly eachUnlimited: Money; + /** Each person's benefit after the maximum, rounded down to the dime. */ + readonly each: Money; + /** + * The worker's unreduced PIA plus everyone collecting on the record. The + * worker's own early-claim reduction is not applied here; the guide's + * table note says so. + */ + readonly familyTotal: Money; + /** Whether the maximum bound, i.e. `each` is below `eachUnlimited`. */ + readonly capped: boolean; +} + +/** + * How the family maximum divides the record among `beneficiaries` people + * collecting on it besides the worker. The worker's own benefit is never + * reduced; the others share whatever the maximum leaves, equally. + */ +export function familyPayout( + parent: Recipient, + beneficiaries: number +): FamilyPayout { + if (!Number.isInteger(beneficiaries) || beneficiaries < 1) { + throw new Error( + `beneficiaries must be a positive integer, got ${beneficiaries}` + ); + } + const pia = parent.pia().primaryInsuranceAmount(); + const indexingYear = parent.indexingYear(); + const maximum = familyMaximum(pia, indexingYear); + const auxiliaryPool = maximum.sub(pia); + if (auxiliaryPool.lessThan(Money.from(0))) { + throw new Error( + `family maximum ${maximum.string()} is below the PIA ${pia.string()}` + ); + } + const eachUnlimited = pia.times(CHILD_BENEFIT_FRACTION).floorToDime(); + const share = auxiliaryPool.div(beneficiaries).floorToDime(); + const capped = share.lessThan(eachUnlimited); + const each = capped ? share : eachUnlimited; + return { + pia, + bendPoints: familyMaximumBendPoints(indexingYear), + familyMaximum: maximum, + auxiliaryPool, + beneficiaries, + eachUnlimited, + each, + familyTotal: pia.plus(each.times(beneficiaries)), + capped, + }; +} + +export interface ClaimAgeComparison { + /** The children's ages in whole years at the claim, as given. */ + readonly childAges: readonly number[]; + readonly fra: MonthDuration; + readonly benefitAt62: Money; + readonly benefitAtFra: Money; + /** The permanent monthly cost of claiming at 62 instead of at FRA. */ + readonly monthlyReduction: Money; + readonly timeToFra: MonthDuration; + /** From the claim until the youngest child turns 18. */ + readonly childBenefitSpan: MonthDuration; + /** From the claim until the oldest child turns 18. */ + readonly oldestChildBenefitSpan: MonthDuration; + readonly parentAgeWhenOldestTurns18: MonthDuration; + readonly parentAgeWhenYoungestTurns18: MonthDuration; + /** + * Everything the children collect if the parent claims at 62, from then + * until the youngest turns 18. Nominal: no cost-of-living adjustments. + */ + readonly childrenTotal: Money; + /** The part of childrenTotal paid before the parent reaches FRA. */ + readonly childrenBeforeFra: Money; + /** The parent's own reduced checks before FRA. */ + readonly ownBeforeFra: Money; + /** + * How far ahead the family is at FRA by having claimed at 62. After FRA + * the children collect the same amounts either way, so the only + * difference from then on is the parent's reduced check. + */ + readonly leadAtFra: Money; + /** Time after FRA for the larger check to make up the lead. */ + readonly timeToCatchUp: MonthDuration; + readonly catchUpAge: MonthDuration; + /** The base survivor benefit a spouse would inherit, by the parent's claim age. */ + readonly survivorIfClaimedAt62: Money; + readonly survivorIfClaimedAtFra: Money; +} + +function validateChildAges(childAges: readonly number[]): void { + if (childAges.length === 0) { + throw new Error('at least one child is required'); + } + for (const age of childAges) { + if ( + !Number.isInteger(age) || + age < 0 || + age >= CHILD_BENEFIT_END_AGE.years() + ) { + throw new Error( + `child ages must be whole years under ${CHILD_BENEFIT_END_AGE.years()}, got ${age}` + ); + } + } +} + +function ageInYears(years: number): MonthDuration { + return MonthDuration.initFromYearsMonths({ years, months: 0 }); +} + +/** How long a child of this age keeps collecting, until they turn 18. */ +function benefitSpan(childAge: number): MonthDuration { + return CHILD_BENEFIT_END_AGE.subtract(ageInYears(childAge)); +} + +/** The children still under 18 at `elapsed` after the claim. */ +function childrenStillEligible( + childAges: readonly number[], + elapsed: MonthDuration +): number { + return childAges.filter((age) => elapsed.lessThan(benefitSpan(age))).length; +} + +/** + * The base survivor benefit a same-age spouse would receive if the parent + * claimed at `claimAge` and died at SURVIVOR_EXAMPLE_DEATH_AGE. + */ +function survivorAfterClaimAt( + parent: Recipient, + claimAge: MonthDuration +): Money { + const spouse = new Recipient(); + spouse.birthdate = parent.birthdate; + const filingDate = parent.birthdate.dateAtSsaAge(claimAge); + const deathDate = parent.birthdate.dateAtSsaAge(SURVIVOR_EXAMPLE_DEATH_AGE); + const survivorFilingDate = deathDate.addDuration(new MonthDuration(1)); + return survivorBenefit( + spouse, + parent, + filingDate, + deathDate, + survivorFilingDate + ); +} + +export function claimAgeComparison( + parent: Recipient, + childAges: readonly number[] +): ClaimAgeComparison { + validateChildAges(childAges); + const ages = [...childAges]; + if (parent.birthdate.earliestFilingMonth().greaterThan(EARLY_CLAIM_AGE)) { + throw new Error( + 'the comparison claims at exactly 62, which this parent cannot do' + ); + } + const fra = parent.normalRetirementAge(); + const benefitAt62 = benefitAtAge(parent, EARLY_CLAIM_AGE); + const benefitAtFra = benefitAtAge(parent, fra); + const monthlyReduction = benefitAtFra.sub(benefitAt62); + if (!monthlyReduction.greaterThan(Money.from(0))) { + throw new Error( + 'the comparison needs a parent whose benefit is reduced at 62' + ); + } + const timeToFra = fra.subtract(EARLY_CLAIM_AGE); + + // Each child's benefit depends on how many children are still under 18, + // so the family's monthly payment is recomputed as children age out. + const childBenefitSpan = benefitSpan(Math.min(...ages)); + const oldestChildBenefitSpan = benefitSpan(Math.max(...ages)); + let childrenTotal = Money.from(0); + let childrenBeforeFra = Money.from(0); + for ( + const elapsed = new MonthDuration(0); + elapsed.lessThan(childBenefitSpan); + elapsed.increment() + ) { + const eligible = childrenStillEligible(ages, elapsed); + const payment = familyPayout(parent, eligible).each.times(eligible); + childrenTotal = childrenTotal.plus(payment); + if (elapsed.lessThan(timeToFra)) { + childrenBeforeFra = childrenBeforeFra.plus(payment); + } + } + + const ownBeforeFra = benefitAt62.times(timeToFra.asMonths()); + const leadAtFra = ownBeforeFra.plus(childrenBeforeFra); + const timeToCatchUp = new MonthDuration( + Math.ceil(leadAtFra.div$(monthlyReduction)) + ); + + return { + childAges: ages, + fra, + benefitAt62, + benefitAtFra, + monthlyReduction, + timeToFra, + childBenefitSpan, + oldestChildBenefitSpan, + parentAgeWhenOldestTurns18: EARLY_CLAIM_AGE.add(oldestChildBenefitSpan), + parentAgeWhenYoungestTurns18: EARLY_CLAIM_AGE.add(childBenefitSpan), + childrenTotal, + childrenBeforeFra, + ownBeforeFra, + leadAtFra, + timeToCatchUp, + catchUpAge: fra.add(timeToCatchUp), + survivorIfClaimedAt62: survivorAfterClaimAt(parent, EARLY_CLAIM_AGE), + survivorIfClaimedAtFra: survivorAfterClaimAt(parent, fra), + }; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6ccffe28..f9b23599 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -294,6 +294,28 @@ export const BEFORE_BENDPOINT2_MULTIPLIER: number = 0.32; /* PIA formula multiplier after the second bend point. */ export const AFTER_BENDPOINT2_MULTIPLIER: number = 0.15; +// Values from https://www.ssa.gov/oact/cola/familymax.html +// +// The family maximum formula has three bend points. Like the PIA bend +// points above, they are the 1979 dollar amounts scaled by the ratio of the +// average wage index in the worker's indexing year to the index in 1977. + +/* The first bend in dollars (1979 base, indexed on 1977 wages) for the + * family maximum formula. */ +export const FAMILY_MAX_BENDPOINT1_IN_1977: Money = Money.from(230.0); +/* The second bend for the family maximum formula. */ +export const FAMILY_MAX_BENDPOINT2_IN_1977: Money = Money.from(332.0); +/* The third bend for the family maximum formula. */ +export const FAMILY_MAX_BENDPOINT3_IN_1977: Money = Money.from(433.0); +/* Family maximum multiplier on the PIA up to the first bend point. */ +export const FAMILY_MAX_MULTIPLIER_BRACKET1: number = 1.5; +/* Family maximum multiplier between the first and second bend points. */ +export const FAMILY_MAX_MULTIPLIER_BRACKET2: number = 2.72; +/* Family maximum multiplier between the second and third bend points. */ +export const FAMILY_MAX_MULTIPLIER_BRACKET3: number = 1.34; +/* Family maximum multiplier above the third bend point. */ +export const FAMILY_MAX_MULTIPLIER_BRACKET4: number = 1.75; + /** * The average wage index for each year, in dollars. */ diff --git a/src/lib/family-maximum.ts b/src/lib/family-maximum.ts new file mode 100644 index 00000000..6c74a478 --- /dev/null +++ b/src/lib/family-maximum.ts @@ -0,0 +1,77 @@ +/** + * The family maximum: the most Social Security pays each month on one + * worker's record to the worker and everyone collecting on it together. + * + * Like the PIA formula, the maximum is a piecewise-linear function of the + * PIA whose bend points are fixed dollar amounts from 1979, scaled by the + * growth in the average wage index between 1977 and the worker's indexing + * year (the year they turn 60). Social Security describes the formula at + * https://www.ssa.gov/oact/cola/familymax.html. + * + * Both the PIA and the maximum are set when the worker turns 62 and then + * rise together with each cost-of-living adjustment, so the ratio between + * them is fixed from then on. This module computes the maximum for a given + * PIA and indexing year; it does not apply later COLAs. + */ + +import * as constants from './constants'; +import { Money } from './money'; +import { wageIndexRatio } from './pia'; + +/** + * The three dollar thresholds of the family maximum formula, in ascending + * order. Produced by familyMaximumBendPoints; not meant to be built by hand. + */ +export interface FamilyMaximumBendPoints { + readonly first: Money; + readonly second: Money; + readonly third: Money; +} + +export function familyMaximumBendPoints( + indexingYear: number +): FamilyMaximumBendPoints { + const ratio = wageIndexRatio(indexingYear); + const bends = { + first: constants.FAMILY_MAX_BENDPOINT1_IN_1977.times(ratio).roundToDollar(), + second: + constants.FAMILY_MAX_BENDPOINT2_IN_1977.times(ratio).roundToDollar(), + third: constants.FAMILY_MAX_BENDPOINT3_IN_1977.times(ratio).roundToDollar(), + }; + // familyMaximum slices the PIA by these in order; a constants edit that + // reordered them would silently produce negative slices. + if ( + !bends.first.lessThan(bends.second) || + !bends.second.lessThan(bends.third) + ) { + throw new Error( + `family maximum bend points must ascend, got ${bends.first.string()}, ` + + `${bends.second.string()}, ${bends.third.string()}` + ); + } + return bends; +} + +/** + * The family maximum for a worker with the given PIA, before any + * cost-of-living adjustment. Rounded down to the dime, as the PIA is. + */ +export function familyMaximum(pia: Money, indexingYear: number): Money { + if (!pia.greaterThan(Money.from(0))) { + throw new Error(`PIA must be positive, got ${pia.string()}`); + } + const bends = familyMaximumBendPoints(indexingYear); + // Each slice is the part of the PIA inside one bracket. Capping at the + // bend point and subtracting the lower slices leaves zero, never a + // negative, for brackets the PIA does not reach. + const inFirst = Money.min(pia, bends.first); + const inSecond = Money.min(pia, bends.second).sub(inFirst); + const inThird = Money.min(pia, bends.third).sub(inFirst).sub(inSecond); + const aboveThird = pia.sub(inFirst).sub(inSecond).sub(inThird); + return inFirst + .times(constants.FAMILY_MAX_MULTIPLIER_BRACKET1) + .plus(inSecond.times(constants.FAMILY_MAX_MULTIPLIER_BRACKET2)) + .plus(inThird.times(constants.FAMILY_MAX_MULTIPLIER_BRACKET3)) + .plus(aboveThird.times(constants.FAMILY_MAX_MULTIPLIER_BRACKET4)) + .floorToDime(); +} diff --git a/src/lib/pia.ts b/src/lib/pia.ts index d1f6328d..c04cc5e2 100644 --- a/src/lib/pia.ts +++ b/src/lib/pia.ts @@ -15,6 +15,25 @@ export interface PiaInput { isEligible(): boolean; } +/** + * The ratio of the average wage in `indexingYear` to the average wage in + * 1977, which scales the fixed 1979 bend points of both the PIA formula and + * the family maximum formula to the worker's own wage era. + * + * An indexing year beyond the wage index data is clamped to the latest year + * available, so a worker who has not yet turned 60 is indexed on the most + * recent index published. + */ +export function wageIndexRatio(indexingYear: number): number { + const effectiveIndexingYear = Math.min( + indexingYear, + constants.MAX_WAGE_INDEX_YEAR + ); + return constants.WAGE_INDICES[effectiveIndexingYear].div$( + constants.WAGE_INDICES[1977] + ); +} + /** * A PrimaryInsuranceAmount object manages calculating the user's Primary * Insurance Amount (PIA). @@ -45,15 +64,7 @@ export class PrimaryInsuranceAmount { * @returns {number} The wage ratio used for indexing calculations */ private wageRatio(): number { - // If the indexing year is beyond the WAGE_INDICES data range, use the - // maximum year in the data range. - const effectiveIndexingYear = Math.min( - this.input_.indexingYear(), - constants.MAX_WAGE_INDEX_YEAR - ); - const wage_in_1977: Money = constants.WAGE_INDICES[1977]; - const wage: Money = constants.WAGE_INDICES[effectiveIndexingYear]; - return wage.div$(wage_in_1977); + return wageIndexRatio(this.input_.indexingYear()); } /** diff --git a/src/routes/guides/+page.svelte b/src/routes/guides/+page.svelte index bb36a74b..eff12a8f 100644 --- a/src/routes/guides/+page.svelte +++ b/src/routes/guides/+page.svelte @@ -26,6 +26,22 @@ const pageImageAlt = 'Social Security guides and educational resources';

Social Security Guides

Dependent Parents (Age 62+)

diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index f73dfdba..229f69e6 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -76,6 +76,7 @@ const pages = [ priority: '0.7', changefreq: 'yearly', }, + { path: '/guides/child-benefits', priority: '0.7', changefreq: 'yearly' }, { path: '/guides/spousal-benefits', priority: '0.7', changefreq: 'yearly' }, { path: '/guides/cola', priority: '0.7', changefreq: 'yearly' }, { path: '/guides/earnings-test', priority: '0.7', changefreq: 'yearly' }, diff --git a/src/test/child-benefit-example.test.ts b/src/test/child-benefit-example.test.ts new file mode 100644 index 00000000..47b370fa --- /dev/null +++ b/src/test/child-benefit-example.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { + claimAgeComparison, + EXAMPLE_PIA, + exampleParent, + familyPayout, +} from '$lib/child-benefit-example'; +import { MAX_YEAR } from '$lib/constants'; +import { Money } from '$lib/money'; + +describe('exampleParent', () => { + it('turns 62 in the latest year the constants cover', () => { + expect(exampleParent().birthdate.yearTurningSsaAge(62)).toBe(MAX_YEAR); + }); + + it('has a full retirement age of exactly 67', () => { + const fra = exampleParent().normalRetirementAge(); + expect(fra.years()).toBe(67); + expect(fra.modMonths()).toBe(0); + }); + + it('can file in the month they turn 62, as the comparison assumes', () => { + // Only someone born on the 1st or 2nd is 62 for the whole birth month. + const earliest = exampleParent().birthdate.earliestFilingMonth(); + expect(earliest.years()).toBe(62); + expect(earliest.modMonths()).toBe(0); + }); + + it('carries the example PIA unchanged', () => { + expect( + exampleParent().pia().primaryInsuranceAmount().equals(EXAMPLE_PIA) + ).toBe(true); + }); +}); + +/** + * The guide renders these exact figures and writes prose around them. When + * a constants update moves them, update this table and re-read the prose in + * child-benefits/+page.svelte together. + */ +describe('familyPayout', () => { + it('leaves one child at the full 50% when the maximum does not bind', () => { + const p = familyPayout(exampleParent(), 1); + expect(p.familyMaximum.string()).toBe('$4,617.50'); + expect(p.eachUnlimited.string()).toBe('$1,250.00'); + expect(p.each.string()).toBe('$1,250.00'); + expect(p.familyTotal.string()).toBe('$3,750.00'); + expect(p.capped).toBe(false); + }); + + it('splits the pool between two children once the maximum binds', () => { + const p = familyPayout(exampleParent(), 2); + expect(p.each.string()).toBe('$1,058.70'); + expect(p.familyTotal.string()).toBe('$4,617.40'); + expect(p.capped).toBe(true); + }); + + it('splits the same pool three ways for three beneficiaries', () => { + // A third child, or a spouse with a child in care, adds nothing to the + // family total; it only divides the pool further. + const p = familyPayout(exampleParent(), 3); + expect(p.each.string()).toBe('$705.80'); + expect(p.familyTotal.string()).toBe('$4,617.40'); + expect(p.capped).toBe(true); + }); + + it('halves a small PIA between two children with nothing left over', () => { + // Below the first bend point the maximum is exactly 150%, so the pool + // is half the PIA: one child takes all of it uncapped, two split it. + const parent = exampleParent(); + parent.setPia(Money.from(1000)); + const one = familyPayout(parent, 1); + expect(one.each.string()).toBe('$500.00'); + expect(one.capped).toBe(false); + const two = familyPayout(parent, 2); + expect(two.each.string()).toBe('$250.00'); + expect(two.capped).toBe(true); + }); + + it('rejects a beneficiary count that is not a positive integer', () => { + expect(() => familyPayout(exampleParent(), 0)).toThrow(/positive/); + expect(() => familyPayout(exampleParent(), 1.5)).toThrow(/positive/); + }); +}); + +describe('claimAgeComparison', () => { + const c = claimAgeComparison(exampleParent(), [9, 12]); + + it('reduces the parent by 30% for claiming five years early', () => { + expect(c.benefitAt62.string()).toBe('$1,750.00'); + expect(c.benefitAtFra.string()).toBe('$2,500.00'); + expect(c.monthlyReduction.string()).toBe('$750.00'); + expect(c.timeToFra.asMonths()).toBe(60); + }); + + it('pays both children until the older turns 18, then the younger alone', () => { + // 72 months of $2,117.40 shared, then 36 months of $1,250.00. + expect(c.childrenTotal.string()).toBe('$197,452.80'); + expect(c.childBenefitSpan.asMonths()).toBe(108); + expect(c.oldestChildBenefitSpan.asMonths()).toBe(72); + }); + + it('reports the parent ages at which each child ages out', () => { + expect(c.parentAgeWhenOldestTurns18.years()).toBe(68); + expect(c.parentAgeWhenOldestTurns18.modMonths()).toBe(0); + expect(c.parentAgeWhenYoungestTurns18.years()).toBe(71); + expect(c.parentAgeWhenYoungestTurns18.modMonths()).toBe(0); + }); + + it('counts what the family collects before full retirement age', () => { + expect(c.ownBeforeFra.string()).toBe('$105,000.00'); + expect(c.childrenBeforeFra.string()).toBe('$127,044.00'); + expect(c.leadAtFra.string()).toBe('$232,044.00'); + }); + + it('finds the age at which the larger check would catch up', () => { + // $232,044 / $750 a month = 309.4 months after 67. + expect(c.timeToCatchUp.asMonths()).toBe(310); + expect(c.catchUpAge.years()).toBe(92); + expect(c.catchUpAge.modMonths()).toBe(10); + }); + + it('comes out close to even for a single child who is already 16', () => { + // 24 months of $1,250 plus 60 months of $1,750 = $135,000, which the + // $750 difference repays in exactly 180 months. The guide cites this. + const teen = claimAgeComparison(exampleParent(), [16]); + expect(teen.leadAtFra.string()).toBe('$135,000.00'); + expect(teen.timeToCatchUp.asMonths()).toBe(180); + expect(teen.catchUpAge.years()).toBe(82); + expect(teen.catchUpAge.modMonths()).toBe(0); + }); + + it('lowers the surviving spouse to the 82.5% floor after an early claim', () => { + // A worker who claimed early leaves a survivor benefit of the larger of + // the reduced check and 82.5% of the PIA, rounded down to the dollar. + expect(c.survivorIfClaimedAt62.string()).toBe('$2,062.00'); + expect(c.survivorIfClaimedAtFra.string()).toBe('$2,500.00'); + }); + + it('pays nothing before full retirement age to a child already 18', () => { + const grown = claimAgeComparison(exampleParent(), [17]); + // Twelve months only, all before full retirement age. + expect(grown.childBenefitSpan.asMonths()).toBe(12); + expect(grown.childrenBeforeFra.equals(grown.childrenTotal)).toBe(true); + }); + + it('rejects children who are not whole ages under 18', () => { + expect(() => claimAgeComparison(exampleParent(), [18])).toThrow(/under/); + expect(() => claimAgeComparison(exampleParent(), [])).toThrow(/child/); + expect(() => claimAgeComparison(exampleParent(), [-1])).toThrow(/under/); + expect(() => claimAgeComparison(exampleParent(), [9, Number.NaN])).toThrow( + /under/ + ); + }); + + it('does not share the caller’s age array', () => { + const ages = [9, 12]; + const result = claimAgeComparison(exampleParent(), ages); + ages.push(3); + expect(result.childAges).toEqual([9, 12]); + }); +}); diff --git a/src/test/family-maximum.test.ts b/src/test/family-maximum.test.ts new file mode 100644 index 00000000..ea5ca721 --- /dev/null +++ b/src/test/family-maximum.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_WAGE_INDEX_YEAR } from '$lib/constants'; +import { familyMaximum, familyMaximumBendPoints } from '$lib/family-maximum'; +import { Money } from '$lib/money'; + +/** + * The published bend points come from https://www.ssa.gov/oact/cola/familymax.html. + * A worker first eligible in year Y uses the wage index from Y - 2, so the + * 2025 figures index on 2023 and the 2026 figures on 2024. + */ +describe('familyMaximumBendPoints', () => { + it('reproduces the 2025 bend points SSA published', () => { + const bends = familyMaximumBendPoints(2023); + expect(bends.first.string()).toBe('$1,567.00'); + expect(bends.second.string()).toBe('$2,262.00'); + expect(bends.third.string()).toBe('$2,950.00'); + }); + + it('reproduces the 2026 bend points SSA published', () => { + const bends = familyMaximumBendPoints(2024); + expect(bends.first.string()).toBe('$1,643.00'); + expect(bends.second.string()).toBe('$2,371.00'); + expect(bends.third.string()).toBe('$3,093.00'); + }); + + it('holds the latest known bend points for indexing years past the data', () => { + const latest = familyMaximumBendPoints(MAX_WAGE_INDEX_YEAR); + const future = familyMaximumBendPoints(MAX_WAGE_INDEX_YEAR + 5); + expect(future.first.equals(latest.first)).toBe(true); + expect(future.second.equals(latest.second)).toBe(true); + expect(future.third.equals(latest.third)).toBe(true); + }); +}); + +describe('familyMaximum', () => { + it('is 150% of a PIA that sits below the first bend point', () => { + expect(familyMaximum(Money.from(1000), 2024).string()).toBe('$1,500.00'); + }); + + it('is exactly 150% of a PIA that lands on the first bend point', () => { + expect(familyMaximum(Money.from(1643), 2024).string()).toBe('$2,464.50'); + }); + + it('applies the 272% bracket to a PIA between the first two bend points', () => { + // 1.5 * 1643 + 2.72 * (2000 - 1643) = 3435.54 + expect(familyMaximum(Money.from(2000), 2024).string()).toBe('$3,435.50'); + }); + + it('applies the 150%, 272%, and 134% brackets and floors to the dime', () => { + // 1.5 * 1643 + 2.72 * (2371 - 1643) + 1.34 * (2500 - 2371) = 4617.52 + expect(familyMaximum(Money.from(2500), 2024).string()).toBe('$4,617.50'); + }); + + it('applies the 175% bracket above the third bend point', () => { + // ... + 1.34 * (3093 - 2371) + 1.75 * (5000 - 3093) = 8749.39 + expect(familyMaximum(Money.from(5000), 2024).string()).toBe('$8,749.30'); + }); + + it('rejects a PIA that is not positive', () => { + expect(() => familyMaximum(Money.from(0), 2024)).toThrow(/positive/); + }); + + it('always exceeds the PIA, so the family pool is never negative', () => { + // Every bracket multiplier is above 1. familyPayout relies on this; a + // constants edit that broke it should fail here, not print a negative. + for (const pia of [1, 1643, 2371, 3093, 5000, 10_000]) { + const max = familyMaximum(Money.from(pia), 2024); + expect(max.greaterThan(Money.from(pia))).toBe(true); + } + }); +});