A partial loan prepayment sounds like a subtraction problem: take the extra payment off the remaining balance. That part is easy. The next step is where implementations often go wrong.
After the balance falls, a lender can usually rebuild the plan in at least two ways:
- keep the remaining number of payments and lower the payment amount;
- keep the payment near its old level and shorten the remaining term.
Both plans start with the same reduced balance. They do not have the same future cash flows, interest cost, or end date. In code, that means they need separate functions and tests, rather than a single prepay() flag bolted onto an amortization loop.
This article models a fixed-rate, monthly, level-payment loan. It assumes a payment is made at the end of each month, the rate does not change, and the prepayment happens immediately after a scheduled payment. It does not model lender-specific rounding, daily interest between payment dates, fees, or contractual eligibility rules.
Start with the balance at the prepayment date
Let:
-
Pbe the original principal; -
rbe the monthly rate; -
Nbe the original number of monthly payments; -
kbe the number of payments already made.
For a level-payment loan, the original payment is:
A = P × r × (1 + r)^N / ((1 + r)^N - 1)
The balance immediately after payment k can be calculated without retaining every previous schedule row:
B(k) = P × (1 + r)^k - A × (((1 + r)^k - 1) / r)
The zero-rate case needs its own branch. Dividing by r is not a harmless detail; it is a real bug when a test uses 0% financing.
function monthlyPayment(principal, annualRate, months) {
if (months <= 0) throw new RangeError("months must be positive");
const r = annualRate / 12;
if (r === 0) return principal / months;
const growth = (1 + r) ** months;
return (principal * r * growth) / (growth - 1);
}
function balanceAfterPayments({ principal, annualRate, months }, paidMonths) {
if (paidMonths <= 0) return principal;
if (paidMonths >= months) return 0;
const r = annualRate / 12;
const payment = monthlyPayment(principal, annualRate, months);
if (r === 0) {
return principal - payment * paidMonths;
}
const growth = (1 + r) ** paidMonths;
const balance = principal * growth - payment * ((growth - 1) / r);
// Formula arithmetic can leave a tiny negative value at the final payment.
return Math.max(0, balance);
}
For an original loan of 1,000,000 at 3.5% for 360 months, the monthly payment is about 4,490.45. After 60 payments, the model gives a balance of about 896,970.73.
If the borrower then prepays 200,000, the common starting point for both revised plans is:
896,970.73 - 200,000.00 = 696,970.73
Do this subtraction once. From this point on, the two choices diverge.
Option 1: keep the term, lower the payment
In the lower-payment option, the remaining term stays at N - k. The new payment is simply a new annuity payment for the reduced balance.
function lowerPaymentAfterPrepayment(loan, paidMonths, prepayment) {
const oldBalance = balanceAfterPayments(loan, paidMonths);
const newPrincipal = Math.max(0, oldBalance - prepayment);
const remainingMonths = loan.months - paidMonths;
if (newPrincipal === 0) {
return { newPrincipal, payment: 0, remainingMonths: 0 };
}
return {
newPrincipal,
payment: monthlyPayment(newPrincipal, loan.annualRate, remainingMonths),
remainingMonths,
};
}
With the example inputs, there are 300 months left. The revised payment is about 3,489.20 per month.
This is a useful option when the product goal is a lower required monthly outflow. It still reduces future interest because the balance falls, but interest continues to accrue across the same 300 remaining periods.
Option 2: keep the payment, shorten the term
For the shorter-term option, keep a target payment close to the original monthly payment and solve for a new number of periods. Rearranging the payment formula gives:
n = -log(1 - B × r / A) / log(1 + r)
n is normally fractional. A real repayment plan needs whole payment periods, so round up and let the final payment absorb the small residual. Rounding down can create a negative final balance or an impossible payment schedule.
function monthsForPayment(principal, annualRate, payment) {
const r = annualRate / 12;
if (principal === 0) return 0;
if (payment <= 0) throw new RangeError("payment must be positive");
if (r === 0) return Math.ceil(principal / payment);
if (payment <= principal * r) {
throw new RangeError("payment does not cover monthly interest");
}
return Math.ceil(
-Math.log(1 - (principal * r) / payment) / Math.log(1 + r)
);
}
function shorterTermAfterPrepayment(loan, paidMonths, prepayment) {
const oldBalance = balanceAfterPayments(loan, paidMonths);
const newPrincipal = Math.max(0, oldBalance - prepayment);
if (newPrincipal === 0) {
return { newPrincipal, payment: 0, remainingMonths: 0 };
}
const targetPayment = monthlyPayment(
loan.principal,
loan.annualRate,
loan.months
);
const remainingMonths = monthsForPayment(
newPrincipal,
loan.annualRate,
targetPayment
);
return {
newPrincipal,
payment: monthlyPayment(newPrincipal, loan.annualRate, remainingMonths),
remainingMonths,
};
}
The same example finishes in about 207 remaining payments, rather than 300. Because the period count has changed, the recalculated regular payment may differ from the old payment by a few cents or units depending on the rounding policy. In this implementation it is about 4,489.85.
That small difference is intentional. The term is the constraint; the payment is derived from it. If a lender promises an exact payment instead, generate the schedule with that exact payment and calculate a final, nonstandard payment.
Keep prepayment fees outside the balance reduction
A common modeling mistake is subtracting a fee from the loan balance alongside the prepayment. These are different cash flows.
If a borrower pays 200,000 toward principal and 2,000 in a penalty, the revised principal is still reduced by 200,000. The 2,000 is a separate out-of-pocket cost. It belongs in a comparison of net interest saved, not in the amortized principal.
function netInterestSaved(oldFutureInterest, newFutureInterest, penalty = 0) {
return oldFutureInterest - newFutureInterest - penalty;
}
To obtain oldFutureInterest and newFutureInterest, create schedules for the remaining old plan and the revised plan, then sum their interest fields. That is one of the places where a schedule is useful. The balance formula avoids storing 360 historical rows; it does not mean an application should never generate future rows for a user-facing table.
Test the invariants, not just one displayed number
Financial code gets fragile when tests only assert a screenshot value. These checks cover the two paths and a few failure cases.
import test from "node:test";
import assert from "node:assert/strict";
const loan = {
principal: 1_000_000,
annualRate: 0.035,
months: 360,
};
test("balance after 60 payments matches the model", () => {
const balance = balanceAfterPayments(loan, 60);
assert.ok(Math.abs(balance - 896_970.73) < 0.01);
});
test("lower-payment option preserves the remaining term", () => {
const result = lowerPaymentAfterPrepayment(loan, 60, 200_000);
assert.equal(result.remainingMonths, 300);
assert.ok(Math.abs(result.payment - 3_489.20) < 0.01);
});
test("shorter-term option preserves a payment near the original", () => {
const result = shorterTermAfterPrepayment(loan, 60, 200_000);
assert.equal(result.remainingMonths, 207);
assert.ok(Math.abs(result.payment - 4_489.85) < 0.01);
});
test("0% loans do not divide by zero", () => {
const result = lowerPaymentAfterPrepayment(
{ principal: 12_000, annualRate: 0, months: 12 },
3,
3_000
);
assert.equal(result.remainingMonths, 9);
assert.equal(result.payment, 666.6666666666666);
});
Production code should add tests for a full payoff, an attempt to prepay more than the balance, a payment too small to cover interest, and the final scheduled payment. Whether currency is rounded every month or only for display also needs an explicit policy. Do not let toFixed(2) quietly become the accounting model.
Validate the cash flows people will actually see
The formula is only one layer of the feature. A useful interface should make the changed constraint visible:
| After the prepayment | What stays fixed | What changes |
|---|---|---|
| Lower payment | Remaining months | Monthly payment falls |
| Shorter term | Payment near the old amount | Payoff date moves earlier |
An interactive check is also handy when debugging input handling. To try the same kind of comparison with a remaining balance, a partial prepayment, and either recalculation path, use this loan prepayment calculator. Treat its result as a model output and compare it with the lender's revised schedule before making a payment decision.
The practical lesson is simple: a partial prepayment changes the principal first. The choice to hold the term or the payment constant is a separate business rule. Put that choice in your API, code paths, tests, and UI. It should never be an accidental side effect of the amortization formula.
Top comments (0)