// Calculs hypothécaires illustratifs (Canada/Québec). Hypothèses éditables ; aucun taux n'est « réel » sauf indication datée et sourcée. export interface MortgageInput { price: number; downPayment: number; // $ rateAnnualPct: number; // taux affiché (composé semestriellement au Canada pour les prêts fixes) amortYears: number; // 25 ou 30 paymentsPerYear?: 12 | 26 | 52; municipalTaxAnnual?: number | null; schoolTaxAnnual?: number | null; insuranceMonthly?: number | null; condoFeesMonthly?: number | null; heatingMonthly?: number | null; } export interface MortgageResult { principal: number; cmhcPremium: number; cmhcRatePct: number; loan: number; payment: number; paymentMonthlyEq: number; totalInterest: number; totalPaid: number; monthlyAllIn: number; minDownPayment: number; downPaymentPct: number; warnings: string[]; schedule: { year: number; interest: number; principal: number; balance: number }[]; stressTest: { qualifyingRatePct: number; payment: number }; } /** Mise de fonds minimale au Canada : 5 % jusqu'à 500 k$, 10 % sur la tranche 500 k$–1,5 M$, 20 % au-delà de 1,5 M$ (règle en vigueur depuis le 15 déc. 2024). */ export function minDownPayment(price: number): number { if (price > 1_500_000) return price * 0.2; if (price <= 500_000) return price * 0.05; return 500_000 * 0.05 + (price - 500_000) * 0.10; } /** Prime SCHL/Sagen/Canada Guaranty selon le rapport prêt-valeur (barème standard). */ export function cmhcRatePct(ltv: number): number { if (ltv <= 0.65) return 0.6; if (ltv <= 0.75) return 1.7; if (ltv <= 0.80) return 2.4; if (ltv <= 0.85) return 2.8; if (ltv <= 0.90) return 3.1; if (ltv <= 0.95) return 4.0; return NaN; } /** Taux périodique équivalent à partir d'un taux nominal composé semestriellement (norme canadienne). */ export function periodicRate(rateAnnualPct: number, paymentsPerYear: number): number { const semi = rateAnnualPct / 100 / 2; return Math.pow(1 + semi, 2 / paymentsPerYear) - 1; } export function paymentFor(loan: number, rateAnnualPct: number, amortYears: number, paymentsPerYear = 12): number { const r = periodicRate(rateAnnualPct, paymentsPerYear); const n = amortYears * paymentsPerYear; if (r === 0) return loan / n; return (loan * r) / (1 - Math.pow(1 + r, -n)); } export function computeMortgage(i: MortgageInput): MortgageResult { const ppy = i.paymentsPerYear ?? 12; const warnings: string[] = []; const minDp = minDownPayment(i.price); const dp = Math.min(Math.max(i.downPayment, 0), i.price); if (dp < minDp - 1) warnings.push(`Mise de fonds inférieure au minimum réglementaire (${Math.round(minDp).toLocaleString("fr-CA")} $).`); const principal = i.price - dp; const ltv = principal / i.price; let premiumRate = 0; if (ltv > 0.8) { premiumRate = cmhcRatePct(ltv); if (Number.isNaN(premiumRate)) { warnings.push("Rapport prêt-valeur > 95 % : non assurable."); premiumRate = 0; } if (i.price >= 1_500_000) warnings.push("Prix ≥ 1,5 M$ : assurance prêt hypothécaire non disponible, 20 % de mise de fonds requis."); if (i.amortYears > 25) warnings.push("Prêt assuré : amortissement de 30 ans réservé aux premiers acheteurs ou aux constructions neuves."); } const cmhcPremium = principal * premiumRate / 100; const loan = principal + cmhcPremium; const payment = paymentFor(loan, i.rateAnnualPct, i.amortYears, ppy); const paymentMonthlyEq = payment * ppy / 12; const totalPaid = payment * i.amortYears * ppy; const totalInterest = totalPaid - loan; const extras = (i.municipalTaxAnnual ?? 0) / 12 + (i.schoolTaxAnnual ?? 0) / 12 + (i.insuranceMonthly ?? 0) + (i.condoFeesMonthly ?? 0) + (i.heatingMonthly ?? 0); // Tableau d'amortissement annuel const r = periodicRate(i.rateAnnualPct, ppy); let bal = loan; const schedule: MortgageResult["schedule"] = []; for (let y = 1; y <= i.amortYears; y++) { let yi = 0, yp = 0; for (let k = 0; k < ppy; k++) { const int = bal * r; const pr = Math.min(payment - int, bal); yi += int; yp += pr; bal -= pr; if (bal <= 0.005) { bal = 0; break; } } schedule.push({ year: y, interest: yi, principal: yp, balance: Math.max(0, bal) }); if (bal === 0) break; } const qualifyingRatePct = Math.max(i.rateAnnualPct + 2, 5.25); return { principal, cmhcPremium, cmhcRatePct: premiumRate, loan, payment, paymentMonthlyEq, totalInterest, totalPaid, monthlyAllIn: paymentMonthlyEq + extras, minDownPayment: minDp, downPaymentPct: dp / i.price, warnings, schedule, stressTest: { qualifyingRatePct, payment: paymentFor(loan, qualifyingRatePct, i.amortYears, ppy) * ppy / 12 }, }; } /** Droits de mutation (« taxe de bienvenue ») — barème provincial 2026 ; Montréal applique des tranches supérieures additionnelles (non modélisées ici). */ export function transferDuty(price: number, montreal = false): number { const brackets: [number, number][] = montreal ? [[61_500, 0.005], [307_800, 0.01], [552_300, 0.015], [1_104_700, 0.02], [2_136_500, 0.025], [3_113_000, 0.035], [Infinity, 0.04]] : [[61_500, 0.005], [307_800, 0.01], [Infinity, 0.015]]; let duty = 0, prev = 0; for (const [cap, rate] of brackets) { if (price <= prev) break; duty += (Math.min(price, cap) - prev) * rate; prev = cap; } return duty; }