// ----------------------------------------------------------------------------- // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // fiche/FinancingCard.tsx : « Financement et coût de propriété » — calculateur // hypothécaire canadien branché sur les taux RÉELS observés (immoka/mortgage : // composition semestrielle pour les fixes, SCHL, test de résistance, // renouvellement, comparateur par banque, historique du taux, amortissement), // puis COÛT DE PROPRIÉTÉ MENSUEL : versement + taxes municipales/scolaires // publiées + copropriété + électricité (estimation Hydro-Québec quand elle // existe), chaque poste étiqueté (publié / estimé / inconnu). Reprend toute // la logique de l'ancien composant Financement, en accordéons compacts. // ----------------------------------------------------------------------------- import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { HydroEstimate, Listing, MortgageBest, MortgageCalc, calculateMortgage, fetchHydro, fetchMortgageBest, fmtPrice, fmtRate, } from "../api"; import { Ico } from "../components/Icons"; import TauxHistorique from "../components/TauxHistorique"; import { estLocation, fraisCoproMensuels, taxesAnnuelles } from "./synthese"; import { Accordion, ErrorState, SectionCard, StatTile, NBSP } from "./ui"; import { Res } from "./useFicheData"; const FREQS: [string, string][] = [ ["monthly", "Mensuel"], ["semimonthly", "Bimensuel (24/an)"], ["biweekly", "Aux 2 semaines"], ["accelerated-biweekly", "Aux 2 semaines accéléré"], ["weekly", "Hebdomadaire"], ["accelerated-weekly", "Hebdomadaire accéléré"], ]; const TERMES: [number, string][] = [[12, "1 an"], [24, "2 ans"], [36, "3 ans"], [48, "4 ans"], [60, "5 ans"], [84, "7 ans"], [120, "10 ans"]]; const KIND_FR: Record = { posted: "taux affiché", special: "offre spéciale" }; const INSURED_FR: Record = { insured: "assuré", insurable: "assurable", uninsured: "non assuré", unknown: "" }; const nf = (n: number) => n.toLocaleString("fr-CA", { maximumFractionDigits: 0 }); const money = (n: number | null | undefined) => n == null ? "—" : `${n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $`; const 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`); /** Versement estimé au taux d'une autre banque, même scénario (approximation * frontend par facteur d'annuité — les chiffres officiels du scénario viennent * toujours du moteur). */ 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 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; }; if (frequency.startsWith("accelerated")) { const m = pay0(rate, 12); return Math.round((m / (frequency === "accelerated-biweekly" ? 2 : 4)) * 100) / 100; } return pay0(rate, f); } export default function FinancingCard({ l, hydro }: { l: Listing; hydro: Res }) { const prix = l.price; const [price, setPrice] = useState(prix ?? 0); const [down, setDown] = useState(Math.round((prix ?? 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 [h, setH] = useState(null); const [busy, setBusy] = useState(false); 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)); // recalcul débobiné : les taux viennent du moteur, jamais du navigateur 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("Taux momentanément indisponibles — réessayez plus tard.")); }, 350); return () => window.clearTimeout(timer.current); }, [price, down, amort, term, rateType, freq]); // comparateur par banque (mêmes type/terme que le scénario) useEffect(() => { setBest(null); fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null)); }, [rateType, term]); const taxes = taxesAnnuelles(l); const copro = fraisCoproMensuels(l); const hy = h ?? (hydro.status === "ok" ? hydro.data : null); const hydroVisible = hy && (hy.disponible || hy.en_attente || !/captcha|configuré|incomplète/.test(hy.raison || "")); const lancerHydro = () => { setBusy(true); fetchHydro(l.address || l.title, { uid: l.uid, lat: l.lat, lng: l.lng }, true).then(setH).catch(() => {}).finally(() => setBusy(false)); }; const cout = useMemo(() => { if (!res) return null; const lignes: { poste: string; statut: "publié" | "estimé" | "inconnu"; montant: number | null }[] = [ { poste: "Versement hypothécaire (équiv. mensuel)", statut: "estimé", montant: res.payment_monthly_equivalent }, { poste: "Taxes municipales et scolaires", statut: taxes ? "publié" : "inconnu", montant: taxes ? Math.round(taxes.total / 12) : null }, ]; if (copro != null || /condo|copropri/i.test(l.property_type || "")) lignes.push({ poste: "Frais de copropriété", statut: copro != null ? "publié" : "inconnu", montant: copro }); lignes.push({ poste: "Électricité (Hydro-Québec)", statut: hy?.disponible ? "estimé" : "inconnu", montant: hy?.disponible ? Math.round(hy.cout_mensuel!) : null }); lignes.push({ poste: "Assurance habitation", statut: "inconnu", montant: null }); const total = lignes.reduce((s, x) => s + (x.montant ?? 0), 0); const inconnus = lignes.filter((x) => x.montant == null).map((x) => x.poste.toLowerCase()); return { lignes, total, inconnus }; }, [res, taxes, copro, hy, l.property_type]); if (prix == null || prix <= 0 || estLocation(l)) return null; const src = res?.rate_source ?? null; const ins = res?.insurance; return ( } sub="Taux réels publiés par les banques canadiennes, collectés en continu par Immo-Ka">
{err && {err}} {res && ( <>
{FREQS.find(([v]) => v === freq)?.[1]}{freq !== "monthly" && <> · équiv. {money(res.payment_monthly_equivalent)}/mois}} anim={false} />
{src && (

Taux observé chez {src.institution} {freshness(src.age_minutes)}{src.stale && " ⚠ donnée de plus de 24 h"} {src.source_url && <> · source officielle ↗} {" "}· Tous les taux

)} {ins && ins.required && (
Assurance prêt hypothécaire (SCHL) {ins.eligible && ( <> — prime {fmtPrice(ins.premium)} ({ins.premium_rate.toLocaleString("fr-CA")}{NBSP}% du prêt, ajoutée à l'hypothèque), TVQ sur la prime {money(ins.qc_tax)} payable à la clôture, rapport prêt-valeur {ins.ltv?.toLocaleString("fr-CA")}{NBSP}%. )} {ins.issues.map((i, k) =>
⚠ {i}
)}
)} {cout && ( <>

Coût de propriété mensuel estimé

    {cout.lignes.map((li) => (
  • {li.poste}{li.statut} {li.montant != null ? fmtPrice(li.montant) : "—"}
  • ))}
  • Total estimé≈{NBSP}{fmtPrice(Math.round(cout.total))}{NBSP}/mois
  • soit sur 12 mois≈{NBSP}{fmtPrice(Math.round(cout.total * 12))}
{cout.inconnus.length > 0 && (

Non chiffrables avec les données publiées : {cout.inconnus.join(", ")} — le total réel est plus élevé.

)} {taxes && (

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.

)} {hydroVisible && hy && (
{hy.disponible ? ( Électricité : {fmtPrice(hy.cout_mensuel!)}{NBSP}/mois (≈{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é. ) : hy.en_attente ? ( <> Estimation Hydro-Québec du coût d'électricité disponible à la demande. ) : ( Hydro-Québec n'a pas d'estimation pour cette adresse. )}
)} )}
{res.stress.map((s) => (
{s.bump === 0 ? "Taux actuel" : `+${s.bump} point${s.bump > 1 ? "s" : ""}`} — {fmtRate(s.rate)} {money(s.payment)}
))}

{res.qualifying.note}

m === term)?.[1]})`} small>

Solde restant à l'échéance : {fmtPrice(res.renewal.balance_at_renewal)} (amortissement résiduel {res.renewal.remaining_amortization_years} ans). Intérêts payés pendant le terme : {fmtPrice(res.term.interest_paid)}.

{res.renewal.scenarios.map((s) => (
Renouvelé à {fmtRate(s.rate)} ({s.bump >= 0 ? "+" : ""}{s.bump} pt) {money(s.payment)}
))}
{best && best.per_institution.length > 1 && (
{best.per_institution.map((r) => ( ))}
InstitutionTauxNatureVersementFraîcheur
{r.source_url ? {r.institution} : r.institution} {fmtRate(r.rate)}{r.apr != null ? ` (TAP ${fmtRate(r.apr)})` : ""} {KIND_FR[r.kind]}{INSURED_FR[r.insured_status] ? ` · ${INSURED_FR[r.insured_status]}` : ""} {res.principal > 0 ? money(estimatePayment(res, r.rate)) : "—"} {freshness(r.age_minutes)}

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.

)} m === term)?.[1]})`} small>
{res.annual.map((a) => ( ))}
AnnéeIntérêtsCapitalSolde
{a.year}{nf(a.interest)} ${nf(a.principal)} ${nf(a.balance)} $
{res.payoff_years < res.inputs.amortization_years && (

Avec la fréquence accélérée choisie, le prêt s'éteint en {res.payoff_years} ans au lieu de {res.inputs.amortization_years}.

)}
)}

Outil indicatif seulement — ni offre de financement ni préapprobation. Les taux affichés sont ceux publiés par les institutions (source et fraîcheur indiquées) ; vérifiez auprès de la banque ou d'un courtier hypothécaire.

); }