SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
15.5 KB · 370 lines tsx
Raw Blame History
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