Skip to content

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 →