spb/valoplex Public
ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 90.3%
Python 7.1%
CSS 2.5%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Pro forma interactif de l'investisseur — « avec les frais et tout le kit ».4 * Loyer moyen implicite qui justifie la valeur, cascade revenus → RNE,5 * financement hypothécaire canadien, cashflow par porte, DSCR, droits de6 * mutation et liquidités requises. Chaque hypothèse est ajustable en direct.7 */8"use client";9import { useState } from "react";10import { buildProforma, DEFAULT_PARAMS, tgaReference } from "@/lib/proforma";11import { useLang } from "./LangContext";1213const fmt = (v: number, lang: string, frac = 0) =>14 new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {15 style: "currency",16 currency: "CAD",17 maximumFractionDigits: frac,18 }).format(v);1920function Slider({21 label, value, min, max, step, unit, onChange,22}: {23 label: string; value: number; min: number; max: number; step: number;24 unit: string; onChange: (v: number) => void;25}) {26 return (27 <label className="flex flex-col gap-1">28 <span className="klabel flex items-baseline justify-between">29 {label}30 <span className="vp-display text-[14px] font-bold normal-case tracking-normal text-ink">31 {value.toLocaleString("fr-CA")} {unit}32 </span>33 </span>34 <input35 type="range"36 min={min}37 max={max}38 step={step}39 value={value}40 onChange={(e) => onChange(Number(e.target.value))}41 className="h-2 w-full cursor-pointer appearance-none rounded-full border-[1.5px] border-ink bg-surface-2 accent-[var(--green)]"42 />43 </label>44 );45}4647function Row({ k, v, strong, neg }: { k: string; v: string; strong?: boolean; neg?: boolean }) {48 return (49 <div className={`flex items-baseline justify-between gap-3 py-1.5 ${strong ? "border-t-[1.5px] border-ink" : "border-t border-dashed border-[rgba(20,24,20,0.12)]"}`}>50 <span className={strong ? "vp-display text-[13.5px] font-bold uppercase" : "text-[13px] text-ink-2"}>{k}</span>51 <span className={`vp-mono text-[13px] font-bold ${neg ? "text-[var(--danger)]" : strong ? "text-ink" : "text-ink-2"}`}>{v}</span>52 </div>53 );54}5556export default function Proforma({57 valeur, doors, valeurRole, municipalite,58}: {59 valeur: number; doors: number; valeurRole: number | null; municipalite: string | null;60}) {61 const { lang } = useLang();62 const fr = lang === "fr";63 const [taux, setTaux] = useState(DEFAULT_PARAMS.tauxHypoPct);64 const [mise, setMise] = useState(DEFAULT_PARAMS.miseDeFondsPct);65 const [amort, setAmort] = useState(DEFAULT_PARAMS.amortAns);66 const [tga, setTga] = useState(tgaReference(doors));67 const [appr, setAppr] = useState(DEFAULT_PARAMS.appreciationPct);6869 const pf = buildProforma(valeur, doors, valeurRole, municipalite, {70 tauxHypoPct: taux, miseDeFondsPct: mise, amortAns: amort, tgaPct: tga,71 appreciationPct: appr,72 });7374 const DEP_FR: Record<string, string> = {75 taxesMunicipales: fr ? "Taxes municipales (≈1,1 % du rôle)" : "Municipal taxes (≈1.1% of roll)",76 taxeScolaire: fr ? "Taxe scolaire (≈0,1 % du rôle)" : "School tax (≈0.1% of roll)",77 assurances: fr ? "Assurances" : "Insurance",78 entretien: fr ? "Entretien et réparations (5 % RB)" : "Maintenance & repairs (5% GI)",79 gestion: fr ? "Gestion (4 % RB)" : "Management (4% GI)",80 deneigementPelouse: fr ? "Déneigement et pelouse" : "Snow & lawn",81 energieCommuns: fr ? "Énergie des espaces communs" : "Common-area energy",82 reserveRemplacement: fr ? "Réserve de remplacement" : "Replacement reserve",83 };8485 return (86 <section>87 <span className="kicker">{fr ? "Approche revenu — interactif" : "Income approach — interactive"}</span>88 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">89 {fr ? "Pro forma de l'investisseur" : "Investor pro forma"}90 </h2>91 <p className="mt-2 max-w-2xl text-[13.5px] text-ink-2">92 {fr93 ? `Quelle structure de revenus la valeur estimée suppose-t-elle ? Au TGA choisi, ce ${doors <= 6 ? "plex" : "immeuble"} doit générer un loyer moyen de `94 : `What income structure does the estimated value imply? At the chosen cap rate, this building must generate an average rent of `}95 <b className="vp-display text-green-deep">{fmt(pf.loyerMoyenMensuel, lang)}</b>96 {fr ? " par porte par mois. Ajustez les hypothèses — tout recalcule." : " per door per month. Adjust the assumptions — everything recomputes."}97 </p>9899 {/* hypothèses ajustables */}100 <div className="vp-card mt-4 grid gap-x-8 gap-y-4 p-5 sm:grid-cols-2 lg:grid-cols-5">101 <Slider label={fr ? "Taux hypothécaire (5 ans)" : "Mortgage rate (5 yr)"} value={taux} min={2.5} max={8.5} step={0.05} unit="%" onChange={setTaux} />102 <Slider label={fr ? "Mise de fonds" : "Down payment"} value={mise} min={5} max={50} step={1} unit="%" onChange={setMise} />103 <Slider label={fr ? "Amortissement" : "Amortization"} value={amort} min={15} max={30} step={1} unit={fr ? "ans" : "yrs"} onChange={setAmort} />104 <Slider label={`TGA (${fr ? "réf. gabarit" : "size ref."} ${tgaReference(doors)} %)`} value={tga} min={3.5} max={8} step={0.05} unit="%" onChange={setTga} />105 <Slider label={fr ? "Appréciation annuelle" : "Annual appreciation"} value={appr} min={0} max={6} step={0.25} unit="%" onChange={setAppr} />106 </div>107108 <div className="mt-4 grid items-start gap-4 lg:grid-cols-2">109 {/* état des résultats */}110 <div className="vp-card p-5 sm:p-6">111 <p className="klabel mb-2">{fr ? "État des résultats normalisé (annuel)" : "Normalized operating statement (annual)"}</p>112 <Row k={fr ? `Revenus bruts (${doors} portes × ${fmt(pf.loyerMoyenMensuel, lang)}/mois)` : `Gross income (${doors} doors × ${fmt(pf.loyerMoyenMensuel, lang)}/mo)`} v={fmt(pf.revenusBruts, lang)} />113 <Row k={fr ? `Vacance et mauvaises créances (${pf.params.vacancePct} %)` : `Vacancy & bad debt (${pf.params.vacancePct}%)`} v={fmt(pf.vacance, lang)} neg />114 <Row k={fr ? "Revenus effectifs" : "Effective income"} v={fmt(pf.revenusEffectifs, lang)} strong />115 {pf.depenses.map((d) => (116 <Row key={d.key} k={DEP_FR[d.key] ?? d.key} v={fmt(d.amount, lang)} neg />117 ))}118 <Row k={fr ? `Total des dépenses (${pf.ratioDepensesPct.toFixed(0)} % des RE)` : `Total expenses (${pf.ratioDepensesPct.toFixed(0)}% of EI)`} v={fmt(pf.totalDepenses, lang)} strong neg />119 <Row k={fr ? "Revenu net d'exploitation (RNE)" : "Net operating income (NOI)"} v={fmt(pf.rne, lang)} strong />120 <div className="vp-mono mt-3 flex flex-wrap gap-x-5 gap-y-1 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-2.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">121 <span>TGA {fr ? "implicite" : "implied"} : <b className="text-ink">{pf.tgaImplicitePct.toFixed(2)} %</b></span>122 <span>MRB : <b className="text-ink">× {pf.mrb.toFixed(1)}</b></span>123 <span>{fr ? "RNE/porte" : "NOI/door"} : <b className="text-ink">{fmt(pf.rne / doors, lang)}</b></span>124 </div>125 </div>126127 {/* financement + acquisition */}128 <div className="flex flex-col gap-4">129 <div className="vp-card p-5 sm:p-6">130 <p className="klabel mb-2">{fr ? "Financement" : "Financing"}</p>131 <Row k={fr ? `Hypothèque (${100 - pf.params.miseDeFondsPct} % de la valeur)` : `Mortgage (${100 - pf.params.miseDeFondsPct}% of value)`} v={fmt(pf.hypotheque, lang)} />132 <Row k={fr ? `Paiement mensuel (${pf.params.tauxHypoPct.toFixed(2)} %, ${pf.params.amortAns} ans)` : `Monthly payment (${pf.params.tauxHypoPct.toFixed(2)}%, ${pf.params.amortAns} yrs)`} v={fmt(pf.paiementMensuelHypo, lang)} />133 <Row k={fr ? "Service de la dette (annuel)" : "Debt service (annual)"} v={fmt(-pf.serviceDetteAnnuel, lang)} neg />134 <Row k={fr ? "Cashflow avant impôt" : "Pre-tax cashflow"} v={fmt(pf.cashflowAnnuel, lang)} strong neg={pf.cashflowAnnuel < 0} />135 <div className="mt-3 flex flex-wrap gap-2.5">136 <span className={`pill ${pf.dscr >= 1.2 ? "pill-conf-A" : pf.dscr >= 1.0 ? "pill-conf-C" : "pill-conf-D"}`}>137 DSCR {pf.dscr.toFixed(2)}138 </span>139 <span className={`pill ${pf.cashflowMensuelParPorte >= 0 ? "pill-conf-B" : "pill-conf-D"}`}>140 {fmt(pf.cashflowMensuelParPorte, lang)} / {fr ? "porte / mois" : "door / mo"}141 </span>142 <span className={`pill ${pf.cashOnCashPct >= 4 ? "pill-conf-A" : pf.cashOnCashPct >= 0 ? "pill-conf-C" : "pill-conf-D"}`}>143 {fr ? "Rend. liquidités" : "Cash-on-cash"} {pf.cashOnCashPct.toFixed(1)} %144 </span>145 </div>146 </div>147 <div className="vp-card border-ink bg-ink p-5 text-paper sm:p-6">148 <p className="klabel !text-lime mb-2">{fr ? "Liquidités requises — tout le kit" : "Cash required — the whole kit"}</p>149 <Row k={fr ? "Mise de fonds" : "Down payment"} v={fmt(pf.miseDeFonds, lang)} />150 <Row k={fr ? `Droits de mutation${(municipalite ?? "").toLowerCase().includes("montréal") ? " (barème Montréal)" : ""}` : "Transfer tax"} v={fmt(pf.droitsMutation, lang)} />151 <Row k={fr ? "Notaire" : "Notary"} v={fmt(pf.fraisNotaire, lang)} />152 <Row k={fr ? "Inspection" : "Inspection"} v={fmt(pf.fraisInspection, lang)} />153 <div className="mt-2 flex items-baseline justify-between border-t-[1.5px] border-lime pt-2">154 <span className="vp-display text-[14px] font-bold uppercase text-lime">{fr ? "Total à prévoir" : "Total to plan"}</span>155 <span className="vp-display text-[22px] font-bold text-lime">{fmt(pf.liquiditesRequises, lang)}</span>156 </div>157 </div>158 </div>159 </div>160 {/* projection 5 ans + marges de sécurité */}161 <div className="mt-4 grid items-start gap-4 lg:grid-cols-2">162 <div className="vp-card p-5 sm:p-6">163 <p className="klabel mb-2">164 {fr ? `Création de richesse — 5 ans (appréciation ${pf.params.appreciationPct} %/an)` : `Wealth creation — 5 years (${pf.params.appreciationPct}%/yr appreciation)`}165 </p>166 <Row k={fr ? "Capital remboursé — année 1" : "Principal paid — year 1"} v={fmt(pf.capitalAn1, lang)} />167 <Row k={fr ? "Intérêts — année 1" : "Interest — year 1"} v={fmt(-pf.interetsAn1, lang)} neg />168 <Row k={fr ? "Rendement global année 1 (cashflow + capital)" : "Total return year 1 (cashflow + principal)"} v={`${pf.rendementTotalAn1Pct.toFixed(1)} %`} strong />169 <Row k={fr ? "Valeur projetée à 5 ans" : "Projected value in 5 yrs"} v={fmt(pf.valeur5Ans, lang)} />170 <Row k={fr ? "Solde hypothécaire à 5 ans" : "Mortgage balance in 5 yrs"} v={fmt(-pf.soldeHypo5Ans, lang)} neg />171 <Row k={fr ? "Équité à 5 ans" : "Equity in 5 yrs"} v={fmt(pf.equite5Ans, lang)} strong />172 <Row k={fr ? "Gain total 5 ans (cashflows + capital + plus-value)" : "Total 5-yr gain (cashflows + principal + appreciation)"} v={fmt(pf.gainTotal5Ans, lang)} strong />173 <div className="mt-3">174 <span className={`pill ${pf.multipleLiquidites5Ans >= 1.8 ? "pill-conf-A" : pf.multipleLiquidites5Ans >= 1.2 ? "pill-conf-B" : "pill-conf-C"}`}>175 {fr ? "Multiple sur liquidités à 5 ans" : "5-yr equity multiple"} : × {pf.multipleLiquidites5Ans.toFixed(2)}176 </span>177 </div>178 </div>179 <div className="vp-card p-5 sm:p-6">180 <p className="klabel mb-2">{fr ? "Marges de sécurité et sensibilité" : "Safety margins & sensitivity"}</p>181 <Row k={fr ? "Loyer implicite / porte / mois" : "Implied rent / door / mo"} v={fmt(pf.loyerMoyenMensuel, lang)} />182 <Row k={fr ? "Loyer de point mort (cashflow = 0)" : "Breakeven rent (cashflow = 0)"} v={fmt(pf.loyerPointMort, lang)} />183 <Row184 k={fr ? "Marge de sécurité sur les loyers" : "Rent safety margin"}185 v={`${pf.margeSecuriteLoyerPct.toFixed(1)} %`}186 strong187 neg={pf.margeSecuriteLoyerPct < 0}188 />189 <Row190 k={fr ? "Taux hypothécaire de point mort" : "Breakeven mortgage rate"}191 v={pf.tauxPointMortPct != null ? `${pf.tauxPointMortPct.toFixed(2)} %` : fr ? "déjà négatif" : "already negative"}192 strong193 />194 <p className="klabel mb-1.5 mt-4">{fr ? "Cashflow selon le taux" : "Cashflow by rate"}</p>195 <div className="flex flex-wrap gap-2">196 {pf.sensibiliteTaux.map((sc) => (197 <span key={sc.tauxPct} className={`pill ${sc.cashflowAnnuel >= 0 ? "pill-conf-B" : "pill-conf-D"}`}>198 {sc.tauxPct.toFixed(2)} % → {fmt(sc.cashflowAnnuel, lang)}/{fr ? "an" : "yr"} · DSCR {sc.dscr.toFixed(2)}199 </span>200 ))}201 </div>202 </div>203 </div>204205 <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">206 {fr207 ? "Pro forma indicatif à hypothèses normalisées (loyer implicite dérivé de la valeur estimée et du TGA — pas des baux réels). Taxes approximées depuis la valeur au rôle. Ne constitue pas un conseil financier."208 : "Indicative pro forma with normalized assumptions (implied rent derived from estimated value and cap rate — not actual leases). Taxes approximated from assessed value. Not financial advice."}209 </p>210 </section>211 );212}213