An advertised interest rate is not always the rate implied by a borrower's cash flows.
Consider a 12-month loan for $100,000 at a 10% nominal annual rate. If the lender deducts a $3,000 origination fee before disbursement, the borrower receives $97,000 but still repays a schedule calculated from $100,000. The contractual rate has not changed; the amount of money actually available to the borrower has.
For software that compares loan offers, the useful question is therefore not just “what rate is printed on the quote?” It is:
What annual rate makes the present value of every scheduled outflow equal to the money the borrower actually receives?
That is an internal-rate-of-return problem. This post builds a small TypeScript implementation for regular monthly payments, explains the cash-flow convention, and shows how to test the awkward cases that simple formulas tend to hide.
This is a programming model for comparing disclosed cash flows, not financial, legal, or lending advice. Real products may have irregular dates, optional products, taxes, penalties, or rules that require a different disclosure method.
Why the nominal rate is not enough
The usual annuity-payment formula is useful when you know a principal P, a monthly rate r, and a number of payments n:
payment = P × r × (1 + r)^n / ((1 + r)^n - 1)
It answers, “what payment amortizes this contractual balance?” It does not answer, “what annual return is implied after an upfront fee reduces the disbursement?”
For that second question, define the borrower's cash flows:
| Time | Borrower's cash flow |
|---|---|
| At disbursement | +actualReceived |
| At the end of month 1 | -payment - monthlyFee |
| ... | ... |
| At the end of the final month | -payment - monthlyFee - balloon |
The real annualized rate R is the rate that makes net present value (NPV) zero:
0 = actualReceived - Σ(payment[t] / (1 + R)^(t / 12))
The sign convention matters. Amounts received are positive; repayments and borrower-paid fees are negative. Pick one convention and preserve it throughout the model and its tests.
Build the payment schedule first
The IRR calculation should consume a cash-flow schedule. That keeps the pricing logic separate from the solver and makes it easier to extend later for monthly fees or a balloon payment.
export function annuityPayment(
principal: number,
nominalAnnualRate: number,
months: number,
): number {
if (principal < 0 || months < 1) {
throw new Error("principal must be non-negative and months must be positive");
}
const monthlyRate = nominalAnnualRate / 12;
if (monthlyRate === 0) {
return principal / months;
}
const factor = Math.pow(1 + monthlyRate, months);
return (principal * monthlyRate * factor) / (factor - 1);
}
export function regularPayments(
payment: number,
months: number,
monthlyFee = 0,
balloon = 0,
): number[] {
if (months < 1) throw new Error("months must be positive");
return Array.from({ length: months }, (_, index) =>
payment + monthlyFee + (index === months - 1 ? balloon : 0),
);
}
Notice that monthlyFee is added to every payment and balloon is added only to the final one. Both are part of the borrower's future outflows, so both belong in the rate calculation. An upfront fee is different: if it is deducted before the borrower receives the funds, it reduces actualReceived.
Do not round the schedule to cents before solving unless the contract itself requires every installment to be rounded that way. Keep full precision internally, then round values only for display. Repeated early rounding can create a small residual balance and move the solved rate.
Solve the annual rate with bisection
For a conventional loan—one initial inflow followed by only outflows—NPV falls as the discount rate rises. That makes bisection a clear, robust solver: find two rates that put NPV on opposite sides of zero, repeatedly halve the interval, and keep the half that contains the root.
export function annualIrr(
actualReceived: number,
payments: number[],
): number | null {
if (actualReceived <= 0 || payments.length === 0 || payments.every(p => p <= 0)) {
return null;
}
const npv = (annualRate: number): number =>
actualReceived -
payments.reduce(
(sum, payment, index) =>
sum + payment / Math.pow(1 + annualRate, (index + 1) / 12),
0,
);
// Annual rates below -100% are not meaningful in this model.
let low = -0.9999;
let high = 1;
// Expand the upper bound until NPV changes sign, if it can.
while (npv(high) < 0 && high < 10_000) {
high *= 2;
}
if (npv(low) * npv(high) > 0) {
return null;
}
for (let iteration = 0; iteration < 180; iteration += 1) {
const mid = (low + high) / 2;
if (npv(mid) > 0) high = mid;
else low = mid;
}
return (low + high) / 2;
}
This code solves an effective annual rate directly. The exponent (index + 1) / 12 means that a payment one month after disbursement is discounted for one-twelfth of a year. It is not equivalent to calculating a monthly rate and simply multiplying it by 12.
The expanded upper bound is intentional. A fee can make the implied annual rate much larger than the quoted rate, particularly for a short loan. Returning null when a bracket cannot be found is safer than presenting a plausible-looking number for unsupported cash flows.
Work through a loan with an upfront fee
Here is the $100,000, 12-month example. The quoted 10% annual rate determines the scheduled monthly payment; the $3,000 fee changes the amount received.
const contractAmount = 100_000;
const nominalAnnualRate = 0.10;
const months = 12;
const upfrontFee = 3_000;
const payment = annuityPayment(contractAmount, nominalAnnualRate, months);
const payments = regularPayments(payment, months);
const realApr = annualIrr(contractAmount - upfrontFee, payments);
console.log({
payment: payment.toFixed(2),
actualReceived: contractAmount - upfrontFee,
realApr: `${(realApr! * 100).toFixed(2)}%`,
});
// { payment: '8791.59', actualReceived: 97000, realApr: '16.99%' }
The nominal rate is 10%, but the model returns approximately 16.99% annually because the borrower uses $97,000 while repaying the same 12 installments. With no fee, this exact schedule produces an effective annual rate of approximately 10.47%, which is the monthly-compounded equivalent of a 10% nominal annual rate.
That distinction is often missed in dashboards: a nominal annual rate is an input to an amortization formula, while an annualized IRR is an output of the entire cash-flow schedule.
Test the assumptions, not just the happy path
The solver is small enough that tests can be specific. The following examples use Vitest, but the assertions translate directly to Jest or another test runner.
import { describe, expect, it } from "vitest";
describe("annualIrr", () => {
it("matches the effective annual rate when no fee is deducted", () => {
const payment = annuityPayment(100_000, 0.10, 12);
const rate = annualIrr(100_000, regularPayments(payment, 12));
expect(rate).not.toBeNull();
expect(rate!).toBeCloseTo(0.1047, 4);
});
it("increases when an upfront fee reduces the disbursement", () => {
const payment = annuityPayment(100_000, 0.10, 12);
const rate = annualIrr(97_000, regularPayments(payment, 12));
expect(rate).not.toBeNull();
expect(rate!).toBeCloseTo(0.1699, 4);
});
it("includes a final balloon payment", () => {
const payments = regularPayments(900, 12, 0, 5_000);
const rate = annualIrr(10_000, payments);
expect(rate).not.toBeNull();
expect(rate!).toBeGreaterThan(0);
});
it("rejects a schedule with no borrower payments", () => {
expect(annualIrr(10_000, [0, 0, 0])).toBeNull();
});
});
The third test does not hard-code a rate because its purpose is structural: it proves that the final balance enters the solver rather than silently disappearing. In production, add a known-value assertion once the product's payment convention is fixed.
Where this simple model stops
This implementation assumes payments occur exactly one month apart and the first payment is one month after disbursement. Do not use it unchanged when any of the following is true:
- payment dates are irregular or have a first-period stub;
- the borrower receives additional advances after origination;
- fees are refundable, optional, or not directly tied to credit;
- cash flows change direction more than once, which can yield multiple IRR roots;
- the applicable disclosure rule specifies a particular APR method.
For dated, irregular cash flows, store Date values alongside each amount and use an XIRR-style calculation with actual day fractions. For a conventional monthly schedule, however, the model above is easy to audit: one disbursement, a list of borrower outflows, and a solver with an explicit failure case.
Make the numbers inspectable
When presenting the result in a UI, show the inputs that created it: actual amount received, every installment, recurring fees, and any final balloon. A rate without the supporting cash-flow list is difficult to reproduce and easy to misinterpret.
To enter the same regular monthly cash flows interactively, try this real APR calculator. It accepts the actual disbursement, monthly payment, payment count, optional monthly fees, and an optional final balloon payment.
The durable design lesson is simple: calculate the payment schedule from the contract, calculate the rate from what the borrower truly receives and repays, and keep the two calculations separate.
Top comments (0)