Python 67%
TypeScript 18.2%
CSS 14.4%
1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// components/Financing.tsx : “Finance this home” (listing page) —5// Canadian mortgage calculator plugged into the REAL observed rates6// (immoka/mortgage). Semi-annual compounding for fixed rates, CMHC shown7// separately, stress test, per-bank comparator, rate history. Every rate8// shows its provenance and freshness.9// -----------------------------------------------------------------------------10import { useEffect, useMemo, useRef, useState } from "react";11import { Link } from "react-router-dom";12import {13 MortgageBest, MortgageCalc, calculateMortgage, fetchMortgageBest,14 fmtPrice, fmtRate,15} from "../api";16import RateHistory from "./RateHistory";1718const FREQS: [string, string][] = [19 ["monthly", "Monthly"],20 ["semimonthly", "Semi-monthly (24/yr)"],21 ["biweekly", "Bi-weekly"],22 ["accelerated-biweekly", "Accelerated bi-weekly"],23 ["weekly", "Weekly"],24 ["accelerated-weekly", "Accelerated weekly"],25];26const TERMS: [number, string][] = [27 [12, "1 year"], [24, "2 years"], [36, "3 years"], [48, "4 years"],28 [60, "5 years"], [84, "7 years"], [120, "10 years"],29];30const KIND_EN: Record<string, string> = {31 posted: "posted rate", special: "special offer",32};33const INSURED_EN: Record<string, string> = {34 insured: "insured", insurable: "insurable", uninsured: "uninsured",35 unknown: "",36};3738const nf = (n: number) => n.toLocaleString("en-CA", { maximumFractionDigits: 0 });39const money = (n: number | null | undefined) =>40 n == null ? "—" : "$" + n.toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });4142/** Readable freshness of a rate observation. */43function freshness(ageMinutes: number): string {44 if (ageMinutes < 60) return `${ageMinutes} min ago`;45 if (ageMinutes < 48 * 60) return `${Math.round(ageMinutes / 60)} h ago`;46 return `${Math.round(ageMinutes / 1440)} d ago`;47}4849export default function Financing({ price: askingPrice }:50 { price: number | null }) {51 const [price, setPrice] = useState<number>(askingPrice ?? 0);52 const [down, setDown] = useState<number>(Math.round((askingPrice ?? 0) * 0.2));53 const [amort, setAmort] = useState(25);54 const [term, setTerm] = useState(60);55 const [rateType, setRateType] = useState<"fixed" | "variable">("fixed");56 const [freq, setFreq] = useState("monthly");57 const [res, setRes] = useState<MortgageCalc | null>(null);58 const [err, setErr] = useState<string | null>(null);59 const [best, setBest] = useState<MortgageBest | null>(null);60 const timer = useRef<number>();6162 const downPct = price > 0 ? (down / price) * 100 : 0;63 const setDownPct = (pct: number) =>64 setDown(Math.round((price * Math.min(99, Math.max(0, pct))) / 100));6566 // debounced recompute: the rates come from the engine, never the browser67 useEffect(() => {68 if (!price || price <= 0 || down < 0 || down >= price) { setRes(null); return; }69 window.clearTimeout(timer.current);70 timer.current = window.setTimeout(() => {71 calculateMortgage({72 price, down_payment: down, amortization_years: amort,73 term_months: term, frequency: freq, rate_type: rateType,74 })75 .then((r) => { setRes(r); setErr(null); })76 .catch(() => setErr("Rates momentarily unavailable — try again later."));77 }, 350);78 return () => window.clearTimeout(timer.current);79 }, [price, down, amort, term, rateType, freq]);8081 // per-bank comparator (same type/term as the scenario)82 useEffect(() => {83 setBest(null);84 fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null));85 }, [rateType, term]);8687 const monthlyCost = useMemo(() => {88 if (!res) return null;89 const parts: { k: string; v: number }[] = [90 { k: "Mortgage payment (monthly equivalent)", v: res.payment_monthly_equivalent },91 ];92 return { parts, total: parts.reduce((s, p) => s + p.v, 0) };93 }, [res]);9495 if (askingPrice == null || askingPrice <= 0) return null;96 const src = res?.rate_source ?? null;97 const ins = res?.insurance;9899 return (100 <section className="f-bloc f-mtg" id="financing">101 <h2>Finance this home</h2>102 <p className="mtg-intro">103 Simulation using the <b>real rates published by Canadian banks</b>,104 collected continuously by House-Ka — semi-annual compounding (the105 Canadian standard) for fixed rates.{" "}106 <Link to="/rates">See all rates ↗</Link>107 </p>108109 <div className="mtg-form">110 <label>111 <span>Price</span>112 <input type="number" inputMode="numeric" min={1} value={price || ""}113 onChange={(e) => setPrice(Number(e.target.value) || 0)} />114 </label>115 <label>116 <span>Down payment ($)</span>117 <input type="number" inputMode="numeric" min={0} value={down || ""}118 onChange={(e) => setDown(Number(e.target.value) || 0)} />119 </label>120 <label>121 <span>Down payment (%)</span>122 <input type="number" inputMode="decimal" min={0} max={99} step={1}123 value={downPct ? Math.round(downPct * 10) / 10 : ""}124 onChange={(e) => setDownPct(Number(e.target.value) || 0)} />125 </label>126 <label>127 <span>Amortization</span>128 <select value={amort} onChange={(e) => setAmort(Number(e.target.value))}>129 {[10, 15, 20, 25, 30].map((a) => <option key={a} value={a}>{a} years</option>)}130 </select>131 </label>132 <label>133 <span>Term</span>134 <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>135 {TERMS.map(([m, l]) => <option key={m} value={m}>{l}</option>)}136 </select>137 </label>138 <label>139 <span>Rate type</span>140 <select value={rateType}141 onChange={(e) => setRateType(e.target.value as "fixed" | "variable")}>142 <option value="fixed">Fixed</option>143 <option value="variable">Variable</option>144 </select>145 </label>146 <label>147 <span>Frequency</span>148 <select value={freq} onChange={(e) => setFreq(e.target.value)}>149 {FREQS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}150 </select>151 </label>152 </div>153154 {err && <p className="mtg-err">{err}</p>}155156 {res && (157 <>158 <div className="mtg-resultat">159 <div className="mtg-kpi">160 <span className="mtg-kpi-k">Payment</span>161 <span className="mtg-kpi-v">{money(res.payment)}</span>162 <span className="mtg-kpi-sub">163 {FREQS.find(([v]) => v === freq)?.[1].toLowerCase()}164 {freq !== "monthly" && ` · equiv. ${money(res.payment_monthly_equivalent)}/month`}165 </span>166 </div>167 <div className="mtg-kpi">168 <span className="mtg-kpi-k">Rate used</span>169 <span className="mtg-kpi-v">{fmtRate(res.inputs.rate)}</span>170 {src && (171 <span className="mtg-kpi-sub">172 {src.institution} — {src.product_name}{" "}173 ({KIND_EN[src.kind] ?? src.kind}174 {INSURED_EN[src.insured_status ?? "unknown"]175 ? `, ${INSURED_EN[src.insured_status ?? "unknown"]}` : ""})176 </span>177 )}178 </div>179 <div className="mtg-kpi">180 <span className="mtg-kpi-k">Mortgage</span>181 <span className="mtg-kpi-v">{fmtPrice(res.principal)}</span>182 <span className="mtg-kpi-sub">183 down payment {fmtPrice(res.inputs.down_payment)} ({res.inputs.down_payment_pct.toLocaleString("en-CA")}%)184 </span>185 </div>186 <div className="mtg-kpi">187 <span className="mtg-kpi-k">Stress test</span>188 <span className="mtg-kpi-v">{money(res.qualifying.payment)}</span>189 <span className="mtg-kpi-sub">qualifying at {fmtRate(res.qualifying.rate)}</span>190 </div>191 </div>192193 {src && (194 <p className="mtg-source fine">195 Rate observed at <b>{src.institution}</b> {freshness(src.age_minutes)}196 {src.stale && " ⚠ data older than 24 h"} ·{" "}197 {src.source_url && (198 <a href={src.source_url} target="_blank" rel="noopener noreferrer">199 official source ↗200 </a>201 )}202 </p>203 )}204205 {ins && ins.required && (206 <div className={`mtg-schl ${ins.eligible ? "" : "mtg-schl-no"}`}>207 <b>Mortgage default insurance (CMHC)</b>208 {ins.eligible ? (209 <ul>210 <li>Premium: <b>{fmtPrice(ins.premium)}</b> ({ins.premium_rate.toLocaleString("en-CA")}% of the loan, added to the mortgage)</li>211 <li>Loan-to-value ratio: {ins.ltv?.toLocaleString("en-CA")}%</li>212 <li>Provincial sales tax on the premium may apply and is due at closing.</li>213 </ul>214 ) : null}215 {ins.issues.map((i, k) => <p className="mtg-issue" key={k}>⚠ {i}</p>)}216 </div>217 )}218219 {monthlyCost && (220 <details className="mtg-detail">221 <summary>Estimated real monthly cost</summary>222 <div className="dtable">223 {monthlyCost.parts.map((p) => (224 <div className="drow" key={p.k}><span>{p.k}</span><b>{money(p.v)}</b></div>225 ))}226 <div className="drow mtg-total"><span>Estimated total</span><b>{money(monthlyCost.total)}</b></div>227 </div>228 <p className="fine">229 Property taxes, heating, electricity, home insurance and condo230 fees are extra.231 </p>232 </details>233 )}234235 <details className="mtg-detail">236 <summary>What if rates rise? (stress test)</summary>237 <div className="dtable">238 {res.stress.map((s) => (239 <div className="drow" key={s.bump}>240 <span>{s.bump === 0 ? "Current rate" : `+${s.bump} point${s.bump > 1 ? "s" : ""}`} — {fmtRate(s.rate)}</span>241 <b>{money(s.payment)}</b>242 </div>243 ))}244 </div>245 <p className="fine">{res.qualifying.note}</p>246 </details>247248 <details className="mtg-detail">249 <summary>At renewal ({TERMS.find(([m]) => m === term)?.[1]})</summary>250 <p className="fine">251 Balance remaining at maturity: <b>{fmtPrice(res.renewal.balance_at_renewal)}</b>{" "}252 (remaining amortization {res.renewal.remaining_amortization_years} years).253 Interest paid during the term: {fmtPrice(res.term.interest_paid)}.254 </p>255 <div className="dtable">256 {res.renewal.scenarios.map((s) => (257 <div className="drow" key={s.bump}>258 <span>Renewed at {fmtRate(s.rate)} ({s.bump >= 0 ? "+" : ""}{s.bump} pt)</span>259 <b>{money(s.payment)}</b>260 </div>261 ))}262 </div>263 </details>264265 {best && best.per_institution.length > 1 && (266 <details className="mtg-detail">267 <summary>Compare the banks ({best.institutions_count} institutions)</summary>268 <div className="rooms-wrap">269 <table className="rooms mtg-comp">270 <thead>271 <tr><th>Institution</th><th>Rate</th><th>Kind</th><th>Payment</th><th>Freshness</th></tr>272 </thead>273 <tbody>274 {best.per_institution.map((r) => (275 <tr key={r.provider}>276 <td>277 {r.source_url278 ? <a href={r.source_url} target="_blank" rel="noopener noreferrer">{r.institution}</a>279 : r.institution}280 </td>281 <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (APR ${fmtRate(r.apr)})` : ""}</td>282 <td>283 {KIND_EN[r.kind]}284 {INSURED_EN[r.insured_status] ? ` · ${INSURED_EN[r.insured_status]}` : ""}285 </td>286 <td>{res.principal > 0 ? money(estimatePayment(res, r.rate)) : "—"}</td>287 <td className={r.stale ? "mtg-stale" : ""}>{freshness(r.age_minutes)}</td>288 </tr>289 ))}290 </tbody>291 </table>292 </div>293 <p className="fine">294 Comparable products only (same type, same term) — a posted rate295 and a special offer are not the same thing, hence the Kind296 column. Payments estimated on your scenario.297 </p>298 </details>299 )}300301 <details className="mtg-detail">302 <summary>Rate history ({rateType} {TERMS.find(([m]) => m === term)?.[1]})</summary>303 <RateHistory rateType={rateType} termMonths={term} />304 </details>305306 <details className="mtg-detail">307 <summary>Year-by-year amortization</summary>308 <div className="rooms-wrap">309 <table className="rooms">310 <thead>311 <tr><th>Year</th><th>Interest</th><th>Principal</th><th>Balance</th></tr>312 </thead>313 <tbody>314 {res.annual.map((a) => (315 <tr key={a.year}>316 <td>{a.year}</td>317 <td>${nf(a.interest)}</td>318 <td>${nf(a.principal)}</td>319 <td>${nf(a.balance)}</td>320 </tr>321 ))}322 </tbody>323 </table>324 </div>325 {res.payoff_years < res.inputs.amortization_years && (326 <p className="fine">327 With the accelerated frequency chosen, the loan is paid off in{" "}328 <b>{res.payoff_years} years</b> instead of {res.inputs.amortization_years}.329 </p>330 )}331 </details>332 </>333 )}334335 <p className="fine">336 Indicative tool only — neither a financing offer nor a pre-approval.337 The rates shown are those published by the institutions (source and338 freshness indicated); confirm with the bank or a mortgage broker.339 </p>340 </section>341 );342}343344/** Estimated payment at another bank's rate, same scenario (frontend345 * approximation via the annuity factor — the scenario's official numbers346 * always come from the engine). */347function estimatePayment(res: MortgageCalc, rate: number): number {348 const { amortization_years, frequency, compounding } = res.inputs;349 const f = ({ monthly: 12, semimonthly: 24, biweekly: 26,350 "accelerated-biweekly": 26, weekly: 52, "accelerated-weekly": 52 } as351 Record<string, number>)[frequency] ?? 12;352 const per = (pct: number, k: number) =>353 compounding === "monthly"354 ? Math.pow(1 + pct / 100 / 12, 12 / k) - 1355 : Math.pow(1 + pct / 100 / 2, 2 / k) - 1;356 const pay = (pct: number) => {357 if (frequency.startsWith("accelerated")) {358 const m = pay0(pct, 12);359 return Math.round((m / (frequency === "accelerated-biweekly" ? 2 : 4)) * 100) / 100;360 }361 return pay0(pct, f);362 };363 const pay0 = (pct: number, k: number) => {364 const i = per(pct, k);365 const n = Math.round(amortization_years * k);366 return Math.round(((res.principal * i) / (1 - Math.pow(1 + i, -n))) * 100) / 100;367 };368 return pay(rate);369}370