SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
17.7 KB · 296 lines tsx
Raw Blame History
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