Mortgage Calculator — Go source
Compute monthly payments, the full amortization schedule and total interest, with extra-payment what-if analysis showing months and interest saved — runs entirely in your browser.
This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package mortgagecalc is the Go twin of CosmoDev's src/lib/mortgage.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure + deterministic, never panics. The table-driven tests in
// mortgage-calculator_test.go share vectors with src/lib/mortgage.test.ts so
// the two implementations are held to the same contract.
//
// All money is tracked in integer cents (balances, interest accrual) so the TS
// and Go twins stay identical; the scheduled payment stays exact (unrounded)
// and the final month clears the residual, which is how real amortization
// handles a cent-rounded quoted payment. Amortize returns nil on invalid
// input, mirroring the TS lib's null.
package mortgagecalc
import (
"fmt"
"math"
"regexp"
"strconv"
)
// Input mirrors MortgageInput in src/lib/mortgage.ts. ExtraMonthly is *float64
// so nil is the TS omitted field (a non-nil 0 behaves the same: no extra).
// StartDate is an optional ISO yyyy-mm driving schedule dates and PayoffDate;
// a malformed value is ignored ("no valid date", like TS null).
type Input struct {
Principal float64
AnnualRatePct float64
Years int
ExtraMonthly *float64
StartDate string
}
// Row is one month of the amortization schedule (MortgageRow in TS). Money
// fields are display dollars rounded to cents.
type Row struct {
Month int
Date string
Payment float64
Interest float64
PrincipalPart float64
Extra float64
Balance float64
}
// Result mirrors MortgageResult in TS. The TS payoffDate: string | null
// becomes PayoffDate string, where "" means null (no valid StartDate).
type Result struct {
MonthlyPayment float64
TotalPaid float64
TotalInterest float64
Months int
PayoffDate string
Schedule []Row
MonthsSavedByExtra int
InterestSavedByExtra float64
}
// epsilon is JavaScript's Number.EPSILON (2^-52), used by centsToDollars to
// guard against float dust exactly like the TS lib.
const epsilon = 2.220446049250313e-16
var isoMonth = regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`)
// jsRound mirrors JavaScript's Math.round (half-up toward +∞) so cent
// rounding matches the TS lib bit for bit. math.Round is NOT equivalent —
// it rounds halves away from zero.
func jsRound(x float64) float64 { return math.Floor(x + 0.5) }
// centsToDollars rounds cents (or a cents-float) to a display dollar value.
func centsToDollars(cents float64) float64 {
return jsRound((cents/100 + epsilon) * 100) / 100
}
// addMonths adds k months to an ISO yyyy-mm string, rolling the year. Mirrors
// addMonths() in the TS lib (year unpadded, month zero-padded to 2).
func addMonths(iso string, k int) string {
y, _ := strconv.Atoi(iso[0:4])
m, _ := strconv.Atoi(iso[5:7])
idx := y*12 + (m - 1) + k
return fmt.Sprintf("%d-%02d", idx/12, idx%12+1)
}
// runResult is the internal outcome of one amortization run.
type runResult struct {
schedule []Row
months int
totalInterestCents float64
}
// run walks the amortization loop. balance, interest, principalPart and extra
// are integer cents held in float64 (mirroring the TS number type). startDate
// is "" when no valid start date was given, like TS null.
func run(principalCents, monthlyRate, payment, extraCents float64, startDate string) runResult {
balance := principalCents
totalInterest := 0.0
month := 0
var schedule []Row
for balance > 0 {
month++
interest := jsRound(balance * monthlyRate)
principalPart := math.Min(payment-interest, balance)
extra := math.Min(extraCents, balance-principalPart)
if balance-principalPart-extra <= 0.5 {
// Final month: clear the residual exactly instead of a dust payment.
principalPart = balance
extra = 0
}
balance -= principalPart + extra
totalInterest += interest
date := ""
if startDate != "" {
date = addMonths(startDate, month-1)
}
schedule = append(schedule, Row{
Month: month,
Date: date,
Payment: centsToDollars(interest + principalPart + extra),
Interest: centsToDollars(interest),
PrincipalPart: centsToDollars(principalPart),
Extra: centsToDollars(extra),
Balance: centsToDollars(balance),
})
}
return runResult{schedule: schedule, months: month, totalInterestCents: totalInterest}
}
// isBadNumber reports whether x is NaN or ±Inf (TS Number.isFinite is false).
func isBadNumber(x float64) bool {
return math.IsNaN(x) || math.IsInf(x, 0)
}
// Amortize computes a full amortization schedule. It is the Go twin of
// amortize() in src/lib/mortgage.ts and must agree with it on every shared
// vector. Returns nil for invalid input (TS: null).
func Amortize(in Input) *Result {
if isBadNumber(in.Principal) || isBadNumber(in.AnnualRatePct) || in.Years <= 0 ||
(in.ExtraMonthly != nil && isBadNumber(*in.ExtraMonthly)) {
return nil
}
if in.Principal <= 0 || (in.ExtraMonthly != nil && *in.ExtraMonthly < 0) {
return nil
}
validStart := ""
if isoMonth.MatchString(in.StartDate) {
validStart = in.StartDate
}
principalCents := jsRound(in.Principal * 100)
extraCents := 0.0
if in.ExtraMonthly != nil {
extraCents = jsRound(*in.ExtraMonthly * 100)
}
r := in.AnnualRatePct / 100 / 12
n := in.Years * 12
payment := principalCents / float64(n)
if r != 0 {
pow := math.Pow(1+r, float64(n))
payment = principalCents * r * pow / (pow - 1)
}
primary := run(principalCents, r, payment, extraCents, validStart)
res := &Result{
MonthlyPayment: centsToDollars(payment),
TotalPaid: centsToDollars(primary.totalInterestCents + principalCents),
TotalInterest: centsToDollars(primary.totalInterestCents),
Months: primary.months,
Schedule: primary.schedule,
}
if validStart != "" && len(primary.schedule) > 0 {
res.PayoffDate = primary.schedule[len(primary.schedule)-1].Date
}
if extraCents > 0 {
baseline := run(principalCents, r, payment, 0, "")
res.MonthsSavedByExtra = baseline.months - primary.months
res.InterestSavedByExtra = centsToDollars(baseline.totalInterestCents - primary.totalInterestCents)
}
return res
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →