From 2dd5850f8ff26e787d4d292eaebdc03ddf78a84b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:41:52 +0000 Subject: [PATCH 1/5] Initial plan From 947750b3d6684b6a1b4e85483c95697cdd024c29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:47:45 +0000 Subject: [PATCH 2/5] Add comprehensive tests for taxCalculator and rmdCalculator Co-authored-by: frouaix <876178+frouaix@users.noreply.github.com> --- src/tests/rmdCalculator.test.ts | 384 ++++++++++++++++++++++ src/tests/taxCalculator.test.ts | 542 ++++++++++++++++++++++++++++++++ 2 files changed, 926 insertions(+) create mode 100644 src/tests/rmdCalculator.test.ts create mode 100644 src/tests/taxCalculator.test.ts diff --git a/src/tests/rmdCalculator.test.ts b/src/tests/rmdCalculator.test.ts new file mode 100644 index 0000000..77cf29f --- /dev/null +++ b/src/tests/rmdCalculator.test.ts @@ -0,0 +1,384 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect } from 'vitest'; +import { getRMDStartingAge, getLifeExpectancyFactor, calculateRMD } from '../lib/rmdCalculator'; +import { toBig } from '../lib/bigHelpers'; + +describe('RMD Calculator', () => { + describe('getRMDStartingAge', () => { + it('should return 72 for people born before 1951', () => { + expect(getRMDStartingAge(1950)).toBe(72); + expect(getRMDStartingAge(1945)).toBe(72); + expect(getRMDStartingAge(1940)).toBe(72); + }); + + it('should return 73 for people born between 1951-1959', () => { + expect(getRMDStartingAge(1951)).toBe(73); + expect(getRMDStartingAge(1955)).toBe(73); + expect(getRMDStartingAge(1959)).toBe(73); + }); + + it('should return 75 for people born in 1960 or later', () => { + expect(getRMDStartingAge(1960)).toBe(75); + expect(getRMDStartingAge(1965)).toBe(75); + expect(getRMDStartingAge(1970)).toBe(75); + expect(getRMDStartingAge(1980)).toBe(75); + expect(getRMDStartingAge(2000)).toBe(75); + }); + + it('should handle edge case at 1951 boundary', () => { + expect(getRMDStartingAge(1950)).toBe(72); + expect(getRMDStartingAge(1951)).toBe(73); + }); + + it('should handle edge case at 1960 boundary', () => { + expect(getRMDStartingAge(1959)).toBe(73); + expect(getRMDStartingAge(1960)).toBe(75); + }); + }); + + describe('getLifeExpectancyFactor', () => { + it('should return correct factor for age 72', () => { + const factor = getLifeExpectancyFactor(72); + expect(factor.toString()).toBe('27.4'); + }); + + it('should return correct factor for age 73', () => { + const factor = getLifeExpectancyFactor(73); + expect(factor.toString()).toBe('26.5'); + }); + + it('should return correct factor for age 75', () => { + const factor = getLifeExpectancyFactor(75); + expect(factor.toString()).toBe('24.6'); + }); + + it('should return correct factor for age 80', () => { + const factor = getLifeExpectancyFactor(80); + expect(factor.toString()).toBe('20.2'); + }); + + it('should return correct factor for age 90', () => { + const factor = getLifeExpectancyFactor(90); + expect(factor.toString()).toBe('12.2'); + }); + + it('should return correct factor for age 100', () => { + const factor = getLifeExpectancyFactor(100); + expect(factor.toString()).toBe('6.4'); + }); + + it('should return correct factor for age 110', () => { + const factor = getLifeExpectancyFactor(110); + expect(factor.toString()).toBe('3.5'); + }); + + it('should return correct factor for age 120', () => { + const factor = getLifeExpectancyFactor(120); + expect(factor.toString()).toBe('2'); + }); + + it('should return 2.0 for ages above 120', () => { + expect(getLifeExpectancyFactor(121).toString()).toBe('2'); + expect(getLifeExpectancyFactor(125).toString()).toBe('2'); + expect(getLifeExpectancyFactor(130).toString()).toBe('2'); + }); + + it('should throw error for age below 72', () => { + expect(() => getLifeExpectancyFactor(71)).toThrow('No RMD factor for age 71'); + expect(() => getLifeExpectancyFactor(70)).toThrow('No RMD factor for age 70'); + expect(() => getLifeExpectancyFactor(50)).toThrow('No RMD factor for age 50'); + }); + + it('should handle all ages in the table', () => { + // Test a few spot checks across the range + expect(getLifeExpectancyFactor(85).toString()).toBe('16'); + expect(getLifeExpectancyFactor(95).toString()).toBe('8.9'); + expect(getLifeExpectancyFactor(105).toString()).toBe('4.6'); + expect(getLifeExpectancyFactor(115).toString()).toBe('2.9'); + }); + }); + + describe('calculateRMD - basic calculations', () => { + it('should calculate RMD correctly for age 75', () => { + const balance = toBig(100000); + const rmd = calculateRMD(balance, 75); + + // RMD = 100000 / 24.6 = 4065.040650406504... + expect(rmd.toString()).toContain('4065.04'); + }); + + it('should calculate RMD correctly for age 80', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 80); + + // RMD = 500000 / 20.2 = 24752.475247... + expect(rmd.toString()).toContain('24752.47'); + }); + + it('should calculate RMD correctly for age 90', () => { + const balance = toBig(250000); + const rmd = calculateRMD(balance, 90); + + // RMD = 250000 / 12.2 = 20491.803278... + expect(rmd.toString()).toContain('20491.80'); + }); + + it('should calculate RMD correctly for large balance', () => { + const balance = toBig(2000000); + const rmd = calculateRMD(balance, 75); + + // RMD = 2000000 / 24.6 = 81300.813008... + expect(rmd.toString()).toContain('81300.81'); + }); + + it('should calculate RMD correctly for small balance', () => { + const balance = toBig(10000); + const rmd = calculateRMD(balance, 75); + + // RMD = 10000 / 24.6 = 406.504065... + expect(rmd.toString()).toContain('406.50'); + }); + + it('should return 0 for zero balance', () => { + const balance = toBig(0); + const rmd = calculateRMD(balance, 75); + + expect(rmd.toString()).toBe('0'); + }); + + it('should handle very small balance', () => { + const balance = toBig(100); + const rmd = calculateRMD(balance, 75); + + // RMD = 100 / 24.6 = 4.065... + expect(rmd.toString()).toContain('4.06'); + }); + }); + + describe('calculateRMD - age-based rules', () => { + it('should return 0 RMD for age 70 regardless of balance', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 70); + + expect(rmd.toString()).toBe('0'); + }); + + it('should return 0 RMD for age 71', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 71); + + expect(rmd.toString()).toBe('0'); + }); + + it('should return 0 RMD for age 72 when birth year is 1960 (RMD age 75)', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 72, 1960); + + expect(rmd.toString()).toBe('0'); + }); + + it('should return 0 RMD for age 73 when birth year is 1960', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 73, 1960); + + expect(rmd.toString()).toBe('0'); + }); + + it('should return 0 RMD for age 74 when birth year is 1960', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 74, 1960); + + expect(rmd.toString()).toBe('0'); + }); + + it('should calculate RMD at age 75 when birth year is 1960', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 75, 1960); + + // RMD should be calculated (not 0) + expect(rmd.gt(toBig(0))).toBe(true); + expect(rmd.toString()).toContain('20325.20'); + }); + + it('should return 0 RMD for age 72 when birth year is 1955 (RMD age 73)', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 72, 1955); + + expect(rmd.toString()).toBe('0'); + }); + + it('should calculate RMD at age 73 when birth year is 1955', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 73, 1955); + + expect(rmd.gt(toBig(0))).toBe(true); + expect(rmd.toString()).toContain('18867.92'); + }); + + it('should calculate RMD at age 72 when birth year is 1950 (RMD age 72)', () => { + const balance = toBig(500000); + const rmd = calculateRMD(balance, 72, 1950); + + expect(rmd.gt(toBig(0))).toBe(true); + expect(rmd.toString()).toContain('18248.17'); + }); + + it('should use conservative age 73 when birth year not provided', () => { + const balance = toBig(500000); + + // Age 72 without birth year should return 0 + const rmd72 = calculateRMD(balance, 72); + expect(rmd72.toString()).toBe('0'); + + // Age 73 without birth year should calculate RMD + const rmd73 = calculateRMD(balance, 73); + expect(rmd73.gt(toBig(0))).toBe(true); + }); + }); + + describe('calculateRMD - edge cases', () => { + it('should handle age at exact RMD starting age', () => { + const balance = toBig(100000); + + // Born 1950, RMD at 72 + const rmd = calculateRMD(balance, 72, 1950); + expect(rmd.gt(toBig(0))).toBe(true); + }); + + it('should handle age just before RMD starting age', () => { + const balance = toBig(100000); + + // Born 1950, age 71 (one year before RMD) + const rmd = calculateRMD(balance, 71, 1950); + expect(rmd.toString()).toBe('0'); + }); + + it('should handle very old age (120+)', () => { + const balance = toBig(50000); + const rmd = calculateRMD(balance, 125); + + // RMD = 50000 / 2.0 = 25000 + expect(rmd.toString()).toBe('25000'); + }); + + it('should handle decimal balances', () => { + const balance = toBig('123456.78'); + const rmd = calculateRMD(balance, 75); + + // RMD = 123456.78 / 24.6 + expect(rmd.gt(toBig(5000))).toBe(true); + expect(rmd.lt(toBig(5100))).toBe(true); + }); + + it('should maintain precision with Big.js', () => { + const balance = toBig('999999.99'); + const rmd = calculateRMD(balance, 80); + + // Should not have floating-point errors + const expected = balance.div('20.2'); + expect(rmd.toString()).toBe(expected.toString()); + }); + }); + + describe('calculateRMD - realistic scenarios', () => { + it('should calculate RMD for typical retiree scenario', () => { + // 75-year-old with $800k Traditional IRA, born in 1949 + const balance = toBig(800000); + const rmd = calculateRMD(balance, 75, 1949); + + // RMD = 800000 / 24.6 = 32520.325203... + expect(rmd.toString()).toContain('32520.32'); + }); + + it('should calculate RMD for older retiree', () => { + // 85-year-old with $300k Traditional IRA + const balance = toBig(300000); + const rmd = calculateRMD(balance, 85); + + // RMD = 300000 / 16.0 = 18750 + expect(rmd.toString()).toBe('18750'); + }); + + it('should calculate RMD for very old retiree', () => { + // 95-year-old with $150k Traditional IRA + const balance = toBig(150000); + const rmd = calculateRMD(balance, 95); + + // RMD = 150000 / 8.9 = 16853.932584... + expect(rmd.toString()).toContain('16853.93'); + }); + + it('should calculate increasing RMD percentage as age increases', () => { + const balance = toBig(100000); + + const rmd75 = calculateRMD(balance, 75); + const rmd85 = calculateRMD(balance, 85); + const rmd95 = calculateRMD(balance, 95); + + // As age increases, RMD percentage increases + expect(rmd85.gt(rmd75)).toBe(true); + expect(rmd95.gt(rmd85)).toBe(true); + }); + + it('should handle depleting account over time', () => { + let balance = toBig(100000); + + // Calculate RMDs for several years and verify balance decreases + const rmd75 = calculateRMD(balance, 75); + balance = balance.minus(rmd75); + + const rmd76 = calculateRMD(balance, 76); + balance = balance.minus(rmd76); + + const rmd77 = calculateRMD(balance, 77); + + // Balance should decrease each year + expect(balance.lt(toBig(100000))).toBe(true); + expect(balance.gt(toBig(90000))).toBe(true); + }); + }); + + describe('calculateRMD - SECURE Act 2.0 scenarios', () => { + it('should apply correct age for person born in 1950 (pre-SECURE 2.0)', () => { + const balance = toBig(200000); + + // Should start RMDs at age 72 + expect(calculateRMD(balance, 71, 1950).toString()).toBe('0'); + expect(calculateRMD(balance, 72, 1950).gt(toBig(0))).toBe(true); + }); + + it('should apply correct age for person born in 1955 (SECURE 2.0 tier 1)', () => { + const balance = toBig(200000); + + // Should start RMDs at age 73 + expect(calculateRMD(balance, 72, 1955).toString()).toBe('0'); + expect(calculateRMD(balance, 73, 1955).gt(toBig(0))).toBe(true); + }); + + it('should apply correct age for person born in 1965 (SECURE 2.0 tier 2)', () => { + const balance = toBig(200000); + + // Should start RMDs at age 75 + expect(calculateRMD(balance, 72, 1965).toString()).toBe('0'); + expect(calculateRMD(balance, 73, 1965).toString()).toBe('0'); + expect(calculateRMD(balance, 74, 1965).toString()).toBe('0'); + expect(calculateRMD(balance, 75, 1965).gt(toBig(0))).toBe(true); + }); + + it('should handle boundary year 1951 correctly', () => { + const balance = toBig(200000); + + // 1951 is first year for age 73 RMD + expect(calculateRMD(balance, 72, 1951).toString()).toBe('0'); + expect(calculateRMD(balance, 73, 1951).gt(toBig(0))).toBe(true); + }); + + it('should handle boundary year 1960 correctly', () => { + const balance = toBig(200000); + + // 1960 is first year for age 75 RMD + expect(calculateRMD(balance, 74, 1960).toString()).toBe('0'); + expect(calculateRMD(balance, 75, 1960).gt(toBig(0))).toBe(true); + }); + }); +}); diff --git a/src/tests/taxCalculator.test.ts b/src/tests/taxCalculator.test.ts new file mode 100644 index 0000000..f3e5da8 --- /dev/null +++ b/src/tests/taxCalculator.test.ts @@ -0,0 +1,542 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect } from 'vitest'; +import { FederalTaxCalculator } from '../lib/taxCalculator'; +import { FilingStatus } from '../lib/types'; +import type { TaxInputs } from '../lib/types'; +import { toBig } from '../lib/bigHelpers'; + +describe('FederalTaxCalculator', () => { + const calculator = new FederalTaxCalculator(); + + describe('calculate - basic scenarios', () => { + it('should calculate zero tax for income below standard deduction', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(10000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + expect(result.totalTax.toString()).toBe('0'); + expect(result.agi.toString()).toBe('10000'); + expect(result.taxableIncome.toString()).toBe('0'); + }); + + it('should calculate tax for single filer with only ordinary income', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(50000), // Above standard deduction + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // AGI should equal ordinary income + expect(result.agi.toString()).toBe('50000'); + + // Taxable income = 50000 - 14600 (standard deduction) = 35400 + expect(result.taxableIncome.toString()).toBe('35400'); + + // Tax: 10% on first 11600 + 12% on remaining 23800 + // = 1160 + 2856 = 4016 + expect(result.totalTax.toString()).toBe('4016'); + expect(result.ordinaryTax.toString()).toBe('4016'); + expect(result.ltcgTax.toString()).toBe('0'); + }); + + it('should calculate tax for married filing jointly', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(100000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.MARRIED_FILING_JOINTLY, + }; + + const result = calculator.calculate(inputs); + + // AGI = 100000 + expect(result.agi.toString()).toBe('100000'); + + // Taxable income = 100000 - 29200 = 70800 + expect(result.taxableIncome.toString()).toBe('70800'); + + // Tax: 10% on 23200 + 12% on 47600 + // = 2320 + 5712 = 8032 + expect(result.totalTax.toString()).toBe('8032'); + }); + + it('should calculate tax for head of household', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(75000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.HEAD_OF_HOUSEHOLD, + }; + + const result = calculator.calculate(inputs); + + // Taxable income = 75000 - 21900 = 53100 + expect(result.taxableIncome.toString()).toBe('53100'); + + // Tax: 10% on 16550 + 12% on 36550 + // = 1655 + 4386 = 6041 + expect(result.totalTax.toString()).toBe('6041'); + }); + }); + + describe('calculate - LTCG and qualified dividends', () => { + it('should apply 0% LTCG rate when income is low', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(20000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(15000), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // AGI = 35000 + // Taxable = 35000 - 14600 = 20400 + // Ordinary portion = 20000 - 14600 = 5400 + // LTCG portion = 15000 (all taxable) + + // All LTCG should be in 0% bracket (threshold is 47025) + expect(result.ltcgTax.toString()).toBe('0'); + }); + + it('should apply 15% LTCG rate when income is moderate', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(50000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(30000), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // AGI = 80000 + // Taxable = 80000 - 14600 = 65400 + // Ordinary portion = min(65400, 50000 - 14600) = 35400 + // LTCG portion = 30000 + + // Ordinary stack ends at 35400 + // 0% bracket ceiling is 47025, so room = 47025 - 35400 = 11625 + // First 11625 of LTCG at 0% + // Remaining 18375 at 15% + // Tax = 0 + 18375 * 0.15 = 2756.25 + expect(result.ltcgTax.toString()).toBe('2756.25'); + }); + + it('should apply 20% LTCG rate when income is high', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(500000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(100000), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // High income scenario + // Taxable = 585400, Ordinary portion = 485400, LTCG portion = 100000 + // 0% bracket room = 0 (ordinary stack above 47025) + // 15% bracket room = 518900 - 485400 = 33500 + // Amount at 15% = 33500, Amount at 20% = 66500 + // Tax = 33500*0.15 + 66500*0.20 = 5025 + 13300 = 18325 + expect(result.ltcgTax.toString()).toBe('18325'); + }); + + it('should correctly stack qualified dividends and LTCG', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(40000), + qualifiedDividends: toBig(5000), + longTermCapitalGains: toBig(10000), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // AGI = 55000 + // Taxable = 55000 - 14600 = 40400 + // Ordinary portion = min(40400, 40000 - 14600) = 25400 + // LTCG+QD portion = 15000 + + // All preferential income in 0% bracket (25400 < 47025) + expect(result.ltcgTax.toString()).toBe('0'); + expect(result.ltcgTaxable.toString()).toBe('15000'); + }); + }); + + describe('calculate - Social Security taxation', () => { + it('should not tax Social Security when income is below first threshold', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(10000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(20000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Combined income = 10000 + 0 + 0 + 0 + 20000*0.5 = 20000 + // Below threshold of 25000, so no SS taxed + expect(result.taxableSocialSecurity.toString()).toBe('0'); + expect(result.agi.toString()).toBe('10000'); + }); + + it('should tax up to 50% of Social Security when in first tier', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(20000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(20000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Combined income = 20000 + 0 + 0 + 0 + 20000*0.5 = 30000 + // Between 25000 and 34000, so up to 50% taxable + // Taxable = min(20000*0.5, (30000-25000)*0.5) = min(10000, 2500) = 2500 + expect(result.taxableSocialSecurity.toString()).toBe('2500'); + expect(result.agi.toString()).toBe('22500'); + }); + + it('should tax up to 85% of Social Security when in second tier', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(40000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(30000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Combined income = 40000 + 0 + 0 + 0 + 30000*0.5 = 55000 + // Above 34000, so up to 85% taxable + // Formula: 85% of (55000-34000) + min(4500, 30000*0.5) + // = 21000*0.85 + 4500 = 17850 + 4500 = 22350 + // Cap at 85% of SS = 30000 * 0.85 = 25500 + // Result = min(22350, 25500) = 22350 + expect(result.taxableSocialSecurity.toString()).toBe('22350'); + }); + + it('should respect 85% cap on Social Security taxation', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(100000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(40000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Very high income, should hit 85% cap + // Max taxable = 40000 * 0.85 = 34000 + expect(result.taxableSocialSecurity.toString()).toBe('34000'); + }); + + it('should use different thresholds for married filing jointly', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(35000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(30000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.MARRIED_FILING_JOINTLY, + }; + + const result = calculator.calculate(inputs); + + // Combined income = 35000 + 30000*0.5 = 50000 + // MFJ thresholds: 32000, 44000, 6000 + // Above 44000, so in 85% tier + // Formula: 85% of (50000-44000) + min(6000, 30000*0.5) + // = 6000*0.85 + 6000 = 5100 + 6000 = 11100 + expect(result.taxableSocialSecurity.toString()).toBe('11100'); + }); + }); + + describe('calculate - AGI and MAGI', () => { + it('should calculate AGI correctly with all income types', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(50000), + qualifiedDividends: toBig(5000), + longTermCapitalGains: toBig(10000), + socialSecurityGross: toBig(20000), + taxExemptInterest: toBig(2000), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // AGI = ordinary + QD + LTCG + taxable SS + // Need to calculate taxable SS first + // Combined income = 50000 + 5000 + 10000 + 2000 + 20000*0.5 = 77000 + // Above 34000, so 85% formula applies + // This is complex, but AGI should include all income except tax-exempt interest + expect(result.agi.gt(toBig(65000))).toBe(true); + expect(result.agi.lt(toBig(100000))).toBe(true); + }); + + it('should calculate MAGI including tax-exempt interest', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(50000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(5000), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // AGI = 50000 (no taxable SS) + // MAGI = AGI + tax-exempt interest = 50000 + 5000 = 55000 + expect(result.agi.toString()).toBe('50000'); + expect(result.magi.toString()).toBe('55000'); + }); + }); + + describe('calculate - progressive bracket filling', () => { + it('should correctly fill multiple ordinary brackets', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(150000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Taxable = 150000 - 14600 = 135400 + // 10% on 11600 = 1160 + // 12% on (47150-11600) = 35550 * 0.12 = 4266 + // 22% on (100525-47150) = 53375 * 0.22 = 11742.5 + // 24% on (135400-100525) = 34875 * 0.24 = 8370 + // Total = 1160 + 4266 + 11742.5 + 8370 = 25538.5 + expect(result.totalTax.toString()).toBe('25538.5'); + }); + + it('should handle income in top bracket', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(700000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Should reach 37% bracket (above 609350) + // Tax should be substantial + expect(result.totalTax.gt(toBig(200000))).toBe(true); + expect(result.ordinaryTaxable.gt(toBig(600000))).toBe(true); + }); + }); + + describe('calculate - edge cases', () => { + it('should handle zero income', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(0), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + expect(result.totalTax.toString()).toBe('0'); + expect(result.agi.toString()).toBe('0'); + expect(result.magi.toString()).toBe('0'); + }); + + it('should handle very small amounts', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(0.01), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Below standard deduction + expect(result.totalTax.toString()).toBe('0'); + expect(result.agi.toString()).toBe('0.01'); + }); + + it('should handle only qualified dividends with no ordinary income', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(0), + qualifiedDividends: toBig(30000), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // AGI = 30000 + // Taxable = 30000 - 14600 = 15400 + // All QD, no ordinary + // All in 0% LTCG bracket + expect(result.ordinaryTax.toString()).toBe('0'); + expect(result.ltcgTax.toString()).toBe('0'); + expect(result.totalTax.toString()).toBe('0'); + }); + + it('should handle negative intermediate calculations correctly', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(5000), + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Income below standard deduction + // Taxable income should be 0, not negative + expect(result.taxableIncome.toString()).toBe('0'); + expect(result.totalTax.toString()).toBe('0'); + }); + }); + + describe('calculate - complex mixed scenarios', () => { + it('should handle complex scenario with all income types', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(60000), + qualifiedDividends: toBig(8000), + longTermCapitalGains: toBig(12000), + socialSecurityGross: toBig(25000), + taxExemptInterest: toBig(3000), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Should have: + // - Taxable SS component + // - Ordinary tax component + // - LTCG tax component + // - AGI and MAGI different values + expect(result.taxableSocialSecurity.gt(toBig(0))).toBe(true); + expect(result.ordinaryTax.gt(toBig(0))).toBe(true); + expect(result.totalTax.gt(toBig(0))).toBe(true); + expect(result.magi.gt(result.agi)).toBe(true); + }); + + it('should correctly split income between ordinary and preferential', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(80000), + qualifiedDividends: toBig(10000), + longTermCapitalGains: toBig(15000), + socialSecurityGross: toBig(0), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.MARRIED_FILING_JOINTLY, + }; + + const result = calculator.calculate(inputs); + + // Taxable = 105000 - 29200 = 75800 + // LTCG+QD = 25000 + // Ordinary = 75800 - 25000 = 50800 + expect(result.ordinaryTaxable.toString()).toBe('50800'); + expect(result.ltcgTaxable.toString()).toBe('25000'); + }); + }); + + describe('calculate - realistic retirement scenarios', () => { + it('should calculate tax for typical retiree with pension and Social Security', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(40000), // Pension or IRA withdrawal + qualifiedDividends: toBig(0), + longTermCapitalGains: toBig(0), + socialSecurityGross: toBig(30000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.MARRIED_FILING_JOINTLY, + }; + + const result = calculator.calculate(inputs); + + // Should have moderate tax with some SS taxable + expect(result.taxableSocialSecurity.gt(toBig(0))).toBe(true); + expect(result.totalTax.gt(toBig(0))).toBe(true); + expect(result.totalTax.lt(toBig(10000))).toBe(true); + }); + + it('should calculate tax for retiree living on LTCG from taxable account', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(0), + qualifiedDividends: toBig(5000), + longTermCapitalGains: toBig(40000), + socialSecurityGross: toBig(25000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.SINGLE, + }; + + const result = calculator.calculate(inputs); + + // Low ordinary income, mostly LTCG + // Should have favorable tax treatment + expect(result.ltcgTax.lte(result.ordinaryTax.plus(toBig(5000)))).toBe(true); + }); + + it('should calculate tax for high-income retiree with RMDs', () => { + const inputs: TaxInputs = { + ordinaryIncome: toBig(120000), // Large RMD + qualifiedDividends: toBig(15000), + longTermCapitalGains: toBig(25000), + socialSecurityGross: toBig(35000), + taxExemptInterest: toBig(0), + filingStatus: FilingStatus.MARRIED_FILING_JOINTLY, + }; + + const result = calculator.calculate(inputs); + + // High income scenario + // Should have substantial tax + expect(result.totalTax.gt(toBig(20000))).toBe(true); + // Most SS should be taxable (85%) + expect(result.taxableSocialSecurity.toString()).toBe('29750'); + }); + }); +}); From 3cba1c282c58360372d3268aa86f318a3ffd1bd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:50:57 +0000 Subject: [PATCH 3/5] Add comprehensive tests for account classes (Taxable, Traditional, Roth) Co-authored-by: frouaix <876178+frouaix@users.noreply.github.com> --- src/tests/RothAccount.test.ts | 415 +++++++++++++++++++++++++++ src/tests/TaxableAccount.test.ts | 402 ++++++++++++++++++++++++++ src/tests/TraditionalAccount.test.ts | 387 +++++++++++++++++++++++++ 3 files changed, 1204 insertions(+) create mode 100644 src/tests/RothAccount.test.ts create mode 100644 src/tests/TaxableAccount.test.ts create mode 100644 src/tests/TraditionalAccount.test.ts diff --git a/src/tests/RothAccount.test.ts b/src/tests/RothAccount.test.ts new file mode 100644 index 0000000..115cf8f --- /dev/null +++ b/src/tests/RothAccount.test.ts @@ -0,0 +1,415 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect } from 'vitest'; +import { RothAccount } from '../lib/accounts/RothAccount'; +import { IncomeType } from '../lib/types'; +import { toBig } from '../lib/bigHelpers'; + +describe('RothAccount', () => { + describe('constructor', () => { + it('should create account with balance', () => { + const account = new RothAccount('roth-1', 300000); + + expect(account.id).toBe('roth-1'); + expect(account.getBalance().toString()).toBe('300000'); + }); + + it('should create account with nickname', () => { + const account = new RothAccount('roth-1', 300000, 'My Roth IRA'); + + expect(account.nickname).toBe('My Roth IRA'); + }); + + it('should accept Big values', () => { + const account = new RothAccount('roth-1', toBig(300000)); + + expect(account.getBalance().toString()).toBe('300000'); + }); + + it('should accept string values', () => { + const account = new RothAccount('roth-1', '300000'); + + expect(account.getBalance().toString()).toBe('300000'); + }); + }); + + describe('withdraw', () => { + it('should withdraw with zero taxation', () => { + const account = new RothAccount('roth-1', 300000); + const result = account.withdraw(toBig(50000), 65, 2024); + + // Roth withdrawals are tax-free + expect(result.grossAmount.toString()).toBe('50000'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(result.incomeType).toBe(IncomeType.ORDINARY); + expect(result.remainingBalance.toString()).toBe('250000'); + }); + + it('should update balance after withdrawal', () => { + const account = new RothAccount('roth-1', 300000); + account.withdraw(toBig(50000), 65, 2024); + + expect(account.getBalance().toString()).toBe('250000'); + }); + + it('should handle withdrawal of entire balance', () => { + const account = new RothAccount('roth-1', 100000); + const result = account.withdraw(toBig(100000), 70, 2024); + + expect(result.grossAmount.toString()).toBe('100000'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(account.getBalance().toString()).toBe('0'); + }); + + it('should cap withdrawal at available balance', () => { + const account = new RothAccount('roth-1', 100000); + const result = account.withdraw(toBig(150000), 70, 2024); + + expect(result.grossAmount.toString()).toBe('100000'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(account.getBalance().toString()).toBe('0'); + }); + + it('should handle zero withdrawal', () => { + const account = new RothAccount('roth-1', 100000); + const result = account.withdraw(toBig(0), 70, 2024); + + expect(result.grossAmount.toString()).toBe('0'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(account.getBalance().toString()).toBe('100000'); + }); + + it('should handle multiple withdrawals', () => { + const account = new RothAccount('roth-1', 200000); + + const result1 = account.withdraw(toBig(50000), 70, 2024); + expect(result1.taxableAmount.toString()).toBe('0'); + + const result2 = account.withdraw(toBig(50000), 70, 2024); + expect(result2.taxableAmount.toString()).toBe('0'); + + expect(account.getBalance().toString()).toBe('100000'); + }); + + it('should handle very small withdrawal', () => { + const account = new RothAccount('roth-1', 100000); + const result = account.withdraw(toBig(100), 70, 2024); + + expect(result.grossAmount.toString()).toBe('100'); + expect(result.taxableAmount.toString()).toBe('0'); + }); + + it('should handle large withdrawal', () => { + const account = new RothAccount('roth-1', 1000000); + const result = account.withdraw(toBig(500000), 70, 2024); + + expect(result.grossAmount.toString()).toBe('500000'); + expect(result.taxableAmount.toString()).toBe('0'); + }); + + it('should handle decimal withdrawal amounts', () => { + const account = new RothAccount('roth-1', 100000); + const result = account.withdraw(toBig('12345.67'), 70, 2024); + + expect(result.grossAmount.toString()).toBe('12345.67'); + expect(result.taxableAmount.toString()).toBe('0'); + }); + + it('should not have cost basis in result', () => { + const account = new RothAccount('roth-1', 100000); + const result = account.withdraw(toBig(10000), 70, 2024); + + expect(result.costBasis).toBeUndefined(); + }); + + it('should always be tax-free regardless of age', () => { + const account = new RothAccount('roth-1', 100000); + + // Various ages - all tax-free + const result60 = account.withdraw(toBig(10000), 60, 2024); + expect(result60.taxableAmount.toString()).toBe('0'); + + const result70 = account.withdraw(toBig(10000), 70, 2024); + expect(result70.taxableAmount.toString()).toBe('0'); + + const result85 = account.withdraw(toBig(10000), 85, 2024); + expect(result85.taxableAmount.toString()).toBe('0'); + }); + }); + + describe('calculateRMD', () => { + it('should return 0 for Roth accounts', () => { + const account = new RothAccount('roth-1', 500000); + const rmd = account.calculateRMD(75, 1950); + + expect(rmd.toString()).toBe('0'); + }); + + it('should return 0 at any age', () => { + const account = new RothAccount('roth-1', 500000); + + expect(account.calculateRMD(70).toString()).toBe('0'); + expect(account.calculateRMD(75).toString()).toBe('0'); + expect(account.calculateRMD(80).toString()).toBe('0'); + expect(account.calculateRMD(90).toString()).toBe('0'); + expect(account.calculateRMD(100).toString()).toBe('0'); + }); + + it('should return 0 even with birth year', () => { + const account = new RothAccount('roth-1', 500000); + + expect(account.calculateRMD(75, 1950).toString()).toBe('0'); + expect(account.calculateRMD(75, 1960).toString()).toBe('0'); + }); + + it('should return 0 for zero balance', () => { + const account = new RothAccount('roth-1', 0); + const rmd = account.calculateRMD(75); + + expect(rmd.toString()).toBe('0'); + }); + }); + + describe('isTaxAdvantaged', () => { + it('should return true', () => { + const account = new RothAccount('roth-1', 100000); + + expect(account.isTaxAdvantaged()).toBe(true); + }); + }); + + describe('isTaxFree', () => { + it('should return true', () => { + const account = new RothAccount('roth-1', 100000); + + expect(account.isTaxFree()).toBe(true); + }); + }); + + describe('applyGrowth', () => { + it('should apply growth to balance', () => { + const account = new RothAccount('roth-1', 100000); + account.applyGrowth(toBig(0.05)); // 5% growth + + expect(account.getBalance().toString()).toBe('105000'); + }); + + it('should handle negative growth', () => { + const account = new RothAccount('roth-1', 100000); + account.applyGrowth(toBig(-0.10)); // -10% loss + + expect(account.getBalance().toString()).toBe('90000'); + }); + + it('should handle zero growth', () => { + const account = new RothAccount('roth-1', 100000); + account.applyGrowth(toBig(0)); + + expect(account.getBalance().toString()).toBe('100000'); + }); + + it('should compound over multiple years', () => { + const account = new RothAccount('roth-1', 100000); + + account.applyGrowth(toBig(0.07)); // Year 1: 107000 + account.applyGrowth(toBig(0.07)); // Year 2: 114490 + account.applyGrowth(toBig(0.07)); // Year 3: 122504.3 + + expect(account.getBalance().toString()).toBe('122504.3'); + }); + + it('should grow tax-free', () => { + const account = new RothAccount('roth-1', 100000); + + // Grow for 10 years at 7% + for (let i = 0; i < 10; i++) { + account.applyGrowth(toBig(0.07)); + } + + // All growth is tax-free when withdrawn + const finalBalance = account.getBalance(); + const result = account.withdraw(finalBalance, 70, 2024); + + expect(result.taxableAmount.toString()).toBe('0'); + }); + }); + + describe('toString', () => { + it('should include tax-free indicator', () => { + const account = new RothAccount('roth-1', 100000); + const str = account.toString(); + + expect(str).toContain('Tax-Free'); + }); + + it('should include balance', () => { + const account = new RothAccount('roth-1', 123456.78); + const str = account.toString(); + + expect(str).toContain('123456.78'); + }); + }); + + describe('realistic scenarios', () => { + it('should handle typical retiree Roth IRA', () => { + // 65-year-old with $300k Roth IRA + const account = new RothAccount('roth-ira', 300000); + + // Withdraw $20k annually for 10 years with 6% growth + for (let year = 0; year < 10; year++) { + const result = account.withdraw(toBig(20000), 65 + year, 2024 + year); + expect(result.taxableAmount.toString()).toBe('0'); // Always tax-free + account.applyGrowth(toBig(0.06)); + } + + // Should still have substantial balance due to growth + expect(account.getBalance().gt(toBig(200000))).toBe(true); + }); + + it('should preserve Roth for maximum growth', () => { + const account = new RothAccount('roth', 500000); + + // Let it grow untouched for 20 years at 7% + for (let i = 0; i < 20; i++) { + account.applyGrowth(toBig(0.07)); + } + + const finalBalance = account.getBalance(); + + // Massive tax-free growth + expect(finalBalance.gt(toBig(1500000))).toBe(true); + + // Withdraw everything tax-free + const result = account.withdraw(finalBalance, 85, 2044); + expect(result.taxableAmount.toString()).toBe('0'); + }); + + it('should handle emergency large withdrawal', () => { + const account = new RothAccount('roth', 200000); + + // Need to withdraw large amount for medical emergency + const result = account.withdraw(toBig(150000), 67, 2024); + + expect(result.grossAmount.toString()).toBe('150000'); + expect(result.taxableAmount.toString()).toBe('0'); // No tax penalty + expect(account.getBalance().toString()).toBe('50000'); + }); + + it('should handle gradual depletion', () => { + const account = new RothAccount('roth', 100000); + + // Deplete gradually with 4% withdrawals, 5% growth + let years = 0; + while (account.getBalance().gt(toBig(1000)) && years < 100) { + const withdrawal = account.getBalance().times(toBig(0.04)); + const result = account.withdraw(withdrawal, 70 + years, 2024 + years); + expect(result.taxableAmount.toString()).toBe('0'); + account.applyGrowth(toBig(0.05)); + years++; + } + + // Should last many decades due to growth exceeding withdrawals + expect(years).toBeGreaterThan(50); + }); + + it('should compare to Traditional IRA tax advantage', () => { + const roth = new RothAccount('roth', 500000); + + // Withdraw $50k + const rothResult = roth.withdraw(toBig(50000), 70, 2024); + + // Roth gives you full $50k with no tax + expect(rothResult.grossAmount.toString()).toBe('50000'); + expect(rothResult.taxableAmount.toString()).toBe('0'); + + // Traditional would give $50k gross but all taxable + // At 22% tax rate, net would be ~$39k + // Roth advantage = $11k in this withdrawal + }); + + it('should handle estate planning scenario', () => { + const account = new RothAccount('roth', 750000); + + // Owner lives to 95, taking minimal withdrawals + for (let age = 70; age < 95; age++) { + account.withdraw(toBig(15000), age, 2024 + age - 70); + account.applyGrowth(toBig(0.06)); + } + + // Large balance remains for heirs, all tax-free + const remaining = account.getBalance(); + expect(remaining.gt(toBig(500000))).toBe(true); + + // Heirs inherit tax-free account (subject to their own RMDs) + const result = account.withdraw(remaining, 95, 2049); + expect(result.taxableAmount.toString()).toBe('0'); + }); + + it('should demonstrate Roth conversion benefit', () => { + // Start with $200k Roth + const account = new RothAccount('roth', 200000); + + // Add $50k conversion (pay taxes up front) + account['balance'] = account['balance'].plus(toBig(50000)); + + // Grow for 15 years + for (let i = 0; i < 15; i++) { + account.applyGrowth(toBig(0.07)); + } + + // Converted amount grows to much larger sum, all tax-free + const finalBalance = account.getBalance(); + expect(finalBalance.gt(toBig(500000))).toBe(true); + + // All withdrawals tax-free + const result = account.withdraw(finalBalance, 80, 2039); + expect(result.taxableAmount.toString()).toBe('0'); + }); + }); + + describe('edge cases', () => { + it('should handle very small balance', () => { + const account = new RothAccount('roth', 1); + const result = account.withdraw(toBig(1), 70, 2024); + + expect(result.grossAmount.toString()).toBe('1'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(account.getBalance().toString()).toBe('0'); + }); + + it('should handle very large balance', () => { + const account = new RothAccount('roth', 10000000); + const result = account.withdraw(toBig(5000000), 70, 2024); + + expect(result.grossAmount.toString()).toBe('5000000'); + expect(result.taxableAmount.toString()).toBe('0'); + }); + + it('should handle rapid succession withdrawals', () => { + const account = new RothAccount('roth', 100000); + + for (let i = 0; i < 10; i++) { + const result = account.withdraw(toBig(5000), 70, 2024); + expect(result.taxableAmount.toString()).toBe('0'); + } + + expect(account.getBalance().toString()).toBe('50000'); + }); + + it('should maintain tax-free status after growth', () => { + const account = new RothAccount('roth', 50000); + + // Extreme growth scenario + for (let i = 0; i < 30; i++) { + account.applyGrowth(toBig(0.10)); // 10% per year + } + + // Balance grows significantly + const balance = account.getBalance(); + expect(balance.gt(toBig(500000))).toBe(true); + + // Still all tax-free + const result = account.withdraw(balance, 95, 2054); + expect(result.taxableAmount.toString()).toBe('0'); + }); + }); +}); diff --git a/src/tests/TaxableAccount.test.ts b/src/tests/TaxableAccount.test.ts new file mode 100644 index 0000000..7a443ec --- /dev/null +++ b/src/tests/TaxableAccount.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect } from 'vitest'; +import { TaxableAccount } from '../lib/accounts/TaxableAccount'; +import { IncomeType } from '../lib/types'; +import { toBig } from '../lib/bigHelpers'; + +describe('TaxableAccount', () => { + describe('constructor', () => { + it('should create account with valid parameters', () => { + const account = new TaxableAccount('tax-1', 100000, 80000); + + expect(account.id).toBe('tax-1'); + expect(account.getBalance().toString()).toBe('100000'); + expect(account.getCostBasis().toString()).toBe('80000'); + }); + + it('should create account with nickname', () => { + const account = new TaxableAccount('tax-1', 100000, 80000, 'My Brokerage'); + + expect(account.nickname).toBe('My Brokerage'); + }); + + it('should accept Big values', () => { + const account = new TaxableAccount('tax-1', toBig(100000), toBig(80000)); + + expect(account.getBalance().toString()).toBe('100000'); + expect(account.getCostBasis().toString()).toBe('80000'); + }); + + it('should accept string values', () => { + const account = new TaxableAccount('tax-1', '100000', '80000'); + + expect(account.getBalance().toString()).toBe('100000'); + expect(account.getCostBasis().toString()).toBe('80000'); + }); + + it('should throw error for negative cost basis', () => { + expect(() => { + new TaxableAccount('tax-1', 100000, -1000); + }).toThrow('Cost basis cannot be negative'); + }); + + it('should throw error when cost basis exceeds balance', () => { + expect(() => { + new TaxableAccount('tax-1', 100000, 150000); + }).toThrow('Cost basis cannot exceed balance'); + }); + + it('should allow cost basis equal to balance', () => { + const account = new TaxableAccount('tax-1', 100000, 100000); + + expect(account.getCostBasis().toString()).toBe('100000'); + expect(account.getUnrealizedGains().toString()).toBe('0'); + }); + }); + + describe('withdraw', () => { + it('should withdraw correctly with capital gains', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const result = account.withdraw(toBig(20000), 65, 2024); + + // Basis ratio = 60000/100000 = 60% + // Basis removed = 20000 * 0.6 = 12000 + // Capital gain = 20000 - 12000 = 8000 + expect(result.grossAmount.toString()).toBe('20000'); + expect(result.taxableAmount.toString()).toBe('8000'); + expect(result.costBasis?.toString()).toBe('12000'); + expect(result.incomeType).toBe(IncomeType.LONG_TERM_CAPITAL_GAIN); + expect(result.remainingBalance.toString()).toBe('80000'); + }); + + it('should update balance and cost basis after withdrawal', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.withdraw(toBig(20000), 65, 2024); + + expect(account.getBalance().toString()).toBe('80000'); + expect(account.getCostBasis().toString()).toBe('48000'); // 60000 - 12000 + }); + + it('should handle withdrawal of entire balance', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const result = account.withdraw(toBig(100000), 65, 2024); + + expect(result.grossAmount.toString()).toBe('100000'); + expect(result.taxableAmount.toString()).toBe('40000'); // Total gain + expect(account.getBalance().toString()).toBe('0'); + expect(account.getCostBasis().toString()).toBe('0'); + }); + + it('should cap withdrawal at available balance', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const result = account.withdraw(toBig(150000), 65, 2024); + + expect(result.grossAmount.toString()).toBe('100000'); + expect(account.getBalance().toString()).toBe('0'); + }); + + it('should handle zero withdrawal', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const result = account.withdraw(toBig(0), 65, 2024); + + expect(result.grossAmount.toString()).toBe('0'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(account.getBalance().toString()).toBe('100000'); + }); + + it('should handle withdrawal when cost basis equals balance', () => { + const account = new TaxableAccount('tax-1', 100000, 100000); + const result = account.withdraw(toBig(50000), 65, 2024); + + // No gain, all basis + expect(result.grossAmount.toString()).toBe('50000'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(result.costBasis?.toString()).toBe('50000'); + }); + + it('should handle multiple withdrawals', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + + // First withdrawal + const result1 = account.withdraw(toBig(20000), 65, 2024); + expect(result1.taxableAmount.toString()).toBe('8000'); + + // Second withdrawal - basis ratio stays same + const result2 = account.withdraw(toBig(20000), 65, 2024); + expect(result2.taxableAmount.toString()).toBe('8000'); + + expect(account.getBalance().toString()).toBe('60000'); + expect(account.getCostBasis().toString()).toBe('36000'); + }); + + it('should handle very small withdrawal', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const result = account.withdraw(toBig(100), 65, 2024); + + // Basis ratio = 60%, so 60 basis, 40 gain + expect(result.grossAmount.toString()).toBe('100'); + expect(result.taxableAmount.toString()).toBe('40'); + }); + + it('should handle decimal withdrawal amounts', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const result = account.withdraw(toBig('12345.67'), 65, 2024); + + expect(result.grossAmount.toString()).toBe('12345.67'); + // Should maintain Big.js precision + expect(result.taxableAmount.gt(toBig(0))).toBe(true); + }); + }); + + describe('calculateRMD', () => { + it('should return 0 for taxable accounts', () => { + const account = new TaxableAccount('tax-1', 500000, 300000); + const rmd = account.calculateRMD(75, 1950); + + expect(rmd.toString()).toBe('0'); + }); + + it('should return 0 at any age', () => { + const account = new TaxableAccount('tax-1', 500000, 300000); + + expect(account.calculateRMD(70).toString()).toBe('0'); + expect(account.calculateRMD(80).toString()).toBe('0'); + expect(account.calculateRMD(100).toString()).toBe('0'); + }); + }); + + describe('getCostBasis', () => { + it('should return current cost basis', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + + expect(account.getCostBasis().toString()).toBe('60000'); + }); + + it('should reflect cost basis after withdrawal', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.withdraw(toBig(20000), 65, 2024); + + expect(account.getCostBasis().toString()).toBe('48000'); + }); + }); + + describe('getUnrealizedGains', () => { + it('should calculate unrealized gains correctly', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + + expect(account.getUnrealizedGains().toString()).toBe('40000'); + }); + + it('should return 0 when no gains', () => { + const account = new TaxableAccount('tax-1', 100000, 100000); + + expect(account.getUnrealizedGains().toString()).toBe('0'); + }); + + it('should update after withdrawal', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.withdraw(toBig(50000), 65, 2024); + + // Balance = 50000, basis = 30000, gains = 20000 + expect(account.getUnrealizedGains().toString()).toBe('20000'); + }); + + it('should handle zero balance', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.withdraw(toBig(100000), 65, 2024); + + expect(account.getUnrealizedGains().toString()).toBe('0'); + }); + }); + + describe('getGainPercentage', () => { + it('should calculate gain percentage correctly', () => { + const account = new TaxableAccount('tax-1', 150000, 100000); + const gainPct = account.getGainPercentage(); + + // 50000 / 100000 = 0.5 (50%) + expect(gainPct.toString()).toBe('0.5'); + }); + + it('should return 0 when cost basis is balance', () => { + const account = new TaxableAccount('tax-1', 100000, 100000); + + expect(account.getGainPercentage().toString()).toBe('0'); + }); + + it('should return 0 when cost basis is 0', () => { + const account = new TaxableAccount('tax-1', 100000, 0); + + expect(account.getGainPercentage().toString()).toBe('0'); + }); + + it('should handle high gain percentage', () => { + const account = new TaxableAccount('tax-1', 300000, 100000); + const gainPct = account.getGainPercentage(); + + // 200000 / 100000 = 2.0 (200%) + expect(gainPct.toString()).toBe('2'); + }); + }); + + describe('estimateTaxComponents', () => { + it('should estimate LTCG without mutating state', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const estimate = account.estimateTaxComponents(toBig(20000)); + + expect(estimate.ltcg.toString()).toBe('8000'); + // State should not change + expect(account.getBalance().toString()).toBe('100000'); + expect(account.getCostBasis().toString()).toBe('60000'); + }); + + it('should return 0 for zero amount', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const estimate = account.estimateTaxComponents(toBig(0)); + + expect(estimate.ltcg.toString()).toBe('0'); + }); + + it('should estimate for large withdrawal', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const estimate = account.estimateTaxComponents(toBig(100000)); + + expect(estimate.ltcg.toString()).toBe('40000'); + }); + + it('should handle multiple estimates without side effects', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + + const est1 = account.estimateTaxComponents(toBig(10000)); + const est2 = account.estimateTaxComponents(toBig(20000)); + const est3 = account.estimateTaxComponents(toBig(30000)); + + expect(est1.ltcg.toString()).toBe('4000'); + expect(est2.ltcg.toString()).toBe('8000'); + expect(est3.ltcg.toString()).toBe('12000'); + expect(account.getBalance().toString()).toBe('100000'); + }); + }); + + describe('applyGrowth', () => { + it('should apply growth to balance only (not cost basis)', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.applyGrowth(toBig(0.05)); // 5% growth + + // Balance grows, basis stays same + expect(account.getBalance().toString()).toBe('105000'); + expect(account.getCostBasis().toString()).toBe('60000'); + expect(account.getUnrealizedGains().toString()).toBe('45000'); + }); + + it('should handle negative growth', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.applyGrowth(toBig(-0.10)); // -10% loss + + expect(account.getBalance().toString()).toBe('90000'); + expect(account.getCostBasis().toString()).toBe('60000'); + expect(account.getUnrealizedGains().toString()).toBe('30000'); + }); + + it('should handle zero growth', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.applyGrowth(toBig(0)); + + expect(account.getBalance().toString()).toBe('100000'); + expect(account.getCostBasis().toString()).toBe('60000'); + }); + + it('should compound over multiple years', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + + account.applyGrowth(toBig(0.05)); // Year 1: 105000 + account.applyGrowth(toBig(0.05)); // Year 2: 110250 + + expect(account.getBalance().toString()).toBe('110250'); + expect(account.getCostBasis().toString()).toBe('60000'); + }); + }); + + describe('toString', () => { + it('should include basis and unrealized gains', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + const str = account.toString(); + + expect(str).toContain('100000'); + expect(str).toContain('60000'); + expect(str).toContain('40000'); + }); + }); + + describe('isDepleted', () => { + it('should return false when balance is positive', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + + expect(account.isDepleted()).toBe(false); + }); + + it('should return false when balance is exactly zero', () => { + const account = new TaxableAccount('tax-1', 100000, 60000); + account.withdraw(toBig(100000), 65, 2024); + + expect(account.isDepleted()).toBe(false); + }); + + it('should return true when balance is negative', () => { + const account = new TaxableAccount('tax-1', 100, 60); + account.withdraw(toBig(50), 65, 2024); + // Manually set negative for testing + account['balance'] = toBig(-10); + + expect(account.isDepleted()).toBe(true); + }); + }); + + describe('realistic scenarios', () => { + it('should handle typical retiree brokerage account', () => { + // $500k brokerage, $300k basis + const account = new TaxableAccount('brokerage', 500000, 300000); + + // Withdraw $50k annually for 5 years + for (let i = 0; i < 5; i++) { + const result = account.withdraw(toBig(50000), 65 + i, 2024 + i); + // Each withdrawal should have some capital gain + expect(result.taxableAmount.gt(toBig(0))).toBe(true); + expect(result.taxableAmount.lt(result.grossAmount)).toBe(true); + } + + // After 5 years + expect(account.getBalance().toString()).toBe('250000'); + expect(account.getCostBasis().toString()).toBe('150000'); + }); + + it('should handle account with large unrealized gains', () => { + // Highly appreciated stock: $1M value, $200k basis + const account = new TaxableAccount('tech-stock', 1000000, 200000); + + const result = account.withdraw(toBig(100000), 65, 2024); + + // 80% of withdrawal is taxable gain + expect(result.taxableAmount.toString()).toBe('80000'); + expect(result.costBasis?.toString()).toBe('20000'); + }); + + it('should handle depleting account gradually', () => { + const account = new TaxableAccount('savings', 100000, 80000); + + // Withdraw until depleted + let totalWithdrawn = toBig(0); + let totalGains = toBig(0); + + while (account.getBalance().gt(toBig(0))) { + const result = account.withdraw(toBig(10000), 65, 2024); + totalWithdrawn = totalWithdrawn.plus(result.grossAmount); + totalGains = totalGains.plus(result.taxableAmount); + } + + // Should have withdrawn exactly 100k with 20k total gains + expect(totalWithdrawn.toString()).toBe('100000'); + expect(totalGains.toString()).toBe('20000'); + }); + }); +}); diff --git a/src/tests/TraditionalAccount.test.ts b/src/tests/TraditionalAccount.test.ts new file mode 100644 index 0000000..044a596 --- /dev/null +++ b/src/tests/TraditionalAccount.test.ts @@ -0,0 +1,387 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect } from 'vitest'; +import { TraditionalAccount } from '../lib/accounts/TraditionalAccount'; +import { IncomeType } from '../lib/types'; +import { toBig } from '../lib/bigHelpers'; + +describe('TraditionalAccount', () => { + describe('constructor', () => { + it('should create account with balance only', () => { + const account = new TraditionalAccount('trad-1', 500000); + + expect(account.id).toBe('trad-1'); + expect(account.getBalance().toString()).toBe('500000'); + expect(account.getBirthYear()).toBeUndefined(); + }); + + it('should create account with birth year', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + + expect(account.getBirthYear()).toBe(1955); + }); + + it('should create account with nickname', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955, 'My 401k'); + + expect(account.nickname).toBe('My 401k'); + }); + + it('should accept Big values', () => { + const account = new TraditionalAccount('trad-1', toBig(500000), 1955); + + expect(account.getBalance().toString()).toBe('500000'); + }); + + it('should accept string values', () => { + const account = new TraditionalAccount('trad-1', '500000', 1955); + + expect(account.getBalance().toString()).toBe('500000'); + }); + }); + + describe('withdraw', () => { + it('should withdraw with full taxation', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + const result = account.withdraw(toBig(50000), 75, 2024); + + // All traditional withdrawals are fully taxable + expect(result.grossAmount.toString()).toBe('50000'); + expect(result.taxableAmount.toString()).toBe('50000'); + expect(result.incomeType).toBe(IncomeType.ORDINARY); + expect(result.remainingBalance.toString()).toBe('450000'); + }); + + it('should update balance after withdrawal', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + account.withdraw(toBig(50000), 75, 2024); + + expect(account.getBalance().toString()).toBe('450000'); + }); + + it('should handle withdrawal of entire balance', () => { + const account = new TraditionalAccount('trad-1', 100000); + const result = account.withdraw(toBig(100000), 70, 2024); + + expect(result.grossAmount.toString()).toBe('100000'); + expect(result.taxableAmount.toString()).toBe('100000'); + expect(account.getBalance().toString()).toBe('0'); + }); + + it('should cap withdrawal at available balance', () => { + const account = new TraditionalAccount('trad-1', 100000); + const result = account.withdraw(toBig(150000), 70, 2024); + + expect(result.grossAmount.toString()).toBe('100000'); + expect(account.getBalance().toString()).toBe('0'); + }); + + it('should handle zero withdrawal', () => { + const account = new TraditionalAccount('trad-1', 100000); + const result = account.withdraw(toBig(0), 70, 2024); + + expect(result.grossAmount.toString()).toBe('0'); + expect(result.taxableAmount.toString()).toBe('0'); + expect(account.getBalance().toString()).toBe('100000'); + }); + + it('should handle multiple withdrawals', () => { + const account = new TraditionalAccount('trad-1', 100000); + + const result1 = account.withdraw(toBig(20000), 70, 2024); + expect(result1.taxableAmount.toString()).toBe('20000'); + + const result2 = account.withdraw(toBig(30000), 70, 2024); + expect(result2.taxableAmount.toString()).toBe('30000'); + + expect(account.getBalance().toString()).toBe('50000'); + }); + + it('should handle very small withdrawal', () => { + const account = new TraditionalAccount('trad-1', 100000); + const result = account.withdraw(toBig(100), 70, 2024); + + expect(result.grossAmount.toString()).toBe('100'); + expect(result.taxableAmount.toString()).toBe('100'); + }); + + it('should handle decimal withdrawal amounts', () => { + const account = new TraditionalAccount('trad-1', 100000); + const result = account.withdraw(toBig('12345.67'), 70, 2024); + + expect(result.grossAmount.toString()).toBe('12345.67'); + expect(result.taxableAmount.toString()).toBe('12345.67'); + }); + + it('should not have cost basis in result', () => { + const account = new TraditionalAccount('trad-1', 100000); + const result = account.withdraw(toBig(10000), 70, 2024); + + expect(result.costBasis).toBeUndefined(); + }); + }); + + describe('calculateRMD', () => { + it('should return 0 when below RMD age', () => { + const account = new TraditionalAccount('trad-1', 500000, 1960); + + expect(account.calculateRMD(70, 1960).toString()).toBe('0'); + expect(account.calculateRMD(72, 1960).toString()).toBe('0'); + expect(account.calculateRMD(74, 1960).toString()).toBe('0'); + }); + + it('should calculate RMD at exact RMD age', () => { + const account = new TraditionalAccount('trad-1', 500000, 1960); + const rmd = account.calculateRMD(75, 1960); + + // RMD = 500000 / 24.6 + expect(rmd.toString()).toContain('20325.20'); + }); + + it('should calculate RMD for ages above RMD age', () => { + const account = new TraditionalAccount('trad-1', 500000, 1960); + const rmd80 = account.calculateRMD(80, 1960); + const rmd85 = account.calculateRMD(85, 1960); + + // RMD increases as age increases (smaller divisor) + expect(rmd80.gt(toBig(0))).toBe(true); + expect(rmd85.gt(rmd80)).toBe(true); + }); + + it('should use birth year from constructor if not provided', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + + // Born 1955, RMDs at 73 + expect(account.calculateRMD(72).toString()).toBe('0'); + expect(account.calculateRMD(73).gt(toBig(0))).toBe(true); + }); + + it('should allow birth year parameter to override constructor', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + + // Override to 1960 (RMDs at 75) + expect(account.calculateRMD(73, 1960).toString()).toBe('0'); + expect(account.calculateRMD(75, 1960).gt(toBig(0))).toBe(true); + }); + + it('should return 0 for zero balance', () => { + const account = new TraditionalAccount('trad-1', 0, 1950); + const rmd = account.calculateRMD(75, 1950); + + expect(rmd.toString()).toBe('0'); + }); + + it('should handle depleted account', () => { + const account = new TraditionalAccount('trad-1', 100000, 1950); + account.withdraw(toBig(100000), 75, 2024); + + const rmd = account.calculateRMD(75, 1950); + expect(rmd.toString()).toBe('0'); + }); + + it('should calculate RMD correctly for different birth years', () => { + const balance = toBig(600000); + + // Born 1950: RMD at 72 + const acc1950 = new TraditionalAccount('acc1', balance, 1950); + expect(acc1950.calculateRMD(72, 1950).gt(toBig(0))).toBe(true); + + // Born 1955: RMD at 73 + const acc1955 = new TraditionalAccount('acc2', balance, 1955); + expect(acc1955.calculateRMD(73, 1955).gt(toBig(0))).toBe(true); + + // Born 1965: RMD at 75 + const acc1965 = new TraditionalAccount('acc3', balance, 1965); + expect(acc1965.calculateRMD(75, 1965).gt(toBig(0))).toBe(true); + }); + + it('should use conservative age 73 when no birth year provided', () => { + const account = new TraditionalAccount('trad-1', 500000); + + // No birth year, uses age 73 threshold + expect(account.calculateRMD(72).toString()).toBe('0'); + expect(account.calculateRMD(73).gt(toBig(0))).toBe(true); + }); + }); + + describe('isRMDRequired', () => { + it('should return false when below RMD age', () => { + const account = new TraditionalAccount('trad-1', 500000, 1960); + + expect(account.isRMDRequired(74, 1960)).toBe(false); + }); + + it('should return true at RMD age', () => { + const account = new TraditionalAccount('trad-1', 500000, 1960); + + expect(account.isRMDRequired(75, 1960)).toBe(true); + }); + + it('should return true above RMD age', () => { + const account = new TraditionalAccount('trad-1', 500000, 1960); + + expect(account.isRMDRequired(80, 1960)).toBe(true); + }); + + it('should return false for zero balance', () => { + const account = new TraditionalAccount('trad-1', 0, 1950); + + expect(account.isRMDRequired(75, 1950)).toBe(false); + }); + }); + + describe('getBirthYear and setBirthYear', () => { + it('should get birth year from constructor', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + + expect(account.getBirthYear()).toBe(1955); + }); + + it('should return undefined when no birth year set', () => { + const account = new TraditionalAccount('trad-1', 500000); + + expect(account.getBirthYear()).toBeUndefined(); + }); + + it('should allow setting birth year', () => { + const account = new TraditionalAccount('trad-1', 500000); + account.setBirthYear(1960); + + expect(account.getBirthYear()).toBe(1960); + }); + + it('should allow updating birth year', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + account.setBirthYear(1960); + + expect(account.getBirthYear()).toBe(1960); + }); + + it('should affect RMD calculations after setting birth year', () => { + const account = new TraditionalAccount('trad-1', 500000); + account.setBirthYear(1960); + + // Should use age 75 for RMDs + expect(account.calculateRMD(74, 1960).toString()).toBe('0'); + expect(account.calculateRMD(75, 1960).gt(toBig(0))).toBe(true); + }); + }); + + describe('applyGrowth', () => { + it('should apply growth to balance', () => { + const account = new TraditionalAccount('trad-1', 500000); + account.applyGrowth(toBig(0.05)); // 5% growth + + expect(account.getBalance().toString()).toBe('525000'); + }); + + it('should handle negative growth', () => { + const account = new TraditionalAccount('trad-1', 500000); + account.applyGrowth(toBig(-0.10)); // -10% loss + + expect(account.getBalance().toString()).toBe('450000'); + }); + + it('should handle zero growth', () => { + const account = new TraditionalAccount('trad-1', 500000); + account.applyGrowth(toBig(0)); + + expect(account.getBalance().toString()).toBe('500000'); + }); + + it('should compound over multiple years', () => { + const account = new TraditionalAccount('trad-1', 100000); + + account.applyGrowth(toBig(0.05)); // Year 1: 105000 + account.applyGrowth(toBig(0.05)); // Year 2: 110250 + account.applyGrowth(toBig(0.05)); // Year 3: 115762.5 + + expect(account.getBalance().toString()).toBe('115762.5'); + }); + }); + + describe('toString', () => { + it('should include birth year when set', () => { + const account = new TraditionalAccount('trad-1', 500000, 1955); + const str = account.toString(); + + expect(str).toContain('1955'); + }); + + it('should not include birth year when not set', () => { + const account = new TraditionalAccount('trad-1', 500000); + const str = account.toString(); + + expect(str).not.toContain('Birth'); + }); + }); + + describe('realistic scenarios', () => { + it('should handle typical retiree traditional IRA', () => { + // 75-year-old with $800k Traditional IRA, born 1949 + const account = new TraditionalAccount('trad-ira', 800000, 1949); + + // Calculate RMD + const rmd = account.calculateRMD(75, 1949); + expect(rmd.gt(toBig(30000))).toBe(true); + expect(rmd.lt(toBig(35000))).toBe(true); + + // Withdraw RMD + const result = account.withdraw(rmd, 75, 2024); + expect(result.taxableAmount.eq(result.grossAmount)).toBe(true); + }); + + it('should handle multi-year RMD scenario', () => { + const account = new TraditionalAccount('401k', 1000000, 1950); + + let age = 75; + for (let year = 0; year < 10; year++) { + const rmd = account.calculateRMD(age, 1950); + account.withdraw(rmd, age, 2024 + year); + account.applyGrowth(toBig(0.05)); // 5% growth + age++; + } + + // After 10 years with growth, should still have substantial balance + expect(account.getBalance().gt(toBig(500000))).toBe(true); + }); + + it('should handle forced large RMD for old age', () => { + const account = new TraditionalAccount('ira', 500000, 1940); + + // Age 95: very high RMD percentage (1/8.9 = 11.2%) + const rmd = account.calculateRMD(95, 1940); + expect(rmd.gt(toBig(50000))).toBe(true); + }); + + it('should handle account depletion through RMDs', () => { + const account = new TraditionalAccount('ira', 50000, 1940); + + // Take RMDs for many years without growth + for (let age = 90; age < 110; age++) { + const rmd = account.calculateRMD(age, 1940); + if (rmd.gt(toBig(0))) { + account.withdraw(rmd, age, 2024); + } + } + + // Account should be depleted or very low + expect(account.getBalance().lt(toBig(5000))).toBe(true); + }); + + it('should handle young retiree with no RMDs yet', () => { + const account = new TraditionalAccount('401k', 750000, 1965); + + // Age 65, born 1965, no RMDs until 75 + for (let age = 65; age < 75; age++) { + expect(account.calculateRMD(age, 1965).toString()).toBe('0'); + + // Take discretionary withdrawal + account.withdraw(toBig(40000), age, 2024 + age - 65); + account.applyGrowth(toBig(0.06)); + } + + // Should still have healthy balance after 10 years + expect(account.getBalance().gt(toBig(500000))).toBe(true); + }); + }); +}); From f2c8a3abf214ed3c3d5f1ef6d5bc323e36c44a21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:52:33 +0000 Subject: [PATCH 4/5] Add tests for utility modules (format, ids, validation) Co-authored-by: frouaix <876178+frouaix@users.noreply.github.com> --- src/tests/format.test.ts | 90 ++++++++++++++ src/tests/ids.test.ts | 62 ++++++++++ src/tests/validation.test.ts | 219 +++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 src/tests/format.test.ts create mode 100644 src/tests/ids.test.ts create mode 100644 src/tests/validation.test.ts diff --git a/src/tests/format.test.ts b/src/tests/format.test.ts new file mode 100644 index 0000000..6641f95 --- /dev/null +++ b/src/tests/format.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect } from 'vitest'; +import { formatCurrency, formatCurrencyWithCents } from '../utils/format'; + +describe('format', () => { + describe('formatCurrency', () => { + it('should format whole numbers with thousands separators', () => { + expect(formatCurrency(1234567)).toBe('1,234,567'); + }); + + it('should format small numbers without separators', () => { + expect(formatCurrency(123)).toBe('123'); + }); + + it('should format zero', () => { + expect(formatCurrency(0)).toBe('0'); + }); + + it('should round decimal values to whole numbers', () => { + expect(formatCurrency(1234.56)).toBe('1,235'); + expect(formatCurrency(1234.49)).toBe('1,234'); + }); + + it('should handle negative numbers', () => { + expect(formatCurrency(-1234567)).toBe('-1,234,567'); + }); + + it('should handle large numbers', () => { + expect(formatCurrency(1000000)).toBe('1,000,000'); + expect(formatCurrency(1000000000)).toBe('1,000,000,000'); + }); + + it('should handle very small numbers', () => { + expect(formatCurrency(1)).toBe('1'); + expect(formatCurrency(0.5)).toBe('1'); // Rounds up + }); + + it('should handle decimal that rounds down', () => { + expect(formatCurrency(999.4)).toBe('999'); + }); + + it('should handle decimal that rounds up', () => { + expect(formatCurrency(999.5)).toBe('1,000'); + }); + }); + + describe('formatCurrencyWithCents', () => { + it('should format with two decimal places', () => { + expect(formatCurrencyWithCents(1234.56)).toBe('1,234.56'); + }); + + it('should format whole numbers with .00', () => { + expect(formatCurrencyWithCents(1234)).toBe('1,234.00'); + }); + + it('should format with thousands separators', () => { + expect(formatCurrencyWithCents(1234567.89)).toBe('1,234,567.89'); + }); + + it('should format zero with cents', () => { + expect(formatCurrencyWithCents(0)).toBe('0.00'); + }); + + it('should round to two decimal places', () => { + expect(formatCurrencyWithCents(1234.567)).toBe('1,234.57'); + expect(formatCurrencyWithCents(1234.564)).toBe('1,234.56'); + }); + + it('should handle negative numbers with cents', () => { + expect(formatCurrencyWithCents(-1234.56)).toBe('-1,234.56'); + }); + + it('should handle single decimal digit', () => { + expect(formatCurrencyWithCents(1234.5)).toBe('1,234.50'); + }); + + it('should handle very small amounts', () => { + expect(formatCurrencyWithCents(0.01)).toBe('0.01'); + expect(formatCurrencyWithCents(0.99)).toBe('0.99'); + }); + + it('should handle large numbers with cents', () => { + expect(formatCurrencyWithCents(1000000.99)).toBe('1,000,000.99'); + }); + + it('should round 3+ decimal places correctly', () => { + expect(formatCurrencyWithCents(1234.999)).toBe('1,235.00'); + }); + }); +}); diff --git a/src/tests/ids.test.ts b/src/tests/ids.test.ts new file mode 100644 index 0000000..dfd5108 --- /dev/null +++ b/src/tests/ids.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { generateAccountId } from '../utils/ids'; + +describe('ids', () => { + describe('generateAccountId', () => { + it('should generate ID with account type prefix', () => { + const id = generateAccountId('roth'); + + expect(id).toContain('roth-'); + }); + + it('should generate unique IDs', () => { + const id1 = generateAccountId('roth'); + const id2 = generateAccountId('roth'); + + expect(id1).not.toBe(id2); + }); + + it('should handle different account types', () => { + const rothId = generateAccountId('roth'); + const tradId = generateAccountId('traditional'); + const taxId = generateAccountId('taxable'); + + expect(rothId).toContain('roth-'); + expect(tradId).toContain('traditional-'); + expect(taxId).toContain('taxable-'); + }); + + it('should use crypto.randomUUID when available', () => { + // Most tests will naturally use crypto if available + const id = generateAccountId('test'); + + expect(id).toContain('test-'); + expect(id.length).toBeGreaterThan(10); + }); + + it('should generate different IDs in rapid succession', () => { + const ids = new Set(); + + // Generate 100 IDs quickly + for (let i = 0; i < 100; i++) { + ids.add(generateAccountId('test')); + } + + // All should be unique + expect(ids.size).toBe(100); + }); + + it('should handle empty string account type', () => { + const id = generateAccountId(''); + + expect(id).toContain('-'); + }); + + it('should handle special characters in account type', () => { + const id = generateAccountId('my-special_account'); + + expect(id).toContain('my-special_account-'); + }); + }); +}); diff --git a/src/tests/validation.test.ts b/src/tests/validation.test.ts new file mode 100644 index 0000000..99f8d57 --- /dev/null +++ b/src/tests/validation.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 François Rouaix +import { describe, it, expect } from 'vitest'; +import { validateUserProfile, validateAccount } from '../utils/validation'; +import type { UserProfileData } from '../components/UserProfileForm'; +import type { Account } from '../utils/export'; +import { FilingStatus } from '../lib/types'; + +describe('validation', () => { + describe('validateUserProfile', () => { + it('should return true for valid profile', () => { + const profile: UserProfileData = { + birthYear: 1960, + filingStatus: FilingStatus.SINGLE, + retirementAge: 65, + }; + + expect(validateUserProfile(profile)).toBe(true); + }); + + it('should return false when birthYear is missing', () => { + const profile = { + filingStatus: FilingStatus.SINGLE, + retirementAge: 65, + } as UserProfileData; + + expect(validateUserProfile(profile)).toBe(false); + }); + + it('should return false when filingStatus is missing', () => { + const profile = { + birthYear: 1960, + retirementAge: 65, + } as UserProfileData; + + expect(validateUserProfile(profile)).toBe(false); + }); + + it('should return false when retirementAge is missing', () => { + const profile = { + birthYear: 1960, + filingStatus: FilingStatus.SINGLE, + } as UserProfileData; + + expect(validateUserProfile(profile)).toBe(false); + }); + + it('should return false when all fields are missing', () => { + const profile = {} as UserProfileData; + + expect(validateUserProfile(profile)).toBe(false); + }); + + it('should handle married filing jointly status', () => { + const profile: UserProfileData = { + birthYear: 1960, + filingStatus: FilingStatus.MARRIED_FILING_JOINTLY, + retirementAge: 67, + }; + + expect(validateUserProfile(profile)).toBe(true); + }); + + it('should handle head of household status', () => { + const profile: UserProfileData = { + birthYear: 1960, + filingStatus: FilingStatus.HEAD_OF_HOUSEHOLD, + retirementAge: 65, + }; + + expect(validateUserProfile(profile)).toBe(true); + }); + + it('should return false for zero birthYear', () => { + const profile: UserProfileData = { + birthYear: 0, + filingStatus: FilingStatus.SINGLE, + retirementAge: 65, + }; + + expect(validateUserProfile(profile)).toBe(false); + }); + + it('should return false for zero retirementAge', () => { + const profile: UserProfileData = { + birthYear: 1960, + filingStatus: FilingStatus.SINGLE, + retirementAge: 0, + }; + + expect(validateUserProfile(profile)).toBe(false); + }); + }); + + describe('validateAccount', () => { + it('should return true for valid Roth account', () => { + const account: Account = { + accountId: 'roth-1', + accountType: 'roth', + balance: 100000, + nickname: 'My Roth IRA', + }; + + expect(validateAccount(account)).toBe(true); + }); + + it('should return true for valid Traditional account', () => { + const account: Account = { + accountId: 'trad-1', + accountType: 'traditional', + balance: 500000, + nickname: 'My 401k', + birthYear: 1960, + }; + + expect(validateAccount(account)).toBe(true); + }); + + it('should return true for valid Taxable account', () => { + const account: Account = { + accountId: 'tax-1', + accountType: 'taxable', + balance: 200000, + costBasis: 150000, + nickname: 'Brokerage', + }; + + expect(validateAccount(account)).toBe(true); + }); + + it('should return false when accountId is missing', () => { + const account = { + accountType: 'roth', + balance: 100000, + } as Account; + + expect(validateAccount(account)).toBe(false); + }); + + it('should return false when accountType is missing', () => { + const account = { + accountId: 'roth-1', + balance: 100000, + } as Account; + + expect(validateAccount(account)).toBe(false); + }); + + it('should return true when balance is missing (optional field)', () => { + const account = { + accountId: 'roth-1', + accountType: 'roth', + } as Account; + + expect(validateAccount(account)).toBe(true); + }); + + it('should return false when balance is not a number', () => { + const account = { + accountId: 'roth-1', + accountType: 'roth', + balance: 'invalid' as any, + }; + + expect(validateAccount(account)).toBe(false); + }); + + it('should return true for zero balance', () => { + const account: Account = { + accountId: 'roth-1', + accountType: 'roth', + balance: 0, + }; + + expect(validateAccount(account)).toBe(true); + }); + + it('should return true for negative balance (edge case)', () => { + const account: Account = { + accountId: 'roth-1', + accountType: 'roth', + balance: -100, + }; + + expect(validateAccount(account)).toBe(true); + }); + + it('should return false when accountId is empty string', () => { + const account: Account = { + accountId: '', + accountType: 'roth', + balance: 100000, + }; + + expect(validateAccount(account)).toBe(false); + }); + + it('should return false when accountType is empty string', () => { + const account: Account = { + accountId: 'roth-1', + accountType: '' as any, + balance: 100000, + }; + + expect(validateAccount(account)).toBe(false); + }); + + it('should handle account with all optional fields', () => { + const account: Account = { + accountId: 'trad-1', + accountType: 'traditional', + balance: 500000, + nickname: 'My IRA', + birthYear: 1960, + }; + + expect(validateAccount(account)).toBe(true); + }); + }); +}); From 1dc4a046339182b2a0234ed5ff209bf8cab14d8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:07:22 +0000 Subject: [PATCH 5/5] Fix lint, build, and test errors in test files Co-authored-by: frouaix <876178+frouaix@users.noreply.github.com> --- src/tests/ids.test.ts | 2 +- src/tests/rmdCalculator.test.ts | 2 +- src/tests/validation.test.ts | 20 +++++++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/tests/ids.test.ts b/src/tests/ids.test.ts index dfd5108..0d30c31 100644 --- a/src/tests/ids.test.ts +++ b/src/tests/ids.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 François Rouaix -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { generateAccountId } from '../utils/ids'; describe('ids', () => { diff --git a/src/tests/rmdCalculator.test.ts b/src/tests/rmdCalculator.test.ts index 77cf29f..8a3f5c9 100644 --- a/src/tests/rmdCalculator.test.ts +++ b/src/tests/rmdCalculator.test.ts @@ -330,7 +330,7 @@ describe('RMD Calculator', () => { const rmd76 = calculateRMD(balance, 76); balance = balance.minus(rmd76); - const rmd77 = calculateRMD(balance, 77); + calculateRMD(balance, 77); // Year 77 RMD calculated // Balance should decrease each year expect(balance.lt(toBig(100000))).toBe(true); diff --git a/src/tests/validation.test.ts b/src/tests/validation.test.ts index 99f8d57..04a6aa8 100644 --- a/src/tests/validation.test.ts +++ b/src/tests/validation.test.ts @@ -109,7 +109,6 @@ describe('validation', () => { accountType: 'traditional', balance: 500000, nickname: 'My 401k', - birthYear: 1960, }; expect(validateAccount(account)).toBe(true); @@ -122,6 +121,7 @@ describe('validation', () => { balance: 200000, costBasis: 150000, nickname: 'Brokerage', + dividendYield: 0.02, }; expect(validateAccount(account)).toBe(true); @@ -155,11 +155,13 @@ describe('validation', () => { }); it('should return false when balance is not a number', () => { + // Using Partial to test invalid data const account = { accountId: 'roth-1', accountType: 'roth', - balance: 'invalid' as any, - }; + nickname: 'Test', + balance: 'invalid' as unknown as number, + } as Account; expect(validateAccount(account)).toBe(false); }); @@ -168,6 +170,7 @@ describe('validation', () => { const account: Account = { accountId: 'roth-1', accountType: 'roth', + nickname: 'Test', balance: 0, }; @@ -178,6 +181,7 @@ describe('validation', () => { const account: Account = { accountId: 'roth-1', accountType: 'roth', + nickname: 'Test', balance: -100, }; @@ -188,6 +192,7 @@ describe('validation', () => { const account: Account = { accountId: '', accountType: 'roth', + nickname: 'Test', balance: 100000, }; @@ -195,11 +200,13 @@ describe('validation', () => { }); it('should return false when accountType is empty string', () => { - const account: Account = { + // Using invalid type cast to test validation + const account = { accountId: 'roth-1', - accountType: '' as any, + accountType: '', + nickname: 'Test', balance: 100000, - }; + } as unknown as Account; expect(validateAccount(account)).toBe(false); }); @@ -210,7 +217,6 @@ describe('validation', () => { accountType: 'traditional', balance: 500000, nickname: 'My IRA', - birthYear: 1960, }; expect(validateAccount(account)).toBe(true);