// Auteur : Simon-Pierre Boucher — contact@spboucher.ai import { describe, expect, it } from "vitest"; import { buildProforma, droitsMutation, paiementMensuel, soldeApresMois, tauxMensuelCanadien, tgaReference, } from "./proforma"; describe("tauxMensuelCanadien", () => { it("composition semestrielle : 6 % annuel → ~0,4939 % mensuel", () => { expect(tauxMensuelCanadien(6)).toBeCloseTo(0.0049386, 6); }); }); describe("paiementMensuel (hypothèque canadienne)", () => { it("cas de référence : 100 000 $ à 6 % sur 25 ans ≈ 639,81 $", () => { expect(paiementMensuel(100000, 6, 25)).toBeCloseTo(639.81, 0); }); it("principal nul → 0", () => { expect(paiementMensuel(0, 5, 25)).toBe(0); }); }); describe("droitsMutation", () => { it("barème QC standard : 500 000 $ hors Montréal", () => { // 61 500×0,5% + (307 800−61 500)×1% + (500 000−307 800)×1,5% const attendu = Math.round(61500 * 0.005 + (307800 - 61500) * 0.01 + (500000 - 307800) * 0.015); expect(droitsMutation(500000, false)).toBe(attendu); }); it("Montréal > QC standard pour un prix élevé", () => { expect(droitsMutation(2000000, true)).toBeGreaterThan(droitsMutation(2000000, false)); }); it("identiques sous 307 800 $", () => { expect(droitsMutation(250000, true)).toBe(droitsMutation(250000, false)); }); }); describe("tgaReference", () => { it("croît avec le gabarit", () => { expect(tgaReference(2)).toBeLessThan(tgaReference(6)); expect(tgaReference(6)).toBeLessThan(tgaReference(20)); }); }); describe("soldeApresMois", () => { it("décroît avec le temps et atteint ~0 à l'échéance", () => { const s12 = soldeApresMois(300000, 5, 25, 12); const s60 = soldeApresMois(300000, 5, 25, 60); expect(s12).toBeLessThan(300000); expect(s60).toBeLessThan(s12); expect(soldeApresMois(300000, 5, 25, 300)).toBeLessThan(1); }); }); describe("buildProforma", () => { const pf = buildProforma(1_095_300, 3, 2_160_600, "Montréal"); it("le RNE implicite correspond au TGA de référence appliqué à la valeur", () => { expect(pf.rne).toBeCloseTo(1_095_300 * (pf.params.tgaPct / 100), 0); expect(pf.tgaImplicitePct).toBeCloseTo(pf.params.tgaPct, 1); }); it("le loyer implicite reconstruit exactement les revenus bruts", () => { expect(pf.loyerMoyenMensuel * 12 * 3).toBeCloseTo(pf.revenusBruts, 6); }); it("la cascade comptable est cohérente : RE + dépenses = RNE", () => { expect(pf.revenusEffectifs + pf.totalDepenses).toBeCloseTo(pf.rne, 6); }); it("cashflow = RNE − service de la dette", () => { expect(pf.cashflowAnnuel).toBeCloseTo(pf.rne - pf.serviceDetteAnnuel, 6); }); it("liquidités = mise de fonds + mutation + notaire + inspection", () => { expect(pf.liquiditesRequises).toBeCloseTo( pf.miseDeFonds + pf.droitsMutation + pf.fraisNotaire + pf.fraisInspection, 6); }); it("un taux plus élevé réduit le cashflow et le DSCR", () => { const cher = buildProforma(1_095_300, 3, 2_160_600, "Montréal", { tauxHypoPct: 7 }); expect(cher.cashflowAnnuel).toBeLessThan(pf.cashflowAnnuel); expect(cher.dscr).toBeLessThan(pf.dscr); }); it("plus de mise de fonds améliore le cashflow", () => { const gros = buildProforma(1_095_300, 3, 2_160_600, "Montréal", { miseDeFondsPct: 40 }); expect(gros.cashflowAnnuel).toBeGreaterThan(pf.cashflowAnnuel); }); it("capital an 1 + intérêts an 1 = service de la dette", () => { expect(pf.capitalAn1 + pf.interetsAn1).toBeCloseTo(pf.serviceDetteAnnuel, 4); }); it("équité 5 ans = valeur projetée − solde", () => { expect(pf.equite5Ans).toBeCloseTo(pf.valeur5Ans - pf.soldeHypo5Ans, 4); }); it("au loyer de point mort, le cashflow est nul (reconstruit)", () => { // RB' au point mort → RNE' = service de dette const rbPM = pf.loyerPointMort * 12 * 3; const denom = 1 - pf.params.vacancePct / 100 - pf.params.gestionPct / 100 - pf.params.entretienPct / 100; const F = -pf.depenses.filter((d) => !["entretien", "gestion"].includes(d.key)) .reduce((a, d) => a + d.amount, 0); const rnePM = rbPM * denom - F; expect(rnePM).toBeCloseTo(pf.serviceDetteAnnuel, 0); }); it("le taux de point mort annule le cashflow", () => { if (pf.tauxPointMortPct != null) { const cf = pf.rne - 12 * paiementMensuel(pf.hypotheque, pf.tauxPointMortPct, pf.params.amortAns); expect(Math.abs(cf)).toBeLessThan(1); } }); it("les taxes sont dérivées de la valeur au rôle", () => { const taxes = pf.depenses.find((d) => d.key === "taxesMunicipales")!; expect(-taxes.amount).toBeCloseTo(2_160_600 * 0.011, 2); }); });