A balloon loan does not make part of the principal disappear. It moves it to the end of the schedule.
That sounds obvious, but it leads to a surprisingly common implementation bug: code calculates a lower regular payment, then treats the balloon as if it were a separate expense that does not affect the amortization math. The result can be a schedule that looks plausible for 35 months and leaves the wrong balance in month 36.
This article builds a monthly, fixed-rate balloon schedule in TypeScript. The model assumes payments occur at the end of each month, the annual rate remains fixed, and the balloon is due with the final regular payment. It does not cover taxes, insurance, refinancing approval, or lender-specific daily-interest and rounding rules.
Define the cash flows before writing the loop
Use these variables:
| Variable | Meaning |
|---|---|
P | Amount financed, after any down payment |
r | Monthly interest rate |
n | Number of monthly payments |
B | Balloon amount due in the final month |
C | Regular monthly payment, excluding the balloon |
For months 1 through n - 1, the borrower pays C. In month n, the borrower pays C + B.
The regular payment is lower than a fully amortizing payment because the balloon's present value remains in the balance. Its formula is:
C = ((P × (1 + r)^n - B) × r) / ((1 + r)^n - 1) At a 0% rate, use the limiting case instead of dividing by zero:
C = (P - B) / n The important detail is that B is a future payment at month n, not a number to subtract from P at the start. Subtracting it from the initial principal without discounting it makes the regular payment too small.
Start with types that make the final payment explicit
Avoid returning only one payment value. A consumer needs to know that the last cash flow is different.
type BalloonLoanInput = { principal: number; annualRate: number; // 0.0588 means 5.88% months: number; balloon: number; }; type ScheduleRow = { period: number; payment: number; principal: number; interest: number; balance: number; }; type BalloonSchedule = { regularPayment: number; finalPayment: number; totalInterest: number; schedule: ScheduleRow[]; }; regularPayment and finalPayment being separate fields makes it harder for an API or UI to accidentally label the lower number as the amount due every month.
Calculate the regular payment
Validate the inputs before applying the formula. In particular, a negative balloon can hide a data-entry error, and a balloon larger than the financed principal needs an explicit business rule rather than an accidental result.
export function balloonRegularPayment(input: BalloonLoanInput): number { const { principal, annualRate, months, balloon } = input; if (!(principal > 0)) throw new RangeError("principal must be positive"); if (!Number.isInteger(months) || months <= 0) { throw new RangeError("months must be a positive integer"); } if (annualRate < 0) throw new RangeError("annualRate cannot be negative"); if (balloon < 0 || balloon > principal) { throw new RangeError("balloon must be between 0 and principal"); } const r = annualRate / 12; if (r === 0) return (principal - balloon) / months; const factor = (1 + r) ** months; return ((principal * factor - balloon) * r) / (factor - 1); } When balloon is zero, the formula reduces to the usual level-payment loan formula. That is a useful cross-check: a balloon schedule with a zero balloon should behave like a normal annuity schedule.
Generate the schedule without feeding display rounding back into it
Here is the part that deserves more care than the formula. Keep full precision in balance while calculating. Round only when producing the row that will be displayed or exported.
const cents = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100; export function buildBalloonSchedule(input: BalloonLoanInput): BalloonSchedule { const regularPayment = balloonRegularPayment(input); const monthlyRate = input.annualRate / 12; const schedule: ScheduleRow[] = []; let balance = input.principal; for (let period = 1; period <= input.months; period += 1) { const interest = balance * monthlyRate; const isFinalPeriod = period === input.months; // Before maturity, C pays interest plus part of the principal. // At maturity, repay exactly what remains, then add the balloon to cash due. const principalPaid = isFinalPeriod ? balance : regularPayment - interest; const payment = isFinalPeriod ? regularPayment + input.balloon : regularPayment; balance = Math.max(0, balance - principalPaid); schedule.push({ period, payment: cents(payment), principal: cents(principalPaid), interest: cents(interest), balance: cents(balance), }); } return { regularPayment: cents(regularPayment), finalPayment: schedule.at(-1)!.payment, totalInterest: cents(schedule.reduce((sum, row) => sum + row.interest, 0)), schedule, }; } Why does the final row use principalPaid = balance instead of reusing regularPayment - interest?
The balloon formula makes the balance after the final regular-payment component equal to the balloon. In the final period, the borrower pays both pieces. Setting principal paid to the entire opening balance forces the ending balance to exactly zero; the final cash payment still remains regularPayment + balloon.
This also protects the schedule from small floating-point residues. Without the final-period guard, a balance such as 0.00000000003 may survive the loop and eventually show up as a confusing non-zero amount.
Work through one concrete loan
Suppose an auto loan uses these terms:
const example: BalloonLoanInput = { principal: 210_000, annualRate: 0.0588, months: 36, balloon: 90_000, }; const result = buildBalloonSchedule(example); console.log(result.regularPayment); // 4085.11 console.log(result.finalPayment); // 94085.11 console.log(result.schedule.at(-1)?.balance); // 0 The regular payment is about 4,085.11. The final amount due is about 94,085.11, not 90,000, because the last regular payment is still due at the same time as the balloon.
In the final row, the opening balance is about 93,626.34 and the final month's interest is about 458.77. Their sum equals the final cash payment after normal currency rounding.
That last reconciliation is the simplest debugging tool I know for balloon schedules. If the final payment, final interest, final principal, and zero ending balance cannot be reconciled, do not trust the earlier rows merely because their monthly payments look tidy.
Test the properties that matter
Testing only the regular payment misses most balloon-specific failures. These tests cover the final cash flow, the zero balance invariant, the zero-rate branch, and the relationship to an ordinary annuity loan.
import test from "node:test"; import assert from "node:assert/strict"; test("includes the balloon in the final payment", () => { const result = buildBalloonSchedule({ principal: 210_000, annualRate: 0.0588, months: 36, balloon: 90_000, }); assert.equal(result.regularPayment, 4085.11); assert.equal(result.finalPayment, 94085.11); assert.equal(result.schedule.at(-1)?.balance, 0); }); test("principal, interest, and payment reconcile in the final row", () => { const result = buildBalloonSchedule({ principal: 210_000, annualRate: 0.0588, months: 36, balloon: 90_000, }); const last = result.schedule.at(-1)!; assert.ok(Math.abs(last.payment - last.principal - last.interest) < 0.01); }); test("0% financing amortizes only principal before the balloon", () => { const result = buildBalloonSchedule({ principal: 12_000, annualRate: 0, months: 12, balloon: 3_000, }); assert.equal(result.regularPayment, 750); assert.equal(result.finalPayment, 3750); assert.equal(result.totalInterest, 0); }); test("a zero balloon matches a fully amortizing plan", () => { const result = buildBalloonSchedule({ principal: 100_000, annualRate: 0.06, months: 24, balloon: 0, }); assert.equal(result.regularPayment, result.finalPayment); assert.equal(result.schedule.at(-1)?.balance, 0); }); One nuance: row-level rounding can make payment - principal - interest differ by one cent in a real lender statement. Keep a tolerance in reconciliation tests, but never accept a meaningful remaining balance at maturity.
Do not confuse the loan balance with all purchase costs
Down payments and fees can matter to the buyer without belonging in the loan balance. If a financed amount is 210,000, a service fee is 3,000, and the balloon is 90,000, the schedule should amortize the 210,000. The service fee belongs in total cash outflow and, where appropriate, an APR or IRR calculation.
For the same reason, refinancing is not an automatic resolution of a balloon. In a product flow, model it as a new loan with its own approval, rate, fees, and cash flows. Do not silently replace the final balloon with another set of installments.
Make the final obligation visible in the interface
A schedule table can render the correct values and still be misleading if its summary only says “monthly payment: 4,085.11.” Surface both values:
| Summary field | Example value |
|---|---|
| Regular monthly payment | 4,085.11 |
| Final payment due | 94,085.11 |
| Final payment composition | 4,085.11 regular payment + 90,000.00 balloon |
For an interactive version of the same fixed-rate calculation, including the regular payment and maturity balloon, try this balloon loan calculator. Compare the results with the lender's written schedule, especially when fees, a first-period day count, or a different contract rounding rule applies.
The implementation rule is short: discount the balloon when calculating the regular payment, add the balloon to the final cash flow, and force the final principal payment to clear the unrounded balance. Those three choices keep the formula, schedule, and UI talking about the same loan.

