// ----------------------------------------------------------------------------- // House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first) // Author: Simon-Pierre Boucher — contact@spboucher.ai // components/Financing.tsx : “Finance this home” (listing page) — // Canadian mortgage calculator plugged into the REAL observed rates // (immoka/mortgage). Semi-annual compounding for fixed rates, CMHC shown // separately, stress test, per-bank comparator, rate history. Every rate // shows its provenance and freshness. // ----------------------------------------------------------------------------- import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { MortgageBest, MortgageCalc, calculateMortgage, fetchMortgageBest, fmtPrice, fmtRate, } from "../api"; import RateHistory from "./RateHistory"; const FREQS: [string, string][] = [ ["monthly", "Monthly"], ["semimonthly", "Semi-monthly (24/yr)"], ["biweekly", "Bi-weekly"], ["accelerated-biweekly", "Accelerated bi-weekly"], ["weekly", "Weekly"], ["accelerated-weekly", "Accelerated weekly"], ]; const TERMS: [number, string][] = [ [12, "1 year"], [24, "2 years"], [36, "3 years"], [48, "4 years"], [60, "5 years"], [84, "7 years"], [120, "10 years"], ]; const KIND_EN: Record = { posted: "posted rate", special: "special offer", }; const INSURED_EN: Record = { insured: "insured", insurable: "insurable", uninsured: "uninsured", unknown: "", }; const nf = (n: number) => n.toLocaleString("en-CA", { maximumFractionDigits: 0 }); const money = (n: number | null | undefined) => n == null ? "—" : "$" + n.toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); /** Readable freshness of a rate observation. */ function freshness(ageMinutes: number): string { if (ageMinutes < 60) return `${ageMinutes} min ago`; if (ageMinutes < 48 * 60) return `${Math.round(ageMinutes / 60)} h ago`; return `${Math.round(ageMinutes / 1440)} d ago`; } export default function Financing({ price: askingPrice }: { price: number | null }) { const [price, setPrice] = useState(askingPrice ?? 0); const [down, setDown] = useState(Math.round((askingPrice ?? 0) * 0.2)); const [amort, setAmort] = useState(25); const [term, setTerm] = useState(60); const [rateType, setRateType] = useState<"fixed" | "variable">("fixed"); const [freq, setFreq] = useState("monthly"); const [res, setRes] = useState(null); const [err, setErr] = useState(null); const [best, setBest] = useState(null); const timer = useRef(); const downPct = price > 0 ? (down / price) * 100 : 0; const setDownPct = (pct: number) => setDown(Math.round((price * Math.min(99, Math.max(0, pct))) / 100)); // debounced recompute: the rates come from the engine, never the browser useEffect(() => { if (!price || price <= 0 || down < 0 || down >= price) { setRes(null); return; } window.clearTimeout(timer.current); timer.current = window.setTimeout(() => { calculateMortgage({ price, down_payment: down, amortization_years: amort, term_months: term, frequency: freq, rate_type: rateType, }) .then((r) => { setRes(r); setErr(null); }) .catch(() => setErr("Rates momentarily unavailable — try again later.")); }, 350); return () => window.clearTimeout(timer.current); }, [price, down, amort, term, rateType, freq]); // per-bank comparator (same type/term as the scenario) useEffect(() => { setBest(null); fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null)); }, [rateType, term]); const monthlyCost = useMemo(() => { if (!res) return null; const parts: { k: string; v: number }[] = [ { k: "Mortgage payment (monthly equivalent)", v: res.payment_monthly_equivalent }, ]; return { parts, total: parts.reduce((s, p) => s + p.v, 0) }; }, [res]); if (askingPrice == null || askingPrice <= 0) return null; const src = res?.rate_source ?? null; const ins = res?.insurance; return (

Finance this home

Simulation using the real rates published by Canadian banks, collected continuously by House-Ka — semi-annual compounding (the Canadian standard) for fixed rates.{" "} See all rates ↗

{err &&

{err}

} {res && ( <>
Payment {money(res.payment)} {FREQS.find(([v]) => v === freq)?.[1].toLowerCase()} {freq !== "monthly" && ` · equiv. ${money(res.payment_monthly_equivalent)}/month`}
Rate used {fmtRate(res.inputs.rate)} {src && ( {src.institution} — {src.product_name}{" "} ({KIND_EN[src.kind] ?? src.kind} {INSURED_EN[src.insured_status ?? "unknown"] ? `, ${INSURED_EN[src.insured_status ?? "unknown"]}` : ""}) )}
Mortgage {fmtPrice(res.principal)} down payment {fmtPrice(res.inputs.down_payment)} ({res.inputs.down_payment_pct.toLocaleString("en-CA")}%)
Stress test {money(res.qualifying.payment)} qualifying at {fmtRate(res.qualifying.rate)}
{src && (

Rate observed at {src.institution} {freshness(src.age_minutes)} {src.stale && " ⚠ data older than 24 h"} ·{" "} {src.source_url && ( official source ↗ )}

)} {ins && ins.required && (
Mortgage default insurance (CMHC) {ins.eligible ? (
  • Premium: {fmtPrice(ins.premium)} ({ins.premium_rate.toLocaleString("en-CA")}% of the loan, added to the mortgage)
  • Loan-to-value ratio: {ins.ltv?.toLocaleString("en-CA")}%
  • Provincial sales tax on the premium may apply and is due at closing.
) : null} {ins.issues.map((i, k) =>

⚠ {i}

)}
)} {monthlyCost && (
Estimated real monthly cost
{monthlyCost.parts.map((p) => (
{p.k}{money(p.v)}
))}
Estimated total{money(monthlyCost.total)}

Property taxes, heating, electricity, home insurance and condo fees are extra.

)}
What if rates rise? (stress test)
{res.stress.map((s) => (
{s.bump === 0 ? "Current rate" : `+${s.bump} point${s.bump > 1 ? "s" : ""}`} — {fmtRate(s.rate)} {money(s.payment)}
))}

{res.qualifying.note}

At renewal ({TERMS.find(([m]) => m === term)?.[1]})

Balance remaining at maturity: {fmtPrice(res.renewal.balance_at_renewal)}{" "} (remaining amortization {res.renewal.remaining_amortization_years} years). Interest paid during the term: {fmtPrice(res.term.interest_paid)}.

{res.renewal.scenarios.map((s) => (
Renewed at {fmtRate(s.rate)} ({s.bump >= 0 ? "+" : ""}{s.bump} pt) {money(s.payment)}
))}
{best && best.per_institution.length > 1 && (
Compare the banks ({best.institutions_count} institutions)
{best.per_institution.map((r) => ( ))}
InstitutionRateKindPaymentFreshness
{r.source_url ? {r.institution} : r.institution} {fmtRate(r.rate)}{r.apr != null ? ` (APR ${fmtRate(r.apr)})` : ""} {KIND_EN[r.kind]} {INSURED_EN[r.insured_status] ? ` · ${INSURED_EN[r.insured_status]}` : ""} {res.principal > 0 ? money(estimatePayment(res, r.rate)) : "—"} {freshness(r.age_minutes)}

Comparable products only (same type, same term) — a posted rate and a special offer are not the same thing, hence the Kind column. Payments estimated on your scenario.

)}
Rate history ({rateType} {TERMS.find(([m]) => m === term)?.[1]})
Year-by-year amortization
{res.annual.map((a) => ( ))}
YearInterestPrincipalBalance
{a.year} ${nf(a.interest)} ${nf(a.principal)} ${nf(a.balance)}
{res.payoff_years < res.inputs.amortization_years && (

With the accelerated frequency chosen, the loan is paid off in{" "} {res.payoff_years} years instead of {res.inputs.amortization_years}.

)}
)}

Indicative tool only — neither a financing offer nor a pre-approval. The rates shown are those published by the institutions (source and freshness indicated); confirm with the bank or a mortgage broker.

); } /** Estimated payment at another bank's rate, same scenario (frontend * approximation via the annuity factor — the scenario's official numbers * always come from the engine). */ function estimatePayment(res: MortgageCalc, rate: number): number { const { amortization_years, frequency, compounding } = res.inputs; const f = ({ monthly: 12, semimonthly: 24, biweekly: 26, "accelerated-biweekly": 26, weekly: 52, "accelerated-weekly": 52 } as Record)[frequency] ?? 12; const per = (pct: number, k: number) => compounding === "monthly" ? Math.pow(1 + pct / 100 / 12, 12 / k) - 1 : Math.pow(1 + pct / 100 / 2, 2 / k) - 1; const pay = (pct: number) => { if (frequency.startsWith("accelerated")) { const m = pay0(pct, 12); return Math.round((m / (frequency === "accelerated-biweekly" ? 2 : 4)) * 100) / 100; } return pay0(pct, f); }; const pay0 = (pct: number, k: number) => { const i = per(pct, k); const n = Math.round(amortization_years * k); return Math.round(((res.principal * i) / (1 - Math.pow(1 + i, -n))) * 100) / 100; }; return pay(rate); }