Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/FinancingCard.tsx : « Financement et coût de propriété » — calculateur5// hypothécaire canadien branché sur les taux RÉELS observés (immoka/mortgage :6// composition semestrielle pour les fixes, SCHL, test de résistance,7// renouvellement, comparateur par banque, historique du taux, amortissement),8// puis COÛT DE PROPRIÉTÉ MENSUEL : versement + taxes municipales/scolaires9// publiées + copropriété + électricité (estimation Hydro-Québec quand elle10// existe), chaque poste étiqueté (publié / estimé / inconnu). Reprend toute11// la logique de l'ancien composant Financement, en accordéons compacts.12// -----------------------------------------------------------------------------13import { useEffect, useMemo, useRef, useState } from "react";14import { Link } from "react-router-dom";15import {16 HydroEstimate, Listing, MortgageBest, MortgageCalc, calculateMortgage, fetchHydro, fetchMortgageBest,17 fmtPrice, fmtRate,18} from "../api";19import { Ico } from "../components/Icons";20import TauxHistorique from "../components/TauxHistorique";21import { estLocation, fraisCoproMensuels, taxesAnnuelles } from "./synthese";22import { Accordion, ErrorState, SectionCard, StatTile, NBSP } from "./ui";23import { Res } from "./useFicheData";2425const FREQS: [string, string][] = [26 ["monthly", "Mensuel"], ["semimonthly", "Bimensuel (24/an)"], ["biweekly", "Aux 2 semaines"],27 ["accelerated-biweekly", "Aux 2 semaines accéléré"], ["weekly", "Hebdomadaire"], ["accelerated-weekly", "Hebdomadaire accéléré"],28];29const TERMES: [number, string][] = [[12, "1 an"], [24, "2 ans"], [36, "3 ans"], [48, "4 ans"], [60, "5 ans"], [84, "7 ans"], [120, "10 ans"]];30const KIND_FR: Record<string, string> = { posted: "taux affiché", special: "offre spéciale" };31const INSURED_FR: Record<string, string> = { insured: "assuré", insurable: "assurable", uninsured: "non assuré", unknown: "" };3233const nf = (n: number) => n.toLocaleString("fr-CA", { maximumFractionDigits: 0 });34const money = (n: number | null | undefined) =>35 n == null ? "—" : `${n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $`;36const freshness = (m: number) => (m < 60 ? `il y a ${m} min` : m < 48 * 60 ? `il y a ${Math.round(m / 60)} h` : `il y a ${Math.round(m / 1440)} j`);3738/** Versement estimé au taux d'une autre banque, même scénario (approximation39 * frontend par facteur d'annuité — les chiffres officiels du scénario viennent40 * toujours du moteur). */41function estimatePayment(res: MortgageCalc, rate: number): number {42 const { amortization_years, frequency, compounding } = res.inputs;43 const f = ({ monthly: 12, semimonthly: 24, biweekly: 26, "accelerated-biweekly": 26, weekly: 52, "accelerated-weekly": 52 } as Record<string, number>)[frequency] ?? 12;44 const per = (pct: number, k: number) =>45 compounding === "monthly" ? Math.pow(1 + pct / 100 / 12, 12 / k) - 1 : Math.pow(1 + pct / 100 / 2, 2 / k) - 1;46 const pay0 = (pct: number, k: number) => {47 const i = per(pct, k);48 const n = Math.round(amortization_years * k);49 return Math.round(((res.principal * i) / (1 - Math.pow(1 + i, -n))) * 100) / 100;50 };51 if (frequency.startsWith("accelerated")) {52 const m = pay0(rate, 12);53 return Math.round((m / (frequency === "accelerated-biweekly" ? 2 : 4)) * 100) / 100;54 }55 return pay0(rate, f);56}5758export default function FinancingCard({ l, hydro }: { l: Listing; hydro: Res<HydroEstimate> }) {59 const prix = l.price;60 const [price, setPrice] = useState<number>(prix ?? 0);61 const [down, setDown] = useState<number>(Math.round((prix ?? 0) * 0.2));62 const [amort, setAmort] = useState(25);63 const [term, setTerm] = useState(60);64 const [rateType, setRateType] = useState<"fixed" | "variable">("fixed");65 const [freq, setFreq] = useState("monthly");66 const [res, setRes] = useState<MortgageCalc | null>(null);67 const [err, setErr] = useState<string | null>(null);68 const [best, setBest] = useState<MortgageBest | null>(null);69 const [h, setH] = useState<HydroEstimate | null>(null);70 const [busy, setBusy] = useState(false);71 const timer = useRef<number>();7273 const downPct = price > 0 ? (down / price) * 100 : 0;74 const setDownPct = (pct: number) => setDown(Math.round((price * Math.min(99, Math.max(0, pct))) / 100));7576 // recalcul débobiné : les taux viennent du moteur, jamais du navigateur77 useEffect(() => {78 if (!price || price <= 0 || down < 0 || down >= price) { setRes(null); return; }79 window.clearTimeout(timer.current);80 timer.current = window.setTimeout(() => {81 calculateMortgage({ price, down_payment: down, amortization_years: amort, term_months: term, frequency: freq, rate_type: rateType })82 .then((r) => { setRes(r); setErr(null); })83 .catch(() => setErr("Taux momentanément indisponibles — réessayez plus tard."));84 }, 350);85 return () => window.clearTimeout(timer.current);86 }, [price, down, amort, term, rateType, freq]);8788 // comparateur par banque (mêmes type/terme que le scénario)89 useEffect(() => {90 setBest(null);91 fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null));92 }, [rateType, term]);9394 const taxes = taxesAnnuelles(l);95 const copro = fraisCoproMensuels(l);96 const hy = h ?? (hydro.status === "ok" ? hydro.data : null);97 const hydroVisible = hy && (hy.disponible || hy.en_attente || !/captcha|configuré|incomplète/.test(hy.raison || ""));98 const lancerHydro = () => {99 setBusy(true);100 fetchHydro(l.address || l.title, { uid: l.uid, lat: l.lat, lng: l.lng }, true).then(setH).catch(() => {}).finally(() => setBusy(false));101 };102103 const cout = useMemo(() => {104 if (!res) return null;105 const lignes: { poste: string; statut: "publié" | "estimé" | "inconnu"; montant: number | null }[] = [106 { poste: "Versement hypothécaire (équiv. mensuel)", statut: "estimé", montant: res.payment_monthly_equivalent },107 { poste: "Taxes municipales et scolaires", statut: taxes ? "publié" : "inconnu", montant: taxes ? Math.round(taxes.total / 12) : null },108 ];109 if (copro != null || /condo|copropri/i.test(l.property_type || ""))110 lignes.push({ poste: "Frais de copropriété", statut: copro != null ? "publié" : "inconnu", montant: copro });111 lignes.push({ poste: "Électricité (Hydro-Québec)", statut: hy?.disponible ? "estimé" : "inconnu", montant: hy?.disponible ? Math.round(hy.cout_mensuel!) : null });112 lignes.push({ poste: "Assurance habitation", statut: "inconnu", montant: null });113 const total = lignes.reduce((s, x) => s + (x.montant ?? 0), 0);114 const inconnus = lignes.filter((x) => x.montant == null).map((x) => x.poste.toLowerCase());115 return { lignes, total, inconnus };116 }, [res, taxes, copro, hy, l.property_type]);117118 if (prix == null || prix <= 0 || estLocation(l)) return null;119 const src = res?.rate_source ?? null;120 const ins = res?.insurance;121122 return (123 <SectionCard id="financement" title="Financement et coût de propriété" icon={<Ico name="bank" size={18} />}124 sub="Taux réels publiés par les banques canadiennes, collectés en continu par Immo-Ka">125 <div className="ik-form">126 <label><span>Prix</span>127 <input type="number" inputMode="numeric" min={1} value={price || ""} onChange={(e) => setPrice(Number(e.target.value) || 0)} /></label>128 <label><span>Mise de fonds ($)</span>129 <input type="number" inputMode="numeric" min={0} value={down || ""} onChange={(e) => setDown(Number(e.target.value) || 0)} /></label>130 <label><span>Mise de fonds (%)</span>131 <input type="number" inputMode="decimal" min={0} max={99} step={1} value={downPct ? Math.round(downPct * 10) / 10 : ""}132 onChange={(e) => setDownPct(Number(e.target.value) || 0)} /></label>133 <label><span>Amortissement</span>134 <select value={amort} onChange={(e) => setAmort(Number(e.target.value))}>135 {[10, 15, 20, 25, 30].map((a) => <option key={a} value={a}>{a} ans</option>)}136 </select></label>137 <label><span>Terme</span>138 <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>139 {TERMES.map(([m, t]) => <option key={m} value={m}>{t}</option>)}140 </select></label>141 <label><span>Type de taux</span>142 <select value={rateType} onChange={(e) => setRateType(e.target.value as "fixed" | "variable")}>143 <option value="fixed">Fixe</option><option value="variable">Variable</option>144 </select></label>145 <label><span>Fréquence</span>146 <select value={freq} onChange={(e) => setFreq(e.target.value)}>147 {FREQS.map(([v, t]) => <option key={v} value={v}>{t}</option>)}148 </select></label>149 </div>150151 {err && <ErrorState>{err}</ErrorState>}152153 {res && (154 <>155 <div className="ik-kpis" style={{ marginTop: 12 }}>156 <StatTile accent value={money(res.payment)} label={<>{FREQS.find(([v]) => v === freq)?.[1]}{freq !== "monthly" && <> · équiv. {money(res.payment_monthly_equivalent)}/mois</>}</>} anim={false} />157 <StatTile value={fmtRate(res.inputs.rate)} label={src ? `${src.institution} — ${KIND_FR[src.kind] ?? src.kind}${INSURED_FR[src.insured_status ?? "unknown"] ? `, ${INSURED_FR[src.insured_status ?? "unknown"]}` : ""}` : "Taux utilisé"} anim={false} />158 <StatTile value={fmtPrice(res.principal)} label={`Hypothèque · mise de fonds ${res.inputs.down_payment_pct.toLocaleString("fr-CA")}${NBSP}%`} anim={false} />159 <StatTile value={money(res.qualifying.payment)} label={`Test de résistance à ${fmtRate(res.qualifying.rate)}`} anim={false} />160 </div>161 {src && (162 <p className="ik-fine" style={{ marginTop: 8 }}>163 Taux observé chez <b>{src.institution}</b> {freshness(src.age_minutes)}{src.stale && " ⚠ donnée de plus de 24 h"}164 {src.source_url && <> · <a href={src.source_url} target="_blank" rel="noopener noreferrer">source officielle ↗</a></>}165 {" "}· <Link to="/taux-hypothecaires">Tous les taux</Link>166 </p>167 )}168 {ins && ins.required && (169 <div className={`ik-note ${ins.eligible ? "info" : "warn"}`}>170 <b>Assurance prêt hypothécaire (SCHL)</b>171 {ins.eligible && (172 <> — prime <b>{fmtPrice(ins.premium)}</b> ({ins.premium_rate.toLocaleString("fr-CA")}{NBSP}% du prêt, ajoutée à l'hypothèque),173 TVQ sur la prime <b>{money(ins.qc_tax)}</b> payable à la clôture, rapport prêt-valeur {ins.ltv?.toLocaleString("fr-CA")}{NBSP}%.</>174 )}175 {ins.issues.map((i, k) => <div key={k}>⚠ {i}</div>)}176 </div>177 )}178179 {cout && (180 <>181 <h3 className="ik-card-sub ik-subtitle">Coût de propriété mensuel estimé</h3>182 <ul className="ik-cost">183 {cout.lignes.map((li) => (184 <li key={li.poste}>185 <span className="n">{li.poste}<span className={`ik-pill ${li.statut === "publié" ? "observed" : li.statut === "estimé" ? "estimated" : "unknown"}`}>{li.statut}</span></span>186 <span className={`v ${li.montant == null ? "na" : ""}`}>{li.montant != null ? fmtPrice(li.montant) : "—"}</span>187 </li>188 ))}189 <li className="total"><span className="n">Total estimé</span><span className="v">≈{NBSP}{fmtPrice(Math.round(cout.total))}{NBSP}/mois</span></li>190 <li className="annuel"><span className="n">soit sur 12 mois</span><span className="v">≈{NBSP}{fmtPrice(Math.round(cout.total * 12))}</span></li>191 </ul>192 {cout.inconnus.length > 0 && (193 <p className="ik-cost-note">Non chiffrables avec les données publiées : {cout.inconnus.join(", ")} — le total réel est plus élevé.</p>194 )}195 {taxes && (196 <p className="ik-cost-note">Taxes publiées par la source : {taxes.municipales != null ? `municipales ${fmtPrice(Math.round(taxes.municipales))}` : ""}{taxes.municipales != null && taxes.scolaires != null ? " · " : ""}{taxes.scolaires != null ? `scolaires ${fmtPrice(Math.round(taxes.scolaires))}` : ""} par année.</p>197 )}198 {hydroVisible && hy && (199 <div className="ik-note info" style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>200 <Ico name="drop" size={16} />201 {hy.disponible ? (202 <span><b>Électricité : {fmtPrice(hy.cout_mensuel!)}{NBSP}/mois</b> (≈{NBSP}{fmtPrice(hy.cout_annuel!)}{NBSP}/an{hy.kwh_annuel ? `, ${hy.kwh_annuel.toLocaleString("fr-CA")} kWh` : ""}) — estimation Hydro-Québec fondée sur la consommation réelle de la propriété.</span>203 ) : hy.en_attente ? (204 <>205 <span style={{ flex: 1 }}>Estimation Hydro-Québec du coût d'électricité disponible à la demande.</span>206 <button type="button" className="ik-btn ik-btn-ghost" style={{ minHeight: 38 }} onClick={lancerHydro} disabled={busy}>207 {busy ? "Estimation en cours…" : "Estimer"}208 </button>209 </>210 ) : (211 <span>Hydro-Québec n'a pas d'estimation pour cette adresse.</span>212 )}213 </div>214 )}215 </>216 )}217218 <Accordion title="Et si les taux montent ? (test de résistance)" small>219 <div className="ik-facts-rows" style={{ marginTop: 0 }}>220 {res.stress.map((s) => (221 <div className="ik-kv" key={s.bump}>222 <span className="k">{s.bump === 0 ? "Taux actuel" : `+${s.bump} point${s.bump > 1 ? "s" : ""}`} — {fmtRate(s.rate)}</span>223 <span className="v">{money(s.payment)}</span>224 </div>225 ))}226 </div>227 <p>{res.qualifying.note}</p>228 </Accordion>229230 <Accordion title={`Au renouvellement (${TERMES.find(([m]) => m === term)?.[1]})`} small>231 <p>232 Solde restant à l'échéance : <b>{fmtPrice(res.renewal.balance_at_renewal)}</b> (amortissement résiduel {res.renewal.remaining_amortization_years} ans).233 Intérêts payés pendant le terme : {fmtPrice(res.term.interest_paid)}.234 </p>235 <div className="ik-facts-rows" style={{ marginTop: 0 }}>236 {res.renewal.scenarios.map((s) => (237 <div className="ik-kv" key={s.bump}>238 <span className="k">Renouvelé à {fmtRate(s.rate)} ({s.bump >= 0 ? "+" : ""}{s.bump} pt)</span>239 <span className="v">{money(s.payment)}</span>240 </div>241 ))}242 </div>243 </Accordion>244245 {best && best.per_institution.length > 1 && (246 <Accordion title={`Comparer les banques (${best.institutions_count} institutions)`} small>247 <div className="ik-table-wrap">248 <table className="ik-table">249 <thead><tr><th>Institution</th><th>Taux</th><th>Nature</th><th>Versement</th><th>Fraîcheur</th></tr></thead>250 <tbody>251 {best.per_institution.map((r) => (252 <tr key={r.provider}>253 <td>{r.source_url ? <a href={r.source_url} target="_blank" rel="noopener noreferrer">{r.institution}</a> : r.institution}</td>254 <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (TAP ${fmtRate(r.apr)})` : ""}</td>255 <td>{KIND_FR[r.kind]}{INSURED_FR[r.insured_status] ? ` · ${INSURED_FR[r.insured_status]}` : ""}</td>256 <td>{res.principal > 0 ? money(estimatePayment(res, r.rate)) : "—"}</td>257 <td style={r.stale ? { color: "var(--ik-warning)" } : undefined}>{freshness(r.age_minutes)}</td>258 </tr>259 ))}260 </tbody>261 </table>262 </div>263 <p>Produits comparables seulement (même type, même terme) — un taux « affiché » et une « offre spéciale » ne sont pas la même chose. Versements estimés sur votre scénario.</p>264 </Accordion>265 )}266267 <Accordion title={`Historique du taux (${rateType === "fixed" ? "fixe" : "variable"} ${TERMES.find(([m]) => m === term)?.[1]})`} small>268 <TauxHistorique rateType={rateType} termMonths={term} />269 </Accordion>270271 <Accordion title="Amortissement année par année" small>272 <div className="ik-table-wrap">273 <table className="ik-table">274 <thead><tr><th>Année</th><th>Intérêts</th><th>Capital</th><th>Solde</th></tr></thead>275 <tbody>276 {res.annual.map((a) => (277 <tr key={a.year}><td>{a.year}</td><td>{nf(a.interest)} $</td><td>{nf(a.principal)} $</td><td>{nf(a.balance)} $</td></tr>278 ))}279 </tbody>280 </table>281 </div>282 {res.payoff_years < res.inputs.amortization_years && (283 <p>Avec la fréquence accélérée choisie, le prêt s'éteint en <b>{res.payoff_years} ans</b> au lieu de {res.inputs.amortization_years}.</p>284 )}285 </Accordion>286 </>287 )}288289 <p className="ik-fine" style={{ marginTop: 10 }}>290 Outil indicatif seulement — ni offre de financement ni préapprobation. Les taux affichés sont ceux publiés par les291 institutions (source et fraîcheur indiquées) ; vérifiez auprès de la banque ou d'un courtier hypothécaire.292 </p>293 </SectionCard>294 );295}296