@retiregolden/engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +79 -0
- package/dist/allocation/assetClasses.d.ts +94 -0
- package/dist/allocation/assetClasses.js +215 -0
- package/dist/decisions/annuitization.d.ts +72 -0
- package/dist/decisions/annuitization.js +184 -0
- package/dist/decisions/decisionFixtures.d.ts +48 -0
- package/dist/decisions/decisionFixtures.js +253 -0
- package/dist/decisions/evaluateCandidate.d.ts +61 -0
- package/dist/decisions/evaluateCandidate.js +193 -0
- package/dist/decisions/generators.d.ts +110 -0
- package/dist/decisions/generators.js +667 -0
- package/dist/decisions/index.d.ts +17 -0
- package/dist/decisions/index.js +17 -0
- package/dist/decisions/insightsAdapter.d.ts +27 -0
- package/dist/decisions/insightsAdapter.js +51 -0
- package/dist/decisions/objectives.d.ts +96 -0
- package/dist/decisions/objectives.js +319 -0
- package/dist/decisions/pensionElection.d.ts +112 -0
- package/dist/decisions/pensionElection.js +239 -0
- package/dist/decisions/search.d.ts +51 -0
- package/dist/decisions/search.js +110 -0
- package/dist/decisions/spendingSolver.d.ts +70 -0
- package/dist/decisions/spendingSolver.js +180 -0
- package/dist/decisions/spiaQuotes.d.ts +21 -0
- package/dist/decisions/spiaQuotes.js +43 -0
- package/dist/decisions/stochastic.d.ts +11 -0
- package/dist/decisions/stochastic.js +66 -0
- package/dist/decisions/swrComparator.d.ts +54 -0
- package/dist/decisions/swrComparator.js +89 -0
- package/dist/decisions/tournament.d.ts +53 -0
- package/dist/decisions/tournament.js +104 -0
- package/dist/decisions/types.d.ts +125 -0
- package/dist/decisions/types.js +12 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
- package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
- package/dist/insights/detectors/assetLocation.d.ts +9 -0
- package/dist/insights/detectors/assetLocation.js +86 -0
- package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
- package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
- package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
- package/dist/insights/detectors/incomeFloorFunded.js +52 -0
- package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
- package/dist/insights/detectors/irmaaTierEdge.js +102 -0
- package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
- package/dist/insights/detectors/pensionElectionPending.js +62 -0
- package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
- package/dist/insights/detectors/qcdEfficiency.js +62 -0
- package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
- package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
- package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
- package/dist/insights/detectors/spendingGuardrails.js +77 -0
- package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
- package/dist/insights/detectors/spendingHeadroom.js +92 -0
- package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
- package/dist/insights/detectors/ssBridgeGap.js +92 -0
- package/dist/insights/detectors/stateRelocation.d.ts +12 -0
- package/dist/insights/detectors/stateRelocation.js +99 -0
- package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
- package/dist/insights/detectors/widowsPenalty.js +145 -0
- package/dist/insights/registry.d.ts +4 -0
- package/dist/insights/registry.js +62 -0
- package/dist/insights/runInsights.d.ts +6 -0
- package/dist/insights/runInsights.js +15 -0
- package/dist/insights/types.d.ts +70 -0
- package/dist/insights/types.js +1 -0
- package/dist/ladder/bridge.d.ts +72 -0
- package/dist/ladder/bridge.js +59 -0
- package/dist/ladder/fedInvest.d.ts +55 -0
- package/dist/ladder/fedInvest.js +77 -0
- package/dist/ladder/fundedRatio.d.ts +41 -0
- package/dist/ladder/fundedRatio.js +46 -0
- package/dist/ladder/ladderMath.d.ts +88 -0
- package/dist/ladder/ladderMath.js +140 -0
- package/dist/longevity/ssaPeriod2022.d.ts +10 -0
- package/dist/longevity/ssaPeriod2022.js +51 -0
- package/dist/longevity/types.d.ts +39 -0
- package/dist/longevity/types.js +1 -0
- package/dist/model/migrations.d.ts +20 -0
- package/dist/model/migrations.js +67 -0
- package/dist/model/plan.d.ts +3108 -0
- package/dist/model/plan.js +1514 -0
- package/dist/montecarlo/frontiers.d.ts +22 -0
- package/dist/montecarlo/frontiers.js +61 -0
- package/dist/montecarlo/historicalReturns.d.ts +27 -0
- package/dist/montecarlo/historicalReturns.js +124 -0
- package/dist/montecarlo/historicalSuites.d.ts +45 -0
- package/dist/montecarlo/historicalSuites.js +102 -0
- package/dist/montecarlo/ltcShock.d.ts +42 -0
- package/dist/montecarlo/ltcShock.js +62 -0
- package/dist/montecarlo/marketModels.d.ts +248 -0
- package/dist/montecarlo/marketModels.js +672 -0
- package/dist/montecarlo/mortality.d.ts +31 -0
- package/dist/montecarlo/mortality.js +61 -0
- package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
- package/dist/montecarlo/riskBasedGuardrails.js +200 -0
- package/dist/montecarlo/rng.d.ts +18 -0
- package/dist/montecarlo/rng.js +44 -0
- package/dist/montecarlo/run.d.ts +210 -0
- package/dist/montecarlo/run.js +353 -0
- package/dist/montecarlo/sharedPaths.d.ts +43 -0
- package/dist/montecarlo/sharedPaths.js +28 -0
- package/dist/montecarlo/survival.d.ts +49 -0
- package/dist/montecarlo/survival.js +126 -0
- package/dist/params/data/realYieldCurve2026.d.ts +17 -0
- package/dist/params/data/realYieldCurve2026.js +26 -0
- package/dist/params/data/year2026.d.ts +14 -0
- package/dist/params/data/year2026.js +199 -0
- package/dist/params/index.d.ts +63 -0
- package/dist/params/index.js +134 -0
- package/dist/params/provenance.d.ts +29 -0
- package/dist/params/provenance.js +123 -0
- package/dist/params/state/data/year2026.d.ts +18 -0
- package/dist/params/state/data/year2026.js +581 -0
- package/dist/params/state/index.d.ts +13 -0
- package/dist/params/state/index.js +26 -0
- package/dist/params/state/types.d.ts +73 -0
- package/dist/params/state/types.js +10 -0
- package/dist/params/types.d.ts +201 -0
- package/dist/params/types.js +7 -0
- package/dist/projection/annuityForms.d.ts +62 -0
- package/dist/projection/annuityForms.js +87 -0
- package/dist/projection/compare.d.ts +96 -0
- package/dist/projection/compare.js +216 -0
- package/dist/projection/flatTax.d.ts +7 -0
- package/dist/projection/flatTax.js +14 -0
- package/dist/projection/optimizePlan.d.ts +328 -0
- package/dist/projection/optimizePlan.js +1039 -0
- package/dist/projection/relocation.d.ts +149 -0
- package/dist/projection/relocation.js +317 -0
- package/dist/projection/simulate.d.ts +61 -0
- package/dist/projection/simulate.js +2754 -0
- package/dist/projection/types.d.ts +381 -0
- package/dist/projection/types.js +9 -0
- package/dist/rmd/jointLifeTable.d.ts +8 -0
- package/dist/rmd/jointLifeTable.js +663 -0
- package/dist/rmd/rmd.d.ts +29 -0
- package/dist/rmd/rmd.js +34 -0
- package/dist/scenarios/scenarios.d.ts +70 -0
- package/dist/scenarios/scenarios.js +130 -0
- package/dist/socialSecurity/benefitFactor.d.ts +13 -0
- package/dist/socialSecurity/benefitFactor.js +40 -0
- package/dist/socialSecurity/claimFactor.d.ts +33 -0
- package/dist/socialSecurity/claimFactor.js +58 -0
- package/dist/socialSecurity/disability.d.ts +36 -0
- package/dist/socialSecurity/disability.js +42 -0
- package/dist/socialSecurity/familyMaximum.d.ts +38 -0
- package/dist/socialSecurity/familyMaximum.js +48 -0
- package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
- package/dist/socialSecurity/maritalBenefits.js +87 -0
- package/dist/socialSecurity/nra.d.ts +30 -0
- package/dist/socialSecurity/nra.js +83 -0
- package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
- package/dist/socialSecurity/piaFromEarnings.js +202 -0
- package/dist/socialSecurity/ssaWageData.d.ts +68 -0
- package/dist/socialSecurity/ssaWageData.js +306 -0
- package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
- package/dist/socialSecurity/survivorBenefit.js +61 -0
- package/dist/spending/abw.d.ts +57 -0
- package/dist/spending/abw.js +80 -0
- package/dist/spending/flexibleGoals.d.ts +62 -0
- package/dist/spending/flexibleGoals.js +136 -0
- package/dist/spending/guardrails.d.ts +78 -0
- package/dist/spending/guardrails.js +100 -0
- package/dist/spending/layers.d.ts +81 -0
- package/dist/spending/layers.js +67 -0
- package/dist/spending/shapePresets.d.ts +42 -0
- package/dist/spending/shapePresets.js +77 -0
- package/dist/strategies/accountEligibility.d.ts +81 -0
- package/dist/strategies/accountEligibility.js +98 -0
- package/dist/strategies/inheritedIra.d.ts +45 -0
- package/dist/strategies/inheritedIra.js +43 -0
- package/dist/strategies/iraBasis.d.ts +48 -0
- package/dist/strategies/iraBasis.js +46 -0
- package/dist/strategies/optimizer.d.ts +307 -0
- package/dist/strategies/optimizer.js +551 -0
- package/dist/strategies/rothBasis.d.ts +67 -0
- package/dist/strategies/rothBasis.js +83 -0
- package/dist/strategies/rothConversion.d.ts +55 -0
- package/dist/strategies/rothConversion.js +83 -0
- package/dist/strategies/sepp.d.ts +38 -0
- package/dist/strategies/sepp.js +53 -0
- package/dist/tax/aca.d.ts +40 -0
- package/dist/tax/aca.js +82 -0
- package/dist/tax/federalTax.d.ts +110 -0
- package/dist/tax/federalTax.js +249 -0
- package/dist/tax/medicare.d.ts +20 -0
- package/dist/tax/medicare.js +36 -0
- package/dist/tax/propertySale.d.ts +46 -0
- package/dist/tax/propertySale.js +35 -0
- package/dist/tax/stateTax.d.ts +66 -0
- package/dist/tax/stateTax.js +198 -0
- package/dist/testing/money.d.ts +7 -0
- package/dist/testing/money.js +17 -0
- package/dist/testing/planFixtures.d.ts +29 -0
- package/dist/testing/planFixtures.js +141 -0
- package/package.json +75 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Required minimum distributions (SECURE 2.0).
|
|
3
|
+
*
|
|
4
|
+
* Uses the Uniform Lifetime Table, except when the sole-beneficiary spouse is
|
|
5
|
+
* more than 10 years younger — then the Joint Life & Last Survivor expectancy
|
|
6
|
+
* applies (a larger divisor → smaller RMD), from IRS Pub 590-B Table II.
|
|
7
|
+
* Inherited-account 10-year rules and the first-year April 1 deferral are later
|
|
8
|
+
* phases (the RMD is taken in the age-attained year).
|
|
9
|
+
* Roth IRAs and Roth 401(k)s (since 2024) have no lifetime RMDs.
|
|
10
|
+
*
|
|
11
|
+
* @see DOCS/domain/domain-rules-reference.md §6
|
|
12
|
+
*/
|
|
13
|
+
import type { Sex } from '../montecarlo/mortality.js';
|
|
14
|
+
import type { ParameterPack } from '../params/types.js';
|
|
15
|
+
export interface RmdSpouse {
|
|
16
|
+
ageAttained: number;
|
|
17
|
+
sex: Sex;
|
|
18
|
+
}
|
|
19
|
+
export interface RmdOptions {
|
|
20
|
+
/** Kept for API compatibility; IRS Table II is not sex-specific. */
|
|
21
|
+
ownerSex?: Sex;
|
|
22
|
+
/** Sole-beneficiary spouse, if any; triggers the Joint Life table when >10 yrs younger. */
|
|
23
|
+
spouse?: RmdSpouse;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* RMD for one traditional account-year.
|
|
27
|
+
* `priorYearEndBalance` is the Dec 31 balance of the previous year.
|
|
28
|
+
*/
|
|
29
|
+
export declare function requiredMinimumDistribution(pack: ParameterPack, birthYear: number, ageAttained: number, priorYearEndBalance: number, opts?: RmdOptions): number;
|
package/dist/rmd/rmd.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Required minimum distributions (SECURE 2.0).
|
|
3
|
+
*
|
|
4
|
+
* Uses the Uniform Lifetime Table, except when the sole-beneficiary spouse is
|
|
5
|
+
* more than 10 years younger — then the Joint Life & Last Survivor expectancy
|
|
6
|
+
* applies (a larger divisor → smaller RMD), from IRS Pub 590-B Table II.
|
|
7
|
+
* Inherited-account 10-year rules and the first-year April 1 deferral are later
|
|
8
|
+
* phases (the RMD is taken in the age-attained year).
|
|
9
|
+
* Roth IRAs and Roth 401(k)s (since 2024) have no lifetime RMDs.
|
|
10
|
+
*
|
|
11
|
+
* @see DOCS/domain/domain-rules-reference.md §6
|
|
12
|
+
*/
|
|
13
|
+
import { rmdStartAgeForBirthYear, uniformLifetimeDivisor } from '../params/index.js';
|
|
14
|
+
import { jointLifeTableDivisor } from './jointLifeTable.js';
|
|
15
|
+
/**
|
|
16
|
+
* RMD for one traditional account-year.
|
|
17
|
+
* `priorYearEndBalance` is the Dec 31 balance of the previous year.
|
|
18
|
+
*/
|
|
19
|
+
export function requiredMinimumDistribution(pack, birthYear, ageAttained, priorYearEndBalance, opts = {}) {
|
|
20
|
+
if (priorYearEndBalance <= 0)
|
|
21
|
+
return 0;
|
|
22
|
+
if (ageAttained < rmdStartAgeForBirthYear(birthYear))
|
|
23
|
+
return 0;
|
|
24
|
+
let divisor = uniformLifetimeDivisor(pack, ageAttained);
|
|
25
|
+
if (divisor === undefined || divisor <= 0)
|
|
26
|
+
return 0;
|
|
27
|
+
// Joint Life & Last Survivor table when the spouse beneficiary is >10 yrs younger.
|
|
28
|
+
if (opts.spouse && ageAttained - opts.spouse.ageAttained > 10) {
|
|
29
|
+
const joint = jointLifeTableDivisor(ageAttained, opts.spouse.ageAttained);
|
|
30
|
+
if (joint !== undefined)
|
|
31
|
+
divisor = Math.max(divisor, joint);
|
|
32
|
+
}
|
|
33
|
+
return priorYearEndBalance / divisor;
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named scenarios: patch application, diffing, and side-by-side comparison
|
|
3
|
+
* (roadmap V4, feature catalog §12).
|
|
4
|
+
*
|
|
5
|
+
* A scenario is a partial deep-override of the plan ("retire at 62", "17% SS
|
|
6
|
+
* cut from 2034", "no Roth conversions"). Patches are stored loosely typed on
|
|
7
|
+
* the plan (plan.scenarios[].patch); applying one deep-merges it over the
|
|
8
|
+
* base plan and re-parses through the Zod schema, so an invalid override
|
|
9
|
+
* fails loudly instead of simulating garbage.
|
|
10
|
+
*
|
|
11
|
+
* Merge rule: plain objects merge recursively; arrays, primitives, and null
|
|
12
|
+
* replace wholesale. Replacing arrays keeps patches predictable (overriding
|
|
13
|
+
* "accounts[1].balance" positionally would silently break when the user
|
|
14
|
+
* reorders accounts).
|
|
15
|
+
*/
|
|
16
|
+
import type { Plan, Scenario } from '../model/plan.js';
|
|
17
|
+
import { type ParsePlanResult } from '../model/plan.js';
|
|
18
|
+
import type { MarketModelConfig } from '../montecarlo/marketModels.js';
|
|
19
|
+
import { type ProjectionSummary } from '../projection/compare.js';
|
|
20
|
+
import { type SimulateOptions } from '../projection/simulate.js';
|
|
21
|
+
import type { TaxCalculator } from '../projection/types.js';
|
|
22
|
+
/**
|
|
23
|
+
* Deep-merge a scenario patch over the plan and validate the result. The
|
|
24
|
+
* merged plan keeps the base plan's id/name/scenarios; the patch cannot
|
|
25
|
+
* change the schema version.
|
|
26
|
+
*/
|
|
27
|
+
export declare function applyScenarioPatch(plan: Plan, patch: Record<string, unknown>): ParsePlanResult;
|
|
28
|
+
export interface ScenarioDiffEntry {
|
|
29
|
+
/** Dotted path of the overridden leaf (e.g. "assumptions.ssHaircut.cutPct"). */
|
|
30
|
+
path: string;
|
|
31
|
+
baseValue: unknown;
|
|
32
|
+
scenarioValue: unknown;
|
|
33
|
+
}
|
|
34
|
+
/** Leaf-level diff of what a patch changes, for the comparison UI's "changed assumptions" panel. */
|
|
35
|
+
export declare function diffScenarioPatch(plan: Plan, patch: Record<string, unknown>): ScenarioDiffEntry[];
|
|
36
|
+
export interface ScenarioMonteCarloOptions {
|
|
37
|
+
model: MarketModelConfig;
|
|
38
|
+
pathCount: number;
|
|
39
|
+
seed: number;
|
|
40
|
+
}
|
|
41
|
+
export interface CompareScenariosOptions extends SimulateOptions {
|
|
42
|
+
/** When present, each scenario also gets a Monte Carlo success rate (same seed ⇒ same market paths). */
|
|
43
|
+
monteCarlo?: ScenarioMonteCarloOptions;
|
|
44
|
+
/**
|
|
45
|
+
* Build the tax stack from each row's own (patched) plan instead of pricing
|
|
46
|
+
* every row with `taxCalculator`. Needed when a patch changes tax
|
|
47
|
+
* assumptions (flat state-rate override, local rate) — e.g. relocation
|
|
48
|
+
* scenarios — so the scenario reproduces the surface that created it.
|
|
49
|
+
*/
|
|
50
|
+
taxCalculatorForPlan?: (plan: Plan) => TaxCalculator;
|
|
51
|
+
}
|
|
52
|
+
export interface ScenarioComparisonRow {
|
|
53
|
+
/** null for the base plan row. */
|
|
54
|
+
scenarioId: string | null;
|
|
55
|
+
name: string;
|
|
56
|
+
summary: ProjectionSummary;
|
|
57
|
+
/** Patch-application or validation problems; when set, summary metrics are absent. */
|
|
58
|
+
error: string | null;
|
|
59
|
+
diff: ScenarioDiffEntry[];
|
|
60
|
+
successRate: number | null;
|
|
61
|
+
}
|
|
62
|
+
export interface ScenarioComparison {
|
|
63
|
+
rows: ScenarioComparisonRow[];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run the base plan plus the given scenarios (default: all of plan.scenarios)
|
|
67
|
+
* and produce one comparison row each. Scenario rows that fail validation get
|
|
68
|
+
* an error string instead of metrics, so one bad patch never sinks the table.
|
|
69
|
+
*/
|
|
70
|
+
export declare function compareScenarios(plan: Plan, opts: CompareScenariosOptions, scenarios?: Scenario[]): ScenarioComparison;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named scenarios: patch application, diffing, and side-by-side comparison
|
|
3
|
+
* (roadmap V4, feature catalog §12).
|
|
4
|
+
*
|
|
5
|
+
* A scenario is a partial deep-override of the plan ("retire at 62", "17% SS
|
|
6
|
+
* cut from 2034", "no Roth conversions"). Patches are stored loosely typed on
|
|
7
|
+
* the plan (plan.scenarios[].patch); applying one deep-merges it over the
|
|
8
|
+
* base plan and re-parses through the Zod schema, so an invalid override
|
|
9
|
+
* fails loudly instead of simulating garbage.
|
|
10
|
+
*
|
|
11
|
+
* Merge rule: plain objects merge recursively; arrays, primitives, and null
|
|
12
|
+
* replace wholesale. Replacing arrays keeps patches predictable (overriding
|
|
13
|
+
* "accounts[1].balance" positionally would silently break when the user
|
|
14
|
+
* reorders accounts).
|
|
15
|
+
*/
|
|
16
|
+
import { parsePlan } from '../model/plan.js';
|
|
17
|
+
import { createMarketModel } from '../montecarlo/marketModels.js';
|
|
18
|
+
import { aggregateMonteCarlo, runMonteCarloPaths } from '../montecarlo/run.js';
|
|
19
|
+
import { summarizeProjection } from '../projection/compare.js';
|
|
20
|
+
import { simulatePlan } from '../projection/simulate.js';
|
|
21
|
+
function isPlainObject(v) {
|
|
22
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
function deepMerge(base, patch) {
|
|
25
|
+
if (!isPlainObject(base) || !isPlainObject(patch))
|
|
26
|
+
return patch;
|
|
27
|
+
const out = { ...base };
|
|
28
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
29
|
+
out[key] = key in out ? deepMerge(out[key], value) : value;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Deep-merge a scenario patch over the plan and validate the result. The
|
|
35
|
+
* merged plan keeps the base plan's id/name/scenarios; the patch cannot
|
|
36
|
+
* change the schema version.
|
|
37
|
+
*/
|
|
38
|
+
export function applyScenarioPatch(plan, patch) {
|
|
39
|
+
const merged = deepMerge(plan, patch);
|
|
40
|
+
merged.schemaVersion = plan.schemaVersion;
|
|
41
|
+
merged.id = plan.id;
|
|
42
|
+
merged.scenarios = plan.scenarios;
|
|
43
|
+
return parsePlan(merged);
|
|
44
|
+
}
|
|
45
|
+
/** Leaf-level diff of what a patch changes, for the comparison UI's "changed assumptions" panel. */
|
|
46
|
+
export function diffScenarioPatch(plan, patch) {
|
|
47
|
+
const entries = [];
|
|
48
|
+
const walk = (base, node, path) => {
|
|
49
|
+
if (isPlainObject(node) && isPlainObject(base)) {
|
|
50
|
+
for (const [key, value] of Object.entries(node)) {
|
|
51
|
+
walk(base[key], value, path ? `${path}.${key}` : key);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (JSON.stringify(base) !== JSON.stringify(node)) {
|
|
56
|
+
entries.push({ path, baseValue: base, scenarioValue: node });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(plan, patch, '');
|
|
60
|
+
return entries;
|
|
61
|
+
}
|
|
62
|
+
function runOne(plan, opts) {
|
|
63
|
+
const taxCalculator = opts.taxCalculatorForPlan ? opts.taxCalculatorForPlan(plan) : opts.taxCalculator;
|
|
64
|
+
const summary = summarizeProjection(plan, simulatePlan(plan, { startYear: opts.startYear, taxCalculator }));
|
|
65
|
+
let successRate = null;
|
|
66
|
+
if (opts.monteCarlo) {
|
|
67
|
+
const result = runMonteCarloPaths(plan, {
|
|
68
|
+
startYear: opts.startYear,
|
|
69
|
+
taxCalculator,
|
|
70
|
+
model: createMarketModel(opts.monteCarlo.model),
|
|
71
|
+
seed: opts.monteCarlo.seed,
|
|
72
|
+
pathCount: opts.monteCarlo.pathCount,
|
|
73
|
+
});
|
|
74
|
+
successRate = aggregateMonteCarlo(result).successRate;
|
|
75
|
+
}
|
|
76
|
+
return { summary, successRate };
|
|
77
|
+
}
|
|
78
|
+
const EMPTY_SUMMARY = {
|
|
79
|
+
lifetimeTaxesAndPenalties: 0,
|
|
80
|
+
lifetimeRothConversions: 0,
|
|
81
|
+
endingInvestable: 0,
|
|
82
|
+
endingNetWorth: 0,
|
|
83
|
+
endingAfterTaxEstate: 0,
|
|
84
|
+
endingEstateHeirTax: 0,
|
|
85
|
+
endingEstateToCharity: 0,
|
|
86
|
+
estateBreakdown: [],
|
|
87
|
+
endingByCategory: { cash: 0, taxable: 0, traditional: 0, roth: 0, hsa: 0 },
|
|
88
|
+
depletionYear: null,
|
|
89
|
+
warnings: [],
|
|
90
|
+
savingsRates: [],
|
|
91
|
+
averagePreRetirementSavingsRatePct: 0,
|
|
92
|
+
fiNumber: 0,
|
|
93
|
+
fiYear: null,
|
|
94
|
+
fiAge: null,
|
|
95
|
+
coastFireNumber: 0,
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Run the base plan plus the given scenarios (default: all of plan.scenarios)
|
|
99
|
+
* and produce one comparison row each. Scenario rows that fail validation get
|
|
100
|
+
* an error string instead of metrics, so one bad patch never sinks the table.
|
|
101
|
+
*/
|
|
102
|
+
export function compareScenarios(plan, opts, scenarios) {
|
|
103
|
+
const rows = [];
|
|
104
|
+
const base = runOne(plan, opts);
|
|
105
|
+
rows.push({ scenarioId: null, name: 'Base plan', summary: base.summary, error: null, diff: [], successRate: base.successRate });
|
|
106
|
+
for (const scenario of scenarios ?? plan.scenarios) {
|
|
107
|
+
const applied = applyScenarioPatch(plan, scenario.patch);
|
|
108
|
+
if (!applied.ok) {
|
|
109
|
+
rows.push({
|
|
110
|
+
scenarioId: scenario.id,
|
|
111
|
+
name: scenario.name,
|
|
112
|
+
summary: EMPTY_SUMMARY,
|
|
113
|
+
error: `Scenario overrides are invalid: ${applied.issues.join('; ')}`,
|
|
114
|
+
diff: diffScenarioPatch(plan, scenario.patch),
|
|
115
|
+
successRate: null,
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const run = runOne(applied.plan, opts);
|
|
120
|
+
rows.push({
|
|
121
|
+
scenarioId: scenario.id,
|
|
122
|
+
name: scenario.name,
|
|
123
|
+
summary: run.summary,
|
|
124
|
+
error: null,
|
|
125
|
+
diff: diffScenarioPatch(plan, scenario.patch),
|
|
126
|
+
successRate: run.successRate,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { rows };
|
|
130
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FraComponents } from './nra.js';
|
|
2
|
+
/**
|
|
3
|
+
* Retirement benefit as a fraction of PIA for claiming at **completed** age
|
|
4
|
+
* `claimAgeYears` (62–70), vs FRA. Uses SSA monthly reduction / delayed credit rules
|
|
5
|
+
* (same formula for all NRAs once expressed in months early / late).
|
|
6
|
+
*
|
|
7
|
+
* @see https://www.ssa.gov/benefits/retirement/planner/agereduction.html
|
|
8
|
+
*/
|
|
9
|
+
/** Months before FRA: first 36 at 5/9% per month, beyond 36 at 5/12% per month. */
|
|
10
|
+
export declare function earlyRetirementFactor(monthsBeforeFra: number): number;
|
|
11
|
+
/** DRC: 2/3% of PIA per month after FRA, up to age 70 (no further increase). */
|
|
12
|
+
export declare function delayedRetirementFactor(monthsAfterFra: number, maxMonthsAfterFraToAge70: number): number;
|
|
13
|
+
export declare function retirementBenefitPiaFactor(claimAgeYears: number, fra: FraComponents): number;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fraTotalMonths, ageToTotalMonths } from './nra.js';
|
|
2
|
+
/**
|
|
3
|
+
* Retirement benefit as a fraction of PIA for claiming at **completed** age
|
|
4
|
+
* `claimAgeYears` (62–70), vs FRA. Uses SSA monthly reduction / delayed credit rules
|
|
5
|
+
* (same formula for all NRAs once expressed in months early / late).
|
|
6
|
+
*
|
|
7
|
+
* @see https://www.ssa.gov/benefits/retirement/planner/agereduction.html
|
|
8
|
+
*/
|
|
9
|
+
/** Months before FRA: first 36 at 5/9% per month, beyond 36 at 5/12% per month. */
|
|
10
|
+
export function earlyRetirementFactor(monthsBeforeFra) {
|
|
11
|
+
if (monthsBeforeFra <= 0)
|
|
12
|
+
return 1;
|
|
13
|
+
const m = monthsBeforeFra;
|
|
14
|
+
const first = Math.min(36, m);
|
|
15
|
+
const second = Math.max(0, m - 36);
|
|
16
|
+
const reductionPct = first * (5 / 9) + second * (5 / 12);
|
|
17
|
+
return 1 - reductionPct / 100;
|
|
18
|
+
}
|
|
19
|
+
/** DRC: 2/3% of PIA per month after FRA, up to age 70 (no further increase). */
|
|
20
|
+
export function delayedRetirementFactor(monthsAfterFra, maxMonthsAfterFraToAge70) {
|
|
21
|
+
if (monthsAfterFra <= 0)
|
|
22
|
+
return 1;
|
|
23
|
+
const d = Math.min(monthsAfterFra, Math.max(0, maxMonthsAfterFraToAge70));
|
|
24
|
+
return 1 + (d * (2 / 3)) / 100;
|
|
25
|
+
}
|
|
26
|
+
export function retirementBenefitPiaFactor(claimAgeYears, fra) {
|
|
27
|
+
if (claimAgeYears < 62 || claimAgeYears > 70) {
|
|
28
|
+
throw new RangeError('claimAgeYears must be between 62 and 70 inclusive');
|
|
29
|
+
}
|
|
30
|
+
const claimM = ageToTotalMonths(claimAgeYears);
|
|
31
|
+
const fraM = fraTotalMonths(fra);
|
|
32
|
+
const maxDrcMonths = Math.max(0, ageToTotalMonths(70) - fraM);
|
|
33
|
+
if (claimM < fraM) {
|
|
34
|
+
return earlyRetirementFactor(fraM - claimM);
|
|
35
|
+
}
|
|
36
|
+
if (claimM > fraM) {
|
|
37
|
+
return delayedRetirementFactor(claimM - fraM, maxDrcMonths);
|
|
38
|
+
}
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monthly-granularity claiming factor for the v2 engine.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the FRA schedule and reduction/credit math in this directory, which
|
|
5
|
+
* already works in months — v1 only ever called it with whole-year claim
|
|
6
|
+
* ages. The projection engine (engine/projection/simulate.ts) imports this
|
|
7
|
+
* module directly; it must stay pure (no UI, storage, or DOM access).
|
|
8
|
+
*/
|
|
9
|
+
export interface ClaimAge {
|
|
10
|
+
years: number;
|
|
11
|
+
months: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Retirement benefit as a fraction of PIA for claiming at `claimAge`
|
|
15
|
+
* (62y0m–70y0m), for a person born on the given date.
|
|
16
|
+
*/
|
|
17
|
+
export declare function claimFactor(dobYear: number, dobMonth: number, dobDay: number, claimAge: ClaimAge): number;
|
|
18
|
+
/**
|
|
19
|
+
* Spousal benefit as a fraction of the worker's PIA (so the spousal benefit is
|
|
20
|
+
* this × 0.5 × workerPIA at FRA, before the reduction below).
|
|
21
|
+
*
|
|
22
|
+
* Key differences from the retirement factor: the spousal benefit earns **no
|
|
23
|
+
* delayed retirement credits** (it tops out at FRA), and the early-claim
|
|
24
|
+
* reduction schedule is steeper — 25/36 of 1% per month for the first 36
|
|
25
|
+
* months early, then 5/12 of 1% per month beyond that.
|
|
26
|
+
*
|
|
27
|
+
* Simplifications (deemed-filing era, born 1954+): assumes the worker has
|
|
28
|
+
* already filed so the spouse is eligible, and ignores the spouse's own
|
|
29
|
+
* earnings-test interaction. Returns the fraction to apply to 0.5 × workerPIA.
|
|
30
|
+
*
|
|
31
|
+
* @see https://www.ssa.gov/benefits/retirement/planner/applying7.html
|
|
32
|
+
*/
|
|
33
|
+
export declare function spousalBenefitFactor(dobYear: number, dobMonth: number, dobDay: number, claimAge: ClaimAge): number;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monthly-granularity claiming factor for the v2 engine.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the FRA schedule and reduction/credit math in this directory, which
|
|
5
|
+
* already works in months — v1 only ever called it with whole-year claim
|
|
6
|
+
* ages. The projection engine (engine/projection/simulate.ts) imports this
|
|
7
|
+
* module directly; it must stay pure (no UI, storage, or DOM access).
|
|
8
|
+
*/
|
|
9
|
+
import { delayedRetirementFactor, earlyRetirementFactor } from './benefitFactor.js';
|
|
10
|
+
import { effectiveBirthYear, fraForBirthYear, fraTotalMonths } from './nra.js';
|
|
11
|
+
/**
|
|
12
|
+
* Retirement benefit as a fraction of PIA for claiming at `claimAge`
|
|
13
|
+
* (62y0m–70y0m), for a person born on the given date.
|
|
14
|
+
*/
|
|
15
|
+
export function claimFactor(dobYear, dobMonth, dobDay, claimAge) {
|
|
16
|
+
const claimM = claimAge.years * 12 + claimAge.months;
|
|
17
|
+
if (claimM < 62 * 12 || claimM > 70 * 12) {
|
|
18
|
+
throw new RangeError('claim age must be between 62y0m and 70y0m');
|
|
19
|
+
}
|
|
20
|
+
const effY = effectiveBirthYear(dobYear, dobMonth, dobDay);
|
|
21
|
+
const fraM = fraTotalMonths(fraForBirthYear(effY));
|
|
22
|
+
const maxDrcMonths = Math.max(0, 70 * 12 - fraM);
|
|
23
|
+
if (claimM < fraM)
|
|
24
|
+
return earlyRetirementFactor(fraM - claimM);
|
|
25
|
+
if (claimM > fraM)
|
|
26
|
+
return delayedRetirementFactor(claimM - fraM, maxDrcMonths);
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Spousal benefit as a fraction of the worker's PIA (so the spousal benefit is
|
|
31
|
+
* this × 0.5 × workerPIA at FRA, before the reduction below).
|
|
32
|
+
*
|
|
33
|
+
* Key differences from the retirement factor: the spousal benefit earns **no
|
|
34
|
+
* delayed retirement credits** (it tops out at FRA), and the early-claim
|
|
35
|
+
* reduction schedule is steeper — 25/36 of 1% per month for the first 36
|
|
36
|
+
* months early, then 5/12 of 1% per month beyond that.
|
|
37
|
+
*
|
|
38
|
+
* Simplifications (deemed-filing era, born 1954+): assumes the worker has
|
|
39
|
+
* already filed so the spouse is eligible, and ignores the spouse's own
|
|
40
|
+
* earnings-test interaction. Returns the fraction to apply to 0.5 × workerPIA.
|
|
41
|
+
*
|
|
42
|
+
* @see https://www.ssa.gov/benefits/retirement/planner/applying7.html
|
|
43
|
+
*/
|
|
44
|
+
export function spousalBenefitFactor(dobYear, dobMonth, dobDay, claimAge) {
|
|
45
|
+
const claimM = claimAge.years * 12 + claimAge.months;
|
|
46
|
+
if (claimM < 62 * 12 || claimM > 70 * 12) {
|
|
47
|
+
throw new RangeError('claim age must be between 62y0m and 70y0m');
|
|
48
|
+
}
|
|
49
|
+
const effY = effectiveBirthYear(dobYear, dobMonth, dobDay);
|
|
50
|
+
const fraM = fraTotalMonths(fraForBirthYear(effY));
|
|
51
|
+
if (claimM >= fraM)
|
|
52
|
+
return 1; // no delayed credits on spousal benefits
|
|
53
|
+
const monthsEarly = fraM - claimM;
|
|
54
|
+
const first = Math.min(36, monthsEarly);
|
|
55
|
+
const beyond = Math.max(0, monthsEarly - 36);
|
|
56
|
+
const reductionPct = first * (25 / 36) + beyond * (5 / 12);
|
|
57
|
+
return Math.max(0, 1 - reductionPct / 100);
|
|
58
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Security disability (SSDI) — the pure helper behind the `disability`
|
|
3
|
+
* input on a Social Security stream. Cited in DOCS/domain/domain-rules-reference.md
|
|
4
|
+
* §4 (SSDI); illustrative, not a filing tool.
|
|
5
|
+
*
|
|
6
|
+
* The defining rule: **SSDI pays the worker's full PIA with no early-retirement
|
|
7
|
+
* reduction** (unlike early *retirement* claiming), starting at a disability-onset
|
|
8
|
+
* age. At FRA it **converts to the retirement benefit at the same dollar amount**
|
|
9
|
+
* (the PIA — continuous, no jump), so the PIA persists from onset through life and
|
|
10
|
+
* the recipient earns no delayed-retirement credits (the benefit is already being
|
|
11
|
+
* paid). Pre-FRA, earnings above **Substantial Gainful Activity (SGA)** suspend
|
|
12
|
+
* SSDI (SSA replaces the retirement earnings test with SGA for disabled workers).
|
|
13
|
+
*
|
|
14
|
+
* Documented simplifications (out of scope for the first build): the disability
|
|
15
|
+
* freeze (excludes disability months from the AIME average), the trial-work
|
|
16
|
+
* period / extended Medicare / expedited reinstatement, and auxiliary/family
|
|
17
|
+
* benefits on SSDI. The planner uses the PIA the user entered or derived from
|
|
18
|
+
* earnings (the freeze is not recomputed).
|
|
19
|
+
*/
|
|
20
|
+
/** SSDI monthly benefit = the worker's full PIA (no early-retirement reduction). */
|
|
21
|
+
export declare function ssdiMonthlyBenefit(piaMonthly: number): number;
|
|
22
|
+
/** Annual-approximation months used to convert the monthly SGA limit to a year. */
|
|
23
|
+
export declare const SGA_ANNUAL_MONTHS = 12;
|
|
24
|
+
/**
|
|
25
|
+
* SGA gate (annual approximation): wages above SGA × 12 suspend SSDI for the
|
|
26
|
+
* year. The monthly SGA limit comes from the parameter pack
|
|
27
|
+
* (`socialSecurity.sgaMonthlyNonBlind`); `annualSgaLimit` is that value scaled
|
|
28
|
+
* to a year (×12 × inflation growth, supplied by the caller).
|
|
29
|
+
*/
|
|
30
|
+
export declare function ssdiSuspendedBySga(annualWages: number, annualSgaLimit: number): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether a person is in the SSDI window (onset ≤ age < FRA). Pre-FRA, SSDI
|
|
33
|
+
* applies and SGA (not the retirement earnings test) gates it. At/after FRA the
|
|
34
|
+
* benefit has converted to retirement (still the PIA) and no earnings test applies.
|
|
35
|
+
*/
|
|
36
|
+
export declare function inSsdiWindow(ageAttained: number, onsetAge: number, fraYears: number): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Security disability (SSDI) — the pure helper behind the `disability`
|
|
3
|
+
* input on a Social Security stream. Cited in DOCS/domain/domain-rules-reference.md
|
|
4
|
+
* §4 (SSDI); illustrative, not a filing tool.
|
|
5
|
+
*
|
|
6
|
+
* The defining rule: **SSDI pays the worker's full PIA with no early-retirement
|
|
7
|
+
* reduction** (unlike early *retirement* claiming), starting at a disability-onset
|
|
8
|
+
* age. At FRA it **converts to the retirement benefit at the same dollar amount**
|
|
9
|
+
* (the PIA — continuous, no jump), so the PIA persists from onset through life and
|
|
10
|
+
* the recipient earns no delayed-retirement credits (the benefit is already being
|
|
11
|
+
* paid). Pre-FRA, earnings above **Substantial Gainful Activity (SGA)** suspend
|
|
12
|
+
* SSDI (SSA replaces the retirement earnings test with SGA for disabled workers).
|
|
13
|
+
*
|
|
14
|
+
* Documented simplifications (out of scope for the first build): the disability
|
|
15
|
+
* freeze (excludes disability months from the AIME average), the trial-work
|
|
16
|
+
* period / extended Medicare / expedited reinstatement, and auxiliary/family
|
|
17
|
+
* benefits on SSDI. The planner uses the PIA the user entered or derived from
|
|
18
|
+
* earnings (the freeze is not recomputed).
|
|
19
|
+
*/
|
|
20
|
+
/** SSDI monthly benefit = the worker's full PIA (no early-retirement reduction). */
|
|
21
|
+
export function ssdiMonthlyBenefit(piaMonthly) {
|
|
22
|
+
return Math.max(0, piaMonthly);
|
|
23
|
+
}
|
|
24
|
+
/** Annual-approximation months used to convert the monthly SGA limit to a year. */
|
|
25
|
+
export const SGA_ANNUAL_MONTHS = 12;
|
|
26
|
+
/**
|
|
27
|
+
* SGA gate (annual approximation): wages above SGA × 12 suspend SSDI for the
|
|
28
|
+
* year. The monthly SGA limit comes from the parameter pack
|
|
29
|
+
* (`socialSecurity.sgaMonthlyNonBlind`); `annualSgaLimit` is that value scaled
|
|
30
|
+
* to a year (×12 × inflation growth, supplied by the caller).
|
|
31
|
+
*/
|
|
32
|
+
export function ssdiSuspendedBySga(annualWages, annualSgaLimit) {
|
|
33
|
+
return annualWages > annualSgaLimit;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether a person is in the SSDI window (onset ≤ age < FRA). Pre-FRA, SSDI
|
|
37
|
+
* applies and SGA (not the retirement earnings test) gates it. At/after FRA the
|
|
38
|
+
* benefit has converted to retirement (still the PIA) and no earnings test applies.
|
|
39
|
+
*/
|
|
40
|
+
export function inSsdiWindow(ageAttained, onsetAge, fraYears) {
|
|
41
|
+
return ageAttained >= onsetAge && ageAttained < fraYears;
|
|
42
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retirement/survivor family maximum (MFB), using SSA's PIA-based formula.
|
|
3
|
+
*
|
|
4
|
+
* The worker's own benefit is never reduced by the family maximum; auxiliary
|
|
5
|
+
* benefits payable on that worker's record share the remaining room. RetireGolden
|
|
6
|
+
* currently models only the current-spouse auxiliary on a worker record (no child
|
|
7
|
+
* dependents), so the capping helper applies that single auxiliary conservatively.
|
|
8
|
+
*
|
|
9
|
+
* @see https://www.ssa.gov/oact/cola/familymax.html
|
|
10
|
+
*/
|
|
11
|
+
import type { ClaimAge } from './claimFactor.js';
|
|
12
|
+
/** Year of eligibility for the retirement/survivor family maximum. */
|
|
13
|
+
export declare function familyMaximumEligibilityYearFromDobParts(year: number, month: number, day: number): number;
|
|
14
|
+
/**
|
|
15
|
+
* Monthly family maximum for a retirement/survivor worker record, before COLA
|
|
16
|
+
* and trust-fund haircut.
|
|
17
|
+
*/
|
|
18
|
+
export declare function familyMaximumMonthlyFromPia(piaMonthly: number, eligibilityYear: number): number;
|
|
19
|
+
export interface AuxiliaryFamilyMaximumInput {
|
|
20
|
+
/** Worker PIA on whose record the auxiliary is paid, today's dollars. */
|
|
21
|
+
workerPiaMonthly: number;
|
|
22
|
+
/** Worker payable monthly benefit on that record, before COLA/haircut. */
|
|
23
|
+
workerActualMonthly: number;
|
|
24
|
+
/** Worker date of birth, used to pick the eligibility-year bend points. */
|
|
25
|
+
workerDob: {
|
|
26
|
+
year: number;
|
|
27
|
+
month: number;
|
|
28
|
+
day: number;
|
|
29
|
+
};
|
|
30
|
+
/** Proposed auxiliary benefit on the worker record, before COLA/haircut. */
|
|
31
|
+
auxiliaryMonthly: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Cap a single auxiliary benefit to the available room under the worker's MFB.
|
|
35
|
+
* With no child benefits modeled, this is the full current-spouse allocation.
|
|
36
|
+
*/
|
|
37
|
+
export declare function capAuxiliaryForFamilyMaximum(input: AuxiliaryFamilyMaximumInput): number;
|
|
38
|
+
export declare function claimAgeTotalMonths(claimAge: ClaimAge): number;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retirement/survivor family maximum (MFB), using SSA's PIA-based formula.
|
|
3
|
+
*
|
|
4
|
+
* The worker's own benefit is never reduced by the family maximum; auxiliary
|
|
5
|
+
* benefits payable on that worker's record share the remaining room. RetireGolden
|
|
6
|
+
* currently models only the current-spouse auxiliary on a worker record (no child
|
|
7
|
+
* dependents), so the capping helper applies that single auxiliary conservatively.
|
|
8
|
+
*
|
|
9
|
+
* @see https://www.ssa.gov/oact/cola/familymax.html
|
|
10
|
+
*/
|
|
11
|
+
import { effectiveBirthYear } from './nra.js';
|
|
12
|
+
import { familyMaximumBendPointsForEligibilityYearOrLatest } from './ssaWageData.js';
|
|
13
|
+
function floorToDime(value) {
|
|
14
|
+
return Math.floor(value * 10 + 1e-9) / 10;
|
|
15
|
+
}
|
|
16
|
+
/** Year of eligibility for the retirement/survivor family maximum. */
|
|
17
|
+
export function familyMaximumEligibilityYearFromDobParts(year, month, day) {
|
|
18
|
+
return effectiveBirthYear(year, month, day) + 62;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Monthly family maximum for a retirement/survivor worker record, before COLA
|
|
22
|
+
* and trust-fund haircut.
|
|
23
|
+
*/
|
|
24
|
+
export function familyMaximumMonthlyFromPia(piaMonthly, eligibilityYear) {
|
|
25
|
+
if (piaMonthly <= 0)
|
|
26
|
+
return 0;
|
|
27
|
+
const bp = familyMaximumBendPointsForEligibilityYearOrLatest(eligibilityYear);
|
|
28
|
+
const first = Math.min(piaMonthly, bp.first);
|
|
29
|
+
const second = Math.max(0, Math.min(piaMonthly, bp.second) - bp.first);
|
|
30
|
+
const third = Math.max(0, Math.min(piaMonthly, bp.third) - bp.second);
|
|
31
|
+
const above = Math.max(0, piaMonthly - bp.third);
|
|
32
|
+
return floorToDime(first * 1.5 + second * 2.72 + third * 1.34 + above * 1.75);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Cap a single auxiliary benefit to the available room under the worker's MFB.
|
|
36
|
+
* With no child benefits modeled, this is the full current-spouse allocation.
|
|
37
|
+
*/
|
|
38
|
+
export function capAuxiliaryForFamilyMaximum(input) {
|
|
39
|
+
if (input.auxiliaryMonthly <= 0 || input.workerPiaMonthly <= 0)
|
|
40
|
+
return 0;
|
|
41
|
+
const eligibilityYear = familyMaximumEligibilityYearFromDobParts(input.workerDob.year, input.workerDob.month, input.workerDob.day);
|
|
42
|
+
const familyMaximum = familyMaximumMonthlyFromPia(input.workerPiaMonthly, eligibilityYear);
|
|
43
|
+
const roomForAuxiliaries = Math.max(0, familyMaximum - Math.max(0, input.workerActualMonthly));
|
|
44
|
+
return Math.min(input.auxiliaryMonthly, roomForAuxiliaries);
|
|
45
|
+
}
|
|
46
|
+
export function claimAgeTotalMonths(claimAge) {
|
|
47
|
+
return claimAge.years * 12 + claimAge.months;
|
|
48
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marital-history benefit menu (V7 phase 3): the divorced-spousal and survivor
|
|
3
|
+
* benefits a person can claim on a *former* spouse's record, separate from the
|
|
4
|
+
* current-spouse spousal top-up the engine already models for couples.
|
|
5
|
+
*
|
|
6
|
+
* Eligibility rules (from the gap analysis):
|
|
7
|
+
* - Divorced-spousal: marriage lasted ≥10 years, the claimant is currently
|
|
8
|
+
* unmarried, and the ex is eligible (≥62) — the ex need not have filed. Worth
|
|
9
|
+
* up to 50% of the ex's PIA, reduced for the claimant's own (early) claim age,
|
|
10
|
+
* with no delayed credits (same factor as current-spousal).
|
|
11
|
+
* - Survivor: marriage lasted ≥9 months, the claimant is ≥60, and remarriage
|
|
12
|
+
* rules are satisfied (remarrying before 60 forfeits it; at/after 60 preserves
|
|
13
|
+
* it). Survivor benefit is based on the deceased's actual benefit, with the
|
|
14
|
+
* early-claim widow(er) reduction and the RIB-LIM widow's-limit cap applied by
|
|
15
|
+
* the shared `survivorBenefitMonthly` helper (cited in domain rules §4).
|
|
16
|
+
*
|
|
17
|
+
* Simplifications (spec §6): survivor is modeled at the claimant's own claim age
|
|
18
|
+
* (the ledger doesn't model separate survivor-vs-own claim ages here — that
|
|
19
|
+
* sequencing lives in the actuarial `survivorSwitching` view); the
|
|
20
|
+
* 2-years-since-divorce "independently entitled" rule for divorced-spousal on a
|
|
21
|
+
* living ex is ignored (rarely binds in planning). Here a person receives the
|
|
22
|
+
* larger of own vs the best marital benefit at their single claim age.
|
|
23
|
+
*/
|
|
24
|
+
import type { FormerSpouse } from '../model/plan.js';
|
|
25
|
+
import { type ClaimAge } from './claimFactor.js';
|
|
26
|
+
export declare const DIVORCED_MIN_MARRIAGE_YEARS = 10;
|
|
27
|
+
export declare const SURVIVOR_MIN_MARRIAGE_YEARS = 0.75;
|
|
28
|
+
export declare const SURVIVOR_MIN_AGE = 60;
|
|
29
|
+
export declare const DIVORCED_EX_MIN_AGE = 62;
|
|
30
|
+
export declare const REMARRIAGE_SURVIVOR_PRESERVE_AGE = 60;
|
|
31
|
+
export type MaritalBenefitKind = 'divorcedSpousal' | 'survivor';
|
|
32
|
+
export interface MaritalBenefitContext {
|
|
33
|
+
claimantDob: {
|
|
34
|
+
year: number;
|
|
35
|
+
month: number;
|
|
36
|
+
day: number;
|
|
37
|
+
};
|
|
38
|
+
/** Claim age for retirement/divorced-spousal factors, including any ARF credit the caller applies. */
|
|
39
|
+
claimantClaimAge: ClaimAge;
|
|
40
|
+
/** Claim age for survivor factors; defaults to claimantClaimAge for direct helper callers. */
|
|
41
|
+
claimantSurvivorClaimAge?: ClaimAge;
|
|
42
|
+
/** Whole age the claimant has attained in the year being evaluated. */
|
|
43
|
+
claimantAge: number;
|
|
44
|
+
/** Calendar year being evaluated (to derive the ex-spouse's current age). */
|
|
45
|
+
year: number;
|
|
46
|
+
/** True when the claimant has no current spouse (single household). */
|
|
47
|
+
claimantIsSingle: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface MaritalBenefitCandidate {
|
|
50
|
+
kind: MaritalBenefitKind;
|
|
51
|
+
/** Monthly benefit at the claimant's claim age, today's dollars, before COLA/haircut. */
|
|
52
|
+
monthly: number;
|
|
53
|
+
}
|
|
54
|
+
/** Eligibility + monthly amount for one former-spouse record; null if not eligible this year. */
|
|
55
|
+
export declare function maritalBenefitFor(record: FormerSpouse, ctx: MaritalBenefitContext): MaritalBenefitCandidate | null;
|
|
56
|
+
/** Largest eligible monthly marital benefit across all former spouses; null if none. */
|
|
57
|
+
export declare function bestMaritalBenefit(records: FormerSpouse[] | undefined, ctx: MaritalBenefitContext): MaritalBenefitCandidate | null;
|