Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: volta-cli/action@v4
with:
node-version: ${{ matrix.node-version }}
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: volta-cli/action@v4
with:
node-version: ${{ matrix.node-version }}
Expand All @@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
needs: test-and-build
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
# Fetch all history so we can switch back to the branch
fetch-depth: 0
Expand All @@ -34,9 +34,9 @@ jobs:
run: git checkout main

# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: "20.x"
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- run: npm install
- run: npm run build
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
dist
coverage
package-lock.json
package-lock.json
pnpm-lock.yaml
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

## 1.5.0 — 2026-04-21

Added:

- `solveContributionForGoal(options)` / `solveYearsToGoal(options)` — reverse savings-goal solvers using the future-value-of-annuity formula. Solve for either the monthly contribution needed to reach a target by a date, or the years needed at a given contribution.
- `earlyMortgagePayoff(options)` — simulate a repayment mortgage with extra monthly payments and one-off lump sums. Returns months saved, interest saved, and a per-month schedule versus the baseline term.
- `fireNumber(options)` / `yearsToFire(options)` — FIRE number from annual spend and safe withdrawal rate, and years-to-FIRE from current savings, contributions, and expected return.
- Shared `toDecimalRate` helper extracted to `calc/helpers.ts`. Used by the three new calculators (`savingsGoal`, `earlyPayoff`, `fireNumber`) so their rate inputs accept either percentage (6) or decimal (0.06). The existing `compoundInterestPerPeriod` and `mortgageCalculator` still treat rates as percentages only — unchanged behaviour.

Unchanged:

- `compoundInterestPerPeriod`, `mortgageCalculator`, `PMT`, `calcInterestPayments`, `calcTotalPayments`, `compoundInterestOverYears` — existing API is untouched.

Test coverage: 45 tests across 5 files.
104 changes: 95 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
# compound interest
# @jdizm/finance-calculator

A finance calculator to:

- calculate `compound interest` over a period of time with different investment types.
- calculate `mortgage` repayments and interest only payments.
A TypeScript finance library for compound interest, mortgage, savings goal, early mortgage payoff, and FIRE number calculations. Client + server, dual ESM/CJS.

### Features

This calculator can be used to calculate the future value of a present lump sum with contributions, debt repayment or mortgage. The calculator compounds interest per period and can be used to calculate the value of investments or debt over a period of time.
Calculate the future value of a present lump sum with contributions, debt repayment or mortgage. Compounds interest per period and returns rich per-period data suitable for charting.

For example, if you invest $1,000 today at a 7% annual interest rate, how much will $1,000 be worth if invested for 10 years?
For example, if you invest $1,000 today at a 7% annual interest rate, how much will $1,000 be worth invested for 10 years? Or: how much per month do I need to save to hit £100k in 10 years? Or: what's my FIRE number on £40k/year spend?

#### Compound Interest

Expand All @@ -22,6 +19,17 @@ For example, if you invest $1,000 today at a 7% annual interest rate, how much w

- [x] 1. calculate mortgage - repayment
- [x] 2. calculate mortgage - interest only
- [x] 3. simulate early mortgage payoff with extra monthly + lump sum payments (v1.5.0)

#### Savings goal (v1.5.0)

- [x] 1. solve for monthly contribution needed to reach a target by a date
- [x] 2. solve for years needed at a given monthly contribution

#### FIRE (v1.5.0)

- [x] 1. calculate FIRE number from annual spend + withdrawal rate
- [x] 2. solve for years to FIRE given savings, contributions, and return rate

### Installation

Expand All @@ -39,12 +47,28 @@ Importing the library:

```js
// CommonJS
const { compoundInterestPerPeriod, mortgageCalculator } = require("@jdizm/finance-calculator");
const {
compoundInterestPerPeriod,
mortgageCalculator,
solveContributionForGoal,
solveYearsToGoal,
earlyMortgagePayoff,
fireNumber,
yearsToFire,
} = require("@jdizm/finance-calculator");
```

```js
// ESM
import { compoundInterestPerPeriod, mortgageCalculator } from "@jdizm/finance-calculator";
import {
compoundInterestPerPeriod,
mortgageCalculator,
solveContributionForGoal,
solveYearsToGoal,
earlyMortgagePayoff,
fireNumber,
yearsToFire,
} from "@jdizm/finance-calculator";
```

#### Mortgage Calculator
Expand Down Expand Up @@ -147,6 +171,68 @@ What are investment types? These are used to calculate the final results:
2. debtRepayment - a borrowed investment calculated over a period of time with a decreasing principal or interest only payments
3. contribution - a single investment calculated over a period of time with additional contribution

#### Savings Goal Calculator (v1.5.0)

Solve for either the monthly contribution needed to reach a target or the years required given a contribution. Uses the future-value-of-annuity formula.

```ts
// How much per month to reach £100k in 10 years at 6% with £5k seed?
const result = solveContributionForGoal({
target: 100_000,
years: 10,
annualRate: 6,
startingBalance: 5_000,
});
// -> { contributionPerMonth: 609.39, totalContributions: 78_126, interestEarned: 16_874, ... }

// How many years to reach £100k at £500/mo?
const years = solveYearsToGoal({
target: 100_000,
contributionPerMonth: 500,
annualRate: 7,
startingBalance: 5_000,
});
// -> 12.44
```

#### Early Mortgage Payoff (v1.5.0)

Simulate extra monthly payments and one-off lump sums against a standard repayment mortgage. Returns months + interest saved plus a per-month schedule.

```ts
const result = earlyMortgagePayoff({
homeValue: 300_000,
deposit: 30_000,
interestRate: 5,
years: 25,
extraMonthly: 200,
lumpSums: [{ month: 24, amount: 10_000 }],
});
// -> {
// baselineMonths: 300, baselineTotalInterest, newMonths,
// monthsSaved, interestSaved, baseMonthlyPayment, schedule: [...]
// }
```

#### FIRE Number (v1.5.0)

Calculate the savings target for financial independence, then the years needed to reach it.

```ts
const fire = fireNumber({ annualSpend: 40_000, withdrawalRate: 4 });
// -> { target: 1_000_000, monthlyIncome: 3_333.33, ... }

const years = yearsToFire({
currentSavings: 50_000,
annualContribution: 20_000,
annualReturn: 7,
target: fire.target,
});
// -> 19.85
```

The newer calculators (`savingsGoal`, `earlyPayoff`, `fireNumber`) accept rates as either percentage (`6`) or decimal (`0.06`) and normalise internally via `toDecimalRate`. The older calculators (`compoundInterestPerPeriod`, `mortgageCalculator`) treat rates as percentages only (e.g. pass `6`, not `0.06`).

### Building with Typescript

[Only certain tsconfig.json fields are respected when building with esbuild.](https://esbuild.github.io/content-types/#tsconfig-json)
Expand Down
23 changes: 22 additions & 1 deletion calc/compoundInterest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@ describe("compoundInterestPerPeriod", () => {
["1", [251625, 253250, 254875, 256500, 258125, 259750, 261375, 263000, 264625, 266250, 267875, 269500]],
[
"2",
[271251.75, 273003.5, 274755.25, 276507, 278258.75, 280010.5, 281762.25, 283514, 285265.75, 287017.5]
[
271251.75, 273003.5, 274755.25, 276507, 278258.75, 280010.5, 281762.25, 283514, 285265.75, 287017.5,
288769.25, 290521
]
]
]),
interestPerAnnum: [19500, 21021],
Expand Down Expand Up @@ -618,4 +621,22 @@ describe("compoundInterestPerPeriod", () => {
);
});
});

describe("invalid option combinations", () => {
it("throws when debtRepayment is combined with accrualOfPaymentsPerAnnum", () => {
// Intentional invalid combo for runtime guard coverage — cast silences the shape check.
const options = {
type: "debtRepayment",
principal: 150_000,
rate: 4,
years: 25,
paymentsPerAnnum: 12,
debtRepayment: { interestRate: 6, type: "repayment" },
accrualOfPaymentsPerAnnum: true
} as unknown as IOptions;
expect(() => compoundInterestPerPeriod(options)).toThrow(
"Invalid option combination: debtRepayment and accrualOfPaymentsPerAnnum"
);
});
});
});
109 changes: 109 additions & 0 deletions calc/earlyPayoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { earlyMortgagePayoff } from "./earlyPayoff";

describe("earlyMortgagePayoff", () => {
it("matches the baseline schedule when no extras are provided", () => {
const result = earlyMortgagePayoff({
homeValue: 300_000,
deposit: 30_000,
interestRate: 5,
years: 25
});

expect(result.principal).toBe(270_000);
expect(result.baselineMonths).toBe(300);
// Rounding PMT to 2dp can leave a trailing tiny balance; allow one extra month
expect(result.newMonths).toBeGreaterThanOrEqual(300);
expect(result.newMonths).toBeLessThanOrEqual(301);
expect(result.monthsSaved).toBe(0);
expect(Math.abs(result.interestSaved)).toBeLessThan(5);
expect(result.schedule.length).toBe(result.newMonths);
expect(result.schedule[result.schedule.length - 1]!.balance).toBeCloseTo(0, 1);
});

it("saves months and interest with an extra monthly payment", () => {
const result = earlyMortgagePayoff({
homeValue: 300_000,
deposit: 30_000,
interestRate: 5,
years: 25,
extraMonthly: 200
});

expect(result.newMonths).toBeLessThan(300);
expect(result.monthsSaved).toBeGreaterThan(24);
expect(result.interestSaved).toBeGreaterThan(20_000);
expect(result.schedule[result.schedule.length - 1]!.balance).toBeCloseTo(0, 1);
});

it("applies lump sum payments in the correct month and clears the balance", () => {
const result = earlyMortgagePayoff({
homeValue: 200_000,
deposit: 20_000,
interestRate: 4,
years: 20,
lumpSums: [
{ month: 12, amount: 5_000 },
{ month: 60, amount: 15_000 }
]
});

expect(result.schedule[11]!.lumpSum).toBe(5_000);
expect(result.schedule[59]!.lumpSum).toBe(15_000);
expect(result.schedule[result.schedule.length - 1]!.balance).toBeCloseTo(0, 1);
expect(result.monthsSaved).toBeGreaterThan(0);
});

it("throws on each invalid input branch", () => {
const valid = { homeValue: 200_000, deposit: 20_000, interestRate: 5, years: 25 };
expect(() => earlyMortgagePayoff({ ...valid, homeValue: 0 })).toThrow("homeValue must be greater than 0");
expect(() => earlyMortgagePayoff({ ...valid, deposit: -1 })).toThrow("deposit cannot be negative");
expect(() => earlyMortgagePayoff({ ...valid, deposit: 250_000 })).toThrow("deposit cannot exceed homeValue");
expect(() => earlyMortgagePayoff({ ...valid, years: 0 })).toThrow("years must be greater than 0");
expect(() => earlyMortgagePayoff({ ...valid, interestRate: -1 })).toThrow("interestRate cannot be negative");
expect(() => earlyMortgagePayoff({ ...valid, extraMonthly: -10 })).toThrow("extraMonthly cannot be negative");
expect(() => earlyMortgagePayoff({ ...valid, lumpSums: [{ month: 12, amount: -1 }] })).toThrow(
"lump sum amount cannot be negative"
);
expect(() =>
earlyMortgagePayoff({
...valid,
lumpSums: [{ month: 0, amount: 1_000 }]
})
).toThrow("lump sum month must be a positive integer");
});

it("rejects non-integer lump sum months (would never match the whole-month schedule)", () => {
expect(() =>
earlyMortgagePayoff({
homeValue: 200_000,
deposit: 20_000,
interestRate: 5,
years: 25,
lumpSums: [{ month: 1.5, amount: 1_000 }]
})
).toThrow("lump sum month must be a positive integer");
});

it("rejects terms longer than the 100-year simulation cap", () => {
expect(() => earlyMortgagePayoff({ homeValue: 200_000, deposit: 20_000, interestRate: 5, years: 101 })).toThrow(
"years cannot exceed 100"
);
});

it("treats decimal rate input (0.05) the same as percentage (5)", () => {
const fromPct = earlyMortgagePayoff({
homeValue: 300_000,
deposit: 30_000,
interestRate: 5,
years: 25
});
const fromDec = earlyMortgagePayoff({
homeValue: 300_000,
deposit: 30_000,
interestRate: 0.05,
years: 25
});
expect(fromPct.baseMonthlyPayment).toBeCloseTo(fromDec.baseMonthlyPayment, 1);
});
});
Loading
Loading