From 69318b23837ef1e64ea2462b94f34a212e7df215 Mon Sep 17 00:00:00 2001 From: Greg Grothaus Date: Sun, 11 Oct 2026 12:02:00 -0700 Subject: [PATCH 1/2] feat(guides): add Social Security child benefits guide Covers children collecting on a retired parent's record: eligibility, the family maximum, the child-in-care spousal benefit, whether claiming at 62 for the children pays off, start and stop rules, applying, and taxes. Adds the family maximum formula (constants and src/lib/family-maximum.ts) and a worked-example module so every figure on the page follows the annual constants update, each pinned by golden tests. Links the new guide from the spousal, survivor, and earnings-test guides. --- src/lib/child-benefit-example.ts | 261 +++++++ src/lib/constants.ts | 22 + src/lib/family-maximum.ts | 75 ++ src/routes/guides/+page.svelte | 16 + src/routes/guides/child-benefits/+page.svelte | 654 ++++++++++++++++++ src/routes/guides/earnings-test/+page.svelte | 5 +- src/routes/guides/guide-cta-config.ts | 1 + .../guides/spousal-benefits/+page.svelte | 5 +- .../guides/survivor-benefits/+page.svelte | 5 + src/routes/sitemap.xml/+server.ts | 1 + src/test/child-benefit-example.test.ts | 119 ++++ src/test/family-maximum.test.ts | 53 ++ 12 files changed, 1215 insertions(+), 2 deletions(-) create mode 100644 src/lib/child-benefit-example.ts create mode 100644 src/lib/family-maximum.ts create mode 100644 src/routes/guides/child-benefits/+page.svelte create mode 100644 src/test/child-benefit-example.test.ts create mode 100644 src/test/family-maximum.test.ts diff --git a/src/lib/child-benefit-example.ts b/src/lib/child-benefit-example.ts new file mode 100644 index 0000000..a0e3b8c --- /dev/null +++ b/src/lib/child-benefit-example.ts @@ -0,0 +1,261 @@ +/** + * 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. + */ + +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, +}); + +const AGE_62 = MonthDuration.initFromYearsMonths({ years: 62, 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 and no + * cost-of-living adjustment has yet applied. Born mid-month to keep clear + * of the day-before-birthday rule. 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, + 15 + ); + 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 own benefit plus everyone collecting on the record. */ + readonly familyTotal: Money; + 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); + 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 { + readonly childAges: readonly number[]; + readonly benefitAt62: Money; + readonly benefitAtFra: Money; + /** The permanent monthly cost of claiming at 62 instead of at FRA. */ + readonly monthlyReduction: Money; + readonly monthsToFra: number; + /** Months from the claim until the youngest child turns 18. */ + readonly monthsOfChildBenefits: number; + /** + * 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; + /** Months after FRA for the larger check to make up the lead. */ + readonly monthsToCatchUp: number; + 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}` + ); + } + } +} + +/** The children still under 18 `monthsAfterClaim` months after the claim. */ +function childrenStillEligible( + childAges: readonly number[], + monthsAfterClaim: number +): number { + return childAges.filter( + (age) => age * 12 + monthsAfterClaim < CHILD_BENEFIT_END_AGE.asMonths() + ).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 fra = parent.normalRetirementAge(); + const benefitAt62 = benefitAtAge(parent, AGE_62); + 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 monthsToFra = fra.subtract(AGE_62).asMonths(); + + // 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 youngest = Math.min(...childAges); + const monthsOfChildBenefits = + CHILD_BENEFIT_END_AGE.asMonths() - youngest * 12; + let childrenTotal = Money.from(0); + let childrenBeforeFra = Money.from(0); + for (let month = 0; month < monthsOfChildBenefits; month++) { + const eligible = childrenStillEligible(childAges, month); + const payment = familyPayout(parent, eligible).each.times(eligible); + childrenTotal = childrenTotal.plus(payment); + if (month < monthsToFra) { + childrenBeforeFra = childrenBeforeFra.plus(payment); + } + } + + const ownBeforeFra = benefitAt62.times(monthsToFra); + const leadAtFra = ownBeforeFra.plus(childrenBeforeFra); + const monthsToCatchUp = Math.ceil( + leadAtFra.cents() / monthlyReduction.cents() + ); + + return { + childAges, + benefitAt62, + benefitAtFra, + monthlyReduction, + monthsToFra, + monthsOfChildBenefits, + childrenTotal, + childrenBeforeFra, + ownBeforeFra, + leadAtFra, + monthsToCatchUp, + catchUpAge: fra.add(new MonthDuration(monthsToCatchUp)), + survivorIfClaimedAt62: survivorAfterClaimAt(parent, AGE_62), + survivorIfClaimedAtFra: survivorAfterClaimAt(parent, fra), + }; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6ccffe2..f9b2359 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 0000000..8b86aad --- /dev/null +++ b/src/lib/family-maximum.ts @@ -0,0 +1,75 @@ +/** + * 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'; + +export interface FamilyMaximumBendPoints { + readonly first: Money; + readonly second: Money; + readonly third: Money; +} + +/** + * The ratio of the average wage in the indexing year to the average wage in + * 1977. Mirrors the private helper in pia.ts, including its clamp to the + * latest year of wage index data for workers whose indexing year is still in + * the future. + */ +function wageRatio(indexingYear: number): number { + const effectiveIndexingYear = Math.min( + indexingYear, + constants.MAX_WAGE_INDEX_YEAR + ); + return constants.WAGE_INDICES[effectiveIndexingYear].div$( + constants.WAGE_INDICES[1977] + ); +} + +/** The three dollar thresholds of the family maximum formula. */ +export function familyMaximumBendPoints( + indexingYear: number +): FamilyMaximumBendPoints { + const ratio = wageRatio(indexingYear); + return { + 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(), + }; +} + +/** + * 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); + 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); + // Nothing below zero: each slice is the part of the PIA inside one bracket. + 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/routes/guides/+page.svelte b/src/routes/guides/+page.svelte index bb36a74..eff12a8 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 f73dfdb..229f69e 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 0000000..5924b15 --- /dev/null +++ b/src/test/child-benefit-example.test.ts @@ -0,0 +1,119 @@ +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('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('rejects a beneficiary count that is not a positive integer', () => { + expect(() => familyPayout(exampleParent(), 0)).toThrow(); + expect(() => familyPayout(exampleParent(), 1.5)).toThrow(); + }); +}); + +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'); + }); + + 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.monthsOfChildBenefits).toBe(108); + }); + + 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.monthsToCatchUp).toBe(310); + expect(c.catchUpAge.years()).toBe(92); + expect(c.catchUpAge.modMonths()).toBe(10); + }); + + 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.monthsOfChildBenefits).toBe(12); + expect(grown.childrenBeforeFra.equals(grown.childrenTotal)).toBe(true); + }); + + it('rejects children who are not under 18', () => { + expect(() => claimAgeComparison(exampleParent(), [18])).toThrow(); + expect(() => claimAgeComparison(exampleParent(), [])).toThrow(); + expect(() => claimAgeComparison(exampleParent(), [-1])).toThrow(); + }); + + it('rejects a parent whose benefit the example cannot reduce', () => { + const parent = exampleParent(); + expect(() => claimAgeComparison(parent, [9, Number.NaN])).toThrow(); + expect(Money.from(0).lessThan(c.monthlyReduction)).toBe(true); + }); +}); diff --git a/src/test/family-maximum.test.ts b/src/test/family-maximum.test.ts new file mode 100644 index 0000000..31b1d3e --- /dev/null +++ b/src/test/family-maximum.test.ts @@ -0,0 +1,53 @@ +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('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(); + }); +}); From e0671addf4884612d2c7b858731d0145c520e8de Mon Sep 17 00:00:00 2001 From: Greg Grothaus Date: Sun, 11 Oct 2026 12:11:07 -0700 Subject: [PATCH 2/2] fix(guides): address review of the child benefits guide Move the example parent's birthday to the 2nd so a claim at exactly 62 is one SSA allows, state that the child-in-care benefit ends the month before the child turns 16, and replace two hand-written claims in the prose with figures the example module computes, including the single-16-year-old case. Share the wage index ratio between pia.ts and the family maximum instead of copying it, assert the bend points ascend and the auxiliary pool is never negative, keep time quantities as MonthDuration, copy the caller's ages, and fix a doc comment that described the family total as the worker's reduced check. Tests cover the bend-point boundaries, a low PIA, the age-out ages, and the corrected validation labels. --- src/lib/child-benefit-example.ts | 113 ++++++++++++------ src/lib/family-maximum.ts | 42 +++---- src/lib/pia.ts | 29 +++-- src/routes/guides/child-benefits/+page.svelte | 36 +++--- src/test/child-benefit-example.test.ts | 69 +++++++++-- src/test/family-maximum.test.ts | 20 +++- 6 files changed, 217 insertions(+), 92 deletions(-) diff --git a/src/lib/child-benefit-example.ts b/src/lib/child-benefit-example.ts index a0e3b8c..c967e62 100644 --- a/src/lib/child-benefit-example.ts +++ b/src/lib/child-benefit-example.ts @@ -12,6 +12,9 @@ * 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'; @@ -41,7 +44,11 @@ const CHILD_BENEFIT_END_AGE = MonthDuration.initFromYearsMonths({ months: 0, }); -const AGE_62 = MonthDuration.initFromYearsMonths({ years: 62, 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 @@ -55,17 +62,19 @@ const SURVIVOR_EXAMPLE_DEATH_AGE = MonthDuration.initFromYearsMonths({ /** * A parent who turns 62 in the latest year the constants cover, so the - * family maximum is computed from the latest wage index and no - * cost-of-living adjustment has yet applied. Born mid-month to keep clear - * of the day-before-birthday rule. The birth year gives a full retirement - * age of 67, which the guide states in prose. + * 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, - 15 + 2 ); parent.setPia(EXAMPLE_PIA); return parent; @@ -83,8 +92,13 @@ export interface FamilyPayout { readonly eachUnlimited: Money; /** Each person's benefit after the maximum, rounded down to the dime. */ readonly each: Money; - /** The worker's own benefit plus everyone collecting on the record. */ + /** + * 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; } @@ -106,6 +120,11 @@ export function familyPayout( 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); @@ -124,14 +143,20 @@ export function familyPayout( } 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 monthsToFra: number; - /** Months from the claim until the youngest child turns 18. */ - readonly monthsOfChildBenefits: number; + 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. @@ -147,8 +172,8 @@ export interface ClaimAgeComparison { * difference from then on is the parent's reduced check. */ readonly leadAtFra: Money; - /** Months after FRA for the larger check to make up the lead. */ - readonly monthsToCatchUp: number; + /** 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; @@ -172,14 +197,21 @@ function validateChildAges(childAges: readonly number[]): void { } } -/** The children still under 18 `monthsAfterClaim` months after the claim. */ +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[], - monthsAfterClaim: number + elapsed: MonthDuration ): number { - return childAges.filter( - (age) => age * 12 + monthsAfterClaim < CHILD_BENEFIT_END_AGE.asMonths() - ).length; + return childAges.filter((age) => elapsed.lessThan(benefitSpan(age))).length; } /** @@ -209,8 +241,14 @@ export function claimAgeComparison( 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, AGE_62); + const benefitAt62 = benefitAtAge(parent, EARLY_CLAIM_AGE); const benefitAtFra = benefitAtAge(parent, fra); const monthlyReduction = benefitAtFra.sub(benefitAt62); if (!monthlyReduction.greaterThan(Money.from(0))) { @@ -218,44 +256,51 @@ export function claimAgeComparison( 'the comparison needs a parent whose benefit is reduced at 62' ); } - const monthsToFra = fra.subtract(AGE_62).asMonths(); + 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 youngest = Math.min(...childAges); - const monthsOfChildBenefits = - CHILD_BENEFIT_END_AGE.asMonths() - youngest * 12; + const childBenefitSpan = benefitSpan(Math.min(...ages)); + const oldestChildBenefitSpan = benefitSpan(Math.max(...ages)); let childrenTotal = Money.from(0); let childrenBeforeFra = Money.from(0); - for (let month = 0; month < monthsOfChildBenefits; month++) { - const eligible = childrenStillEligible(childAges, month); + 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 (month < monthsToFra) { + if (elapsed.lessThan(timeToFra)) { childrenBeforeFra = childrenBeforeFra.plus(payment); } } - const ownBeforeFra = benefitAt62.times(monthsToFra); + const ownBeforeFra = benefitAt62.times(timeToFra.asMonths()); const leadAtFra = ownBeforeFra.plus(childrenBeforeFra); - const monthsToCatchUp = Math.ceil( - leadAtFra.cents() / monthlyReduction.cents() + const timeToCatchUp = new MonthDuration( + Math.ceil(leadAtFra.div$(monthlyReduction)) ); return { - childAges, + childAges: ages, + fra, benefitAt62, benefitAtFra, monthlyReduction, - monthsToFra, - monthsOfChildBenefits, + timeToFra, + childBenefitSpan, + oldestChildBenefitSpan, + parentAgeWhenOldestTurns18: EARLY_CLAIM_AGE.add(oldestChildBenefitSpan), + parentAgeWhenYoungestTurns18: EARLY_CLAIM_AGE.add(childBenefitSpan), childrenTotal, childrenBeforeFra, ownBeforeFra, leadAtFra, - monthsToCatchUp, - catchUpAge: fra.add(new MonthDuration(monthsToCatchUp)), - survivorIfClaimedAt62: survivorAfterClaimAt(parent, AGE_62), + timeToCatchUp, + catchUpAge: fra.add(timeToCatchUp), + survivorIfClaimedAt62: survivorAfterClaimAt(parent, EARLY_CLAIM_AGE), survivorIfClaimedAtFra: survivorAfterClaimAt(parent, fra), }; } diff --git a/src/lib/family-maximum.ts b/src/lib/family-maximum.ts index 8b86aad..6c74a47 100644 --- a/src/lib/family-maximum.ts +++ b/src/lib/family-maximum.ts @@ -16,40 +16,40 @@ 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; } -/** - * The ratio of the average wage in the indexing year to the average wage in - * 1977. Mirrors the private helper in pia.ts, including its clamp to the - * latest year of wage index data for workers whose indexing year is still in - * the future. - */ -function wageRatio(indexingYear: number): number { - const effectiveIndexingYear = Math.min( - indexingYear, - constants.MAX_WAGE_INDEX_YEAR - ); - return constants.WAGE_INDICES[effectiveIndexingYear].div$( - constants.WAGE_INDICES[1977] - ); -} - -/** The three dollar thresholds of the family maximum formula. */ export function familyMaximumBendPoints( indexingYear: number ): FamilyMaximumBendPoints { - const ratio = wageRatio(indexingYear); - return { + 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; } /** @@ -61,11 +61,13 @@ export function familyMaximum(pia: Money, indexingYear: number): Money { 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); - // Nothing below zero: each slice is the part of the PIA inside one bracket. return inFirst .times(constants.FAMILY_MAX_MULTIPLIER_BRACKET1) .plus(inSecond.times(constants.FAMILY_MAX_MULTIPLIER_BRACKET2)) diff --git a/src/lib/pia.ts b/src/lib/pia.ts index d1f6328..c04cc5e 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/child-benefits/+page.svelte b/src/routes/guides/child-benefits/+page.svelte index 27ede9d..e885848 100644 --- a/src/routes/guides/child-benefits/+page.svelte +++ b/src/routes/guides/child-benefits/+page.svelte @@ -61,8 +61,9 @@ const bends = oneChild.bendPoints; // The claim-early comparison follows two of those children. const CHILD_AGES = [9, 12] as const; const compare = claimAgeComparison(parent, CHILD_AGES); -const olderChildAgesOutAt = 62 + (18 - Math.max(...CHILD_AGES)); -const youngerChildAgesOutAt = 62 + (18 - Math.min(...CHILD_AGES)); +// The same parent with a single child near the end of eligibility, for the +// paragraph on how much the children's ages matter. +const soleTeen = claimAgeComparison(parent, [16]); const catchUpYears = compare.catchUpAge.years(); const catchUpMonths = compare.catchUpAge.modMonths(); @@ -86,7 +87,7 @@ const faqs: FAQItem[] = [ { question: 'Can my spouse get benefits for taking care of our child?', answer: - 'Yes. A spouse of any age who is caring for your child under 16, or a disabled child, can receive a spousal benefit of up to half your full benefit with no reduction for the spouse’s own age. It shares the family maximum with the children and ends when the youngest child turns 16, unless the child is disabled.', + 'Yes. A spouse of any age who is caring for your child under 16, or a disabled child, can receive a spousal benefit of up to half your full benefit with no reduction for the spouse’s own age. It shares the family maximum with the children and ends the month before the youngest child turns 16, unless the child is disabled.', }, { question: 'Do my child’s benefits count as my income for taxes?', @@ -350,7 +351,7 @@ const faqs: FAQItem[] = [

Three catches. The benefit shares the family maximum with the children, so as the table above shows, it may add nothing to the family total. It - ends the month the youngest child turns 16, two years before the child's + ends the month before the youngest child turns 16, two years before the child's own benefit does, and the spouse then waits until 62 to claim again, with the usual age reduction. And if the spouse is working, their own earnings are subject to the @@ -424,8 +425,9 @@ const faqs: FAQItem[] = [ The children's row is the same in both columns because a child's benefit is based on the full amount regardless of when the parent claims. What differs is how many months it is paid. Claiming at 62, the older child - collects until age 18 at the parent's {olderChildAgesOutAt} and the - younger until the parent's {youngerChildAgesOutAt}, for + collects until age 18 at the parent's + {compare.parentAgeWhenOldestTurns18.years()} and the younger until the + parent's {compare.parentAgeWhenYoungestTurns18.years()}, for {compare.childrenTotal.string()} in total. Waiting until {fra.years()} forfeits every month before then, which is {compare.childrenBeforeFra.string()} of that, plus the parent's own @@ -436,7 +438,7 @@ const faqs: FAQItem[] = [ From {fra.years()} on, the children collect the same amounts either way, so the only remaining difference is the parent's check: {compare.monthlyReduction.string()} a month more for having waited. At - that rate the larger check needs {compare.monthsToCatchUp} months to make + that rate the larger check needs {compare.timeToCatchUp.asMonths()} months to make up the {compare.leadAtFra.string()} lead, which happens at age {catchUpYears} and {catchUpMonths} months. Most people will not collect that long, and an early claim comes out ahead for this family by a wide @@ -451,12 +453,15 @@ const faqs: FAQItem[] = [

  • The children's ages do most of the work. Two children - with nine and six years of eligibility left are the best case. With one - child who is already 16, there is a year or two of child benefits at - stake against a lifetime reduction, and the math flips back toward - waiting. Run it for your own children's ages; the - calculator gives you the Primary Insurance - Amount to start from. + with {compare.childBenefitSpan.years()} and + {compare.oldestChildBenefitSpan.years()} years of eligibility left are + a strong case. For the same parent with one child who is already 16, + the lead at {fra.years()} shrinks to {soleTeen.leadAtFra.string()} + and the larger check catches up at age {soleTeen.catchUpAge.years()}, + which is close to a coin flip on dollars alone; health, the survivor + benefit, and whether you are still working decide it. Run it for your + own children's ages; the calculator gives you + the Primary Insurance Amount to start from.
  • Your spouse inherits less. A survivor benefit is @@ -605,8 +610,9 @@ const faqs: FAQItem[] = [

    Everything above starts from the Primary Insurance Amount. Paste your earnings record into the ssa.tools calculator - to see yours, then take half of it for each child and compare the total - with the family maximum for your figure. If your children are young and + to see yours. Half of it is each child's starting point, and the + bend-point table above gives the cap on the family total. If your + children are young and you are no longer working, the comparison in this guide is likely to come out the same way for you; if a child is close to 18 or you plan to keep working, it may not. diff --git a/src/test/child-benefit-example.test.ts b/src/test/child-benefit-example.test.ts index 5924b15..47b370f 100644 --- a/src/test/child-benefit-example.test.ts +++ b/src/test/child-benefit-example.test.ts @@ -19,6 +19,13 @@ describe('exampleParent', () => { 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) @@ -57,9 +64,22 @@ describe('familyPayout', () => { 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(); - expect(() => familyPayout(exampleParent(), 1.5)).toThrow(); + expect(() => familyPayout(exampleParent(), 0)).toThrow(/positive/); + expect(() => familyPayout(exampleParent(), 1.5)).toThrow(/positive/); }); }); @@ -70,12 +90,21 @@ describe('claimAgeComparison', () => { 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.monthsOfChildBenefits).toBe(108); + 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', () => { @@ -86,11 +115,21 @@ describe('claimAgeComparison', () => { it('finds the age at which the larger check would catch up', () => { // $232,044 / $750 a month = 309.4 months after 67. - expect(c.monthsToCatchUp).toBe(310); + 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. @@ -101,19 +140,23 @@ describe('claimAgeComparison', () => { 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.monthsOfChildBenefits).toBe(12); + expect(grown.childBenefitSpan.asMonths()).toBe(12); expect(grown.childrenBeforeFra.equals(grown.childrenTotal)).toBe(true); }); - it('rejects children who are not under 18', () => { - expect(() => claimAgeComparison(exampleParent(), [18])).toThrow(); - expect(() => claimAgeComparison(exampleParent(), [])).toThrow(); - expect(() => claimAgeComparison(exampleParent(), [-1])).toThrow(); + 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('rejects a parent whose benefit the example cannot reduce', () => { - const parent = exampleParent(); - expect(() => claimAgeComparison(parent, [9, Number.NaN])).toThrow(); - expect(Money.from(0).lessThan(c.monthlyReduction)).toBe(true); + 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 index 31b1d3e..ea5ca72 100644 --- a/src/test/family-maximum.test.ts +++ b/src/test/family-maximum.test.ts @@ -37,6 +37,15 @@ describe('familyMaximum', () => { 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'); @@ -48,6 +57,15 @@ describe('familyMaximum', () => { }); it('rejects a PIA that is not positive', () => { - expect(() => familyMaximum(Money.from(0), 2024)).toThrow(); + 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); + } }); });