Skip to content
Merged
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
128 changes: 102 additions & 26 deletions app/interactives/savings-calculator/lib/calculations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,25 @@ export function calculateYearlyBreakdown(
let balance = currentBalance;
const totalYears = Math.ceil(totalPeriods / periodsPerYear);

for (let year = 1; year <= totalYears; year++) {
for (let yearNumber = 1; yearNumber <= totalYears; yearNumber++) {
const startingBalance = balance;
const periodsInThisYear = Math.min(periodsPerYear, totalPeriods - (year - 1) * periodsPerYear);
const periodsInThisYear = Math.min(
periodsPerYear,
totalPeriods - (yearNumber - 1) * periodsPerYear
);
let yearlyContributions = 0;
let yearlyInterest = 0;

const futureValueOfInitial = startingBalance * Math.pow(1 + ratePerPeriod, periodsInThisYear);
const futureValueOfAnnuity =
contribution * ((Math.pow(1 + ratePerPeriod, periodsInThisYear) - 1) / ratePerPeriod);
const futureValueOfAnnuity = ratePerPeriod === 0
? contribution * periodsInThisYear
: contribution * ((Math.pow(1 + ratePerPeriod, periodsInThisYear) - 1) / ratePerPeriod);
balance = futureValueOfInitial + futureValueOfAnnuity;
yearlyContributions = contribution * periodsInThisYear;
yearlyInterest = balance - (startingBalance + yearlyContributions);

breakdown.push({
year,
year: Math.min(yearNumber, totalPeriods / periodsPerYear),
startingBalance,
contributions: yearlyContributions,
interestEarned: yearlyInterest,
Expand Down Expand Up @@ -129,29 +133,52 @@ export function calculateMonthySavings(
const remainingAmount = savingsGoal - futureValueOfInitial;

let results: CalculationResults;
let breakdownPeriods = totalPeriods;
let breakdownContribution = 0;
let finalPartialContribution = 0;

if (remainingAmount <= 0) {
const periodsToGoal =
currentBalance < savingsGoal && ratePerPeriod > 0
? Math.log(savingsGoal / currentBalance) / Math.log(1 + ratePerPeriod)
: 0;
const monthsToGoal = Math.ceil(periodsToGoal * (12 / periodsPerYear) - 1e-10);
breakdownPeriods = periodsToGoal;

results = {
contributionPerPeriod: 0,
totalDeposited: currentBalance,
interestEarned: savingsGoal - currentBalance,
finalBalance: savingsGoal,
timeInMonths: totalTimeInMonths,
timeInMonths: monthsToGoal,
};
} else if (totalPeriods <= 0) {
results = {
contributionPerPeriod: NaN,
totalDeposited: NaN,
interestEarned: NaN,
finalBalance: NaN,
timeInMonths: NaN,
};
} else if (totalPeriods < 1) {
const requiredContributionPerPeriod = savingsGoal - currentBalance;
// Let the existing balance compound through the partial period, then make
// one contribution at the selected horizon to close the remaining gap.
const requiredContributionPerPeriod = remainingAmount;
const totalDeposited = currentBalance + requiredContributionPerPeriod;
finalPartialContribution = requiredContributionPerPeriod;
results = {
contributionPerPeriod: requiredContributionPerPeriod,
totalDeposited: totalDeposited,
interestEarned: 0,
interestEarned: savingsGoal - totalDeposited,
finalBalance: savingsGoal,
timeInMonths: totalTimeInMonths,
};
} else {
const requiredContributionPerPeriod =
remainingAmount / ((Math.pow(1 + ratePerPeriod, totalPeriods) - 1) / ratePerPeriod);
const requiredContributionPerPeriod = ratePerPeriod === 0
? remainingAmount / totalPeriods
: remainingAmount / ((Math.pow(1 + ratePerPeriod, totalPeriods) - 1) / ratePerPeriod);
const totalDeposited = currentBalance + requiredContributionPerPeriod * totalPeriods;
breakdownContribution = requiredContributionPerPeriod;
results = {
contributionPerPeriod: requiredContributionPerPeriod,
totalDeposited: totalDeposited,
Expand All @@ -162,13 +189,22 @@ export function calculateMonthySavings(
}

const breakdown = calculateYearlyBreakdown(
results.contributionPerPeriod,
totalPeriods,
breakdownContribution,
breakdownPeriods,
currentBalance,
compounding,
interestRate
);

if (finalPartialContribution > 0 && breakdown.length > 0) {
const finalRow = breakdown[breakdown.length - 1];
breakdown[breakdown.length - 1] = {
...finalRow,
contributions: finalPartialContribution,
endingBalance: savingsGoal,
};
}

return { results, breakdown };
}

Expand Down Expand Up @@ -253,6 +289,8 @@ export function calculateTimeToGoal(
const { periodsPerYear, ratePerPeriod } = getCompoundingParams(compounding, interestRate);

let results: CalculationResults;
let periodsForBreakdown = 0;
let finalContributionReduction = 0;

if (contributionPerPeriod === 0) {
// Interest-only growth (no contributions)
Expand All @@ -264,34 +302,63 @@ export function calculateTimeToGoal(
finalBalance: savingsGoal,
timeInMonths: 0,
};
} else if (ratePerPeriod === 0) {
// No interest and no contributions: impossible to reach goal
results = {
contributionPerPeriod: 0,
totalDeposited: currentBalance,
interestEarned: 0,
finalBalance: currentBalance,
timeInMonths: NaN,
};
} else {
// Use compound interest formula: n = log(FV/PV) / log(1 + r)
const ratio = savingsGoal / currentBalance;
const periodsToGoal = Math.log(ratio) / Math.log(1 + ratePerPeriod);
const months = Math.round(periodsToGoal * (12 / periodsPerYear));
const interestEarned = savingsGoal - currentBalance;
const exactPeriodsToGoal = Math.log(ratio) / Math.log(1 + ratePerPeriod);
const periodsToGoal = Math.ceil(exactPeriodsToGoal - 1e-10);
const months = Math.ceil(periodsToGoal * (12 / periodsPerYear) - 1e-10);
const finalBalance = currentBalance * Math.pow(1 + ratePerPeriod, periodsToGoal);
const interestEarned = finalBalance - currentBalance;
periodsForBreakdown = periodsToGoal;

results = {
contributionPerPeriod: 0,
totalDeposited: currentBalance,
interestEarned: interestEarned,
finalBalance: savingsGoal,
finalBalance: finalBalance,
timeInMonths: months,
};
}
} else if (contributionPerPeriod > 0) {
// With contributions
const numerator = savingsGoal + contributionPerPeriod / ratePerPeriod;
const denominator = currentBalance + contributionPerPeriod / ratePerPeriod;
const periodsToGoal = Math.log(numerator / denominator) / Math.log(1 + ratePerPeriod);
const months = Math.round(periodsToGoal * (12 / periodsPerYear));
let exactPeriodsToGoal: number;

if (ratePerPeriod === 0) {
// No interest: simple linear calculation
exactPeriodsToGoal = (savingsGoal - currentBalance) / contributionPerPeriod;
} else {
// With interest: use logarithmic formula
const numerator = savingsGoal + contributionPerPeriod / ratePerPeriod;
const denominator = currentBalance + contributionPerPeriod / ratePerPeriod;
exactPeriodsToGoal = Math.log(numerator / denominator) / Math.log(1 + ratePerPeriod);
}

const periodsToGoal = Math.ceil(exactPeriodsToGoal - 1e-10);
const months = Math.ceil(periodsToGoal * (12 / periodsPerYear) - 1e-10);
const futureValueOfInitial = currentBalance * Math.pow(1 + ratePerPeriod, periodsToGoal);
const futureValueOfAnnuity =
contributionPerPeriod *
((Math.pow(1 + ratePerPeriod, periodsToGoal) - 1) / ratePerPeriod);
const finalBalance = futureValueOfInitial + futureValueOfAnnuity;
const totalDeposited = currentBalance + contributionPerPeriod * periodsToGoal;
const futureValueOfAnnuity = ratePerPeriod === 0
? contributionPerPeriod * periodsToGoal
: contributionPerPeriod * ((Math.pow(1 + ratePerPeriod, periodsToGoal) - 1) / ratePerPeriod);
const balanceWithFullFinalContribution = futureValueOfInitial + futureValueOfAnnuity;
// The last scheduled contribution may be smaller than the regular amount.
finalContributionReduction = Math.min(
contributionPerPeriod,
Math.max(0, balanceWithFullFinalContribution - savingsGoal)
);
const finalBalance = balanceWithFullFinalContribution - finalContributionReduction;
const totalDeposited =
currentBalance + contributionPerPeriod * periodsToGoal - finalContributionReduction;
periodsForBreakdown = periodsToGoal;

results = {
contributionPerPeriod: contributionPerPeriod,
Expand All @@ -313,11 +380,20 @@ export function calculateTimeToGoal(

const breakdown = calculateYearlyBreakdown(
results.contributionPerPeriod,
results.timeInMonths / (12 / periodsPerYear),
periodsForBreakdown,
currentBalance,
compounding,
interestRate
);

if (finalContributionReduction > 0 && breakdown.length > 0) {
const finalRow = breakdown[breakdown.length - 1];
breakdown[breakdown.length - 1] = {
...finalRow,
contributions: finalRow.contributions - finalContributionReduction,
endingBalance: results.finalBalance,
};
}

return { results, breakdown };
}
Loading