SPB Git

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%
13.3 KB · 213 lines tsx
Raw Blame History
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