From 87370d8fade667a26b559121d0624dec1e04f933 Mon Sep 17 00:00:00 2001 From: antopoid Date: Sun, 22 Feb 2026 20:00:28 +0100 Subject: [PATCH] feat: core financial engine (PMT, amortization, simulation calculations) --- src/lib/calculations.ts | 267 ++++++++++++++++++++++++++++++++++++++++ src/lib/defaults.ts | 174 ++++++++++++++++++++++++++ src/lib/financial.ts | 54 ++++++++ src/lib/formatters.ts | 49 ++++++++ src/lib/types.ts | 156 +++++++++++++++++++++++ 5 files changed, 700 insertions(+) create mode 100644 src/lib/calculations.ts create mode 100644 src/lib/defaults.ts create mode 100644 src/lib/financial.ts create mode 100644 src/lib/formatters.ts create mode 100644 src/lib/types.ts diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts new file mode 100644 index 0000000..404f1e1 --- /dev/null +++ b/src/lib/calculations.ts @@ -0,0 +1,267 @@ +import { pmt } from './financial'; +import type { + SimulationState, + SimulationResults, + LoanResult, + LoanInput, + MonthlyRow, + YearlyRow, + YearlySummary, +} from './types'; + +const MAX_YEARS = 30; + +// ─── Main computation ─────────────────────────────────────────────────────── + +export function computeSimulation(state: SimulationState): SimulationResults { + // 1. Financing + const notaryFees = state.propertyPrice * state.notaryFeesRate; + const guaranteeFees = state.propertyPrice * state.guaranteeRate; + const totalFinancing = + state.propertyPrice + notaryFees + guaranteeFees + state.bankFees + state.worksCost; + const totalCredit = Math.max(0, totalFinancing - state.downPayment); + + // 2. Main loan amount + const otherLoansTotal = state.loans + .filter((l) => !l.isMainLoan && l.enabled) + .reduce((sum, l) => sum + l.amount, 0); + const mainLoanAmount = Math.max(0, totalCredit - otherLoansTotal); + + // 3. Per-loan amortization + const loanResults: LoanResult[] = state.loans + .filter((l) => l.enabled) + .map((loan) => { + const amount = loan.isMainLoan ? mainLoanAmount : loan.amount; + return computeLoan(loan, amount); + }); + + // 4. Max duration + const maxDuration = loanResults.length > 0 + ? Math.max(...loanResults.map((lr) => lr.loan.duration)) + : 0; + + // 5. Yearly summaries + const projectedChargesMonthly = + state.projectedCharges + + state.propertyTax + + state.projectedUtilities + + state.projectedHomeInsurance; + + const currentChargesMonthly = + state.currentUtilities + state.currentCharges + state.currentHomeInsurance; + + const yearlySummaries: YearlySummary[] = []; + let resalePrice = state.propertyPrice * (1 + state.firstYearDepreciation); + + for (let y = 1; y <= MAX_YEARS; y++) { + // Aggregate loan data for this year + let totalInterest = 0; + let totalPrincipal = 0; + let totalInsurance = 0; + let totalRemaining = 0; + let totalPayment = 0; + + for (const lr of loanResults) { + const yearData = lr.yearly.find((yr) => yr.year === y); + if (yearData) { + totalInterest += yearData.interest; + totalPrincipal += yearData.principal; + totalInsurance += yearData.insurance; + totalRemaining += yearData.remaining; + totalPayment += yearData.payment; + } else if (y <= lr.loan.duration) { + // Year exists but no data (shouldn't happen with correct computation) + } else { + // After loan duration — find last remaining + const lastYear = lr.yearly[lr.yearly.length - 1]; + if (lastYear) { + totalRemaining += lastYear.remaining; + } + } + } + + // Monthly projected costs + const monthlyInterest = totalInterest / 12; + const monthlyPrincipal = totalPrincipal / 12; + const monthlyLoanInsurance = totalInsurance / 12; + const monthlyTotal = monthlyInterest + monthlyPrincipal + projectedChargesMonthly + monthlyLoanInsurance; + const capitalRatio = + monthlyInterest + monthlyPrincipal + projectedChargesMonthly + monthlyLoanInsurance > 0 + ? monthlyPrincipal / (monthlyInterest + monthlyPrincipal + projectedChargesMonthly + monthlyLoanInsurance) + : 0; + + // Current costs (rent evolves) + const currentRent = state.currentRent * Math.pow(1 + state.rentEvolutionRate, y - 1); + const currentMonthlyTotal = currentRent + currentChargesMonthly; + + // Difference + const difference = monthlyTotal - currentMonthlyTotal; + + // Resale + if (y > 1) { + resalePrice = resalePrice * (1 + state.annualAppreciation); + } + const netResale = resalePrice * (1 - state.saleFees) - totalRemaining - state.downPayment; + + yearlySummaries.push({ + year: y, + totalInterest, + totalPrincipal, + totalInsurance, + totalRemaining, + totalPayment, + monthlyInterest, + monthlyPrincipal, + monthlyCharges: projectedChargesMonthly, + monthlyLoanInsurance, + monthlyTotal, + capitalRatio, + yearlyTotal: monthlyTotal * 12, + currentRent, + currentCharges: currentChargesMonthly, + currentMonthlyTotal, + currentYearlyTotal: currentMonthlyTotal * 12, + difference, + resalePrice, + netResale, + }); + } + + // 6. Global totals + const totalCapitalAll = loanResults.reduce((s, lr) => s + lr.effectiveAmount, 0); + const totalInterestAll = loanResults.reduce((s, lr) => s + lr.totalInterest, 0); + const totalInsuranceAll = loanResults.reduce((s, lr) => s + lr.totalInsurance, 0); + const grandTotal = totalCapitalAll + totalInterestAll + totalInsuranceAll; + + // Year 1 snapshot + const year1 = yearlySummaries[0]; + const monthlyPaymentYear1 = year1 ? year1.monthlyInterest + year1.monthlyPrincipal + year1.monthlyLoanInsurance : 0; + + return { + notaryFees, + guaranteeFees, + totalFinancing, + totalCredit, + mainLoanAmount, + loanResults, + yearlySummaries, + totalCapital: totalCapitalAll, + totalInterest: totalInterestAll, + totalInsurance: totalInsuranceAll, + grandTotal, + monthlyPaymentYear1, + projectedMonthlyCharges: projectedChargesMonthly, + projectedMonthlyTotal: year1?.monthlyTotal ?? 0, + currentMonthlyTotal: year1?.currentMonthlyTotal ?? 0, + maxDuration, + }; +} + +// ─── Loan-level computation ───────────────────────────────────────────────── + +function computeLoan(loan: LoanInput, amount: number): LoanResult { + if (amount <= 0 || loan.duration <= 0) { + return emptyLoanResult(loan, amount); + } + + const monthlyRate = loan.rate / 12; + const deferralMonths = Math.round(loan.deferral * 12); + const totalMonths = Math.round(loan.duration * 12); + const repaymentMonths = totalMonths - deferralMonths; + const monthlyPmt = repaymentMonths > 0 ? pmt(monthlyRate, repaymentMonths, amount) : 0; + const monthlyInsurance = amount * loan.insuranceRate / 12; + + let remaining = amount; + const monthly: MonthlyRow[] = []; + + for (let m = 1; m <= totalMonths; m++) { + const interest = remaining * monthlyRate; + let principal = 0; + + if (m > deferralMonths && repaymentMonths > 0) { + principal = monthlyPmt - interest; + // Protect against floating-point overshoot on the last payment + if (principal > remaining) { + principal = remaining; + } + remaining = Math.max(0, remaining - principal); + } + + monthly.push({ + month: m, + monthInYear: ((m - 1) % 12) + 1, + year: Math.ceil(m / 12), + interest, + principal, + insurance: monthlyInsurance, + remaining, + payment: interest + principal + monthlyInsurance, + }); + } + + // Aggregate to yearly + const yearly: YearlyRow[] = []; + const years = Math.ceil(totalMonths / 12); + for (let y = 1; y <= years; y++) { + const rows = monthly.filter((r) => r.year === y); + if (rows.length === 0) continue; + yearly.push({ + year: y, + interest: rows.reduce((s, r) => s + r.interest, 0), + principal: rows.reduce((s, r) => s + r.principal, 0), + insurance: rows.reduce((s, r) => s + r.insurance, 0), + remaining: rows[rows.length - 1].remaining, + payment: rows.reduce((s, r) => s + r.payment, 0), + }); + } + + // Pad yearly to MAX_YEARS for easy aggregation + for (let y = years + 1; y <= MAX_YEARS; y++) { + yearly.push({ + year: y, + interest: 0, + principal: 0, + insurance: 0, + remaining: 0, + payment: 0, + }); + } + + const totalInterest = monthly.reduce((s, r) => s + r.interest, 0); + const totalInsurance = monthly.reduce((s, r) => s + r.insurance, 0); + + return { + loan, + effectiveAmount: amount, + monthlyPayment: monthlyPmt, + monthlyInsurance, + monthly, + yearly, + totalInterest, + totalInsurance, + totalCost: amount + totalInterest + totalInsurance, + }; +} + +function emptyLoanResult(loan: LoanInput, amount: number): LoanResult { + const yearly: YearlyRow[] = Array.from({ length: MAX_YEARS }, (_, i) => ({ + year: i + 1, + interest: 0, + principal: 0, + insurance: 0, + remaining: 0, + payment: 0, + })); + + return { + loan, + effectiveAmount: Math.max(0, amount), + monthlyPayment: 0, + monthlyInsurance: 0, + monthly: [], + yearly, + totalInterest: 0, + totalInsurance: 0, + totalCost: 0, + }; +} diff --git a/src/lib/defaults.ts b/src/lib/defaults.ts new file mode 100644 index 0000000..9b5aaa5 --- /dev/null +++ b/src/lib/defaults.ts @@ -0,0 +1,174 @@ +import type { SimulationState, LoanInput } from './types'; + +let loanIdCounter = 0; +export function generateLoanId(): string { + return `loan_${++loanIdCounter}_${Date.now()}`; +} + +// ─── Default loans ────────────────────────────────────────────────────────── + +export function defaultPTZ(): LoanInput { + return { + id: generateLoanId(), + name: 'Prêt Taux Zéro', + amount: 90000, + rate: 0, + duration: 20, + deferral: 0, + insuranceRate: 0.003, + isMainLoan: false, + enabled: true, + }; +} + +export function defaultActionLogement(): LoanInput { + return { + id: generateLoanId(), + name: 'Action Logement', + amount: 30000, + rate: 0.01, + duration: 25, + deferral: 0, + insuranceRate: 0.003, + isMainLoan: false, + enabled: true, + }; +} + +export function defaultPrimoBoost(): LoanInput { + return { + id: generateLoanId(), + name: 'Primo Boost', + amount: 25000, + rate: 0.0199, + duration: 25, + deferral: 0, + insuranceRate: 0.003, + isMainLoan: false, + enabled: true, + }; +} + +export function defaultMainLoan(): LoanInput { + return { + id: generateLoanId(), + name: 'Prêt Immobilier Classique', + amount: 0, // Auto-calculated + rate: 0.0325, + duration: 25, + deferral: 0, + insuranceRate: 0.003, + isMainLoan: true, + enabled: true, + }; +} + +export function createEmptyLoan(): LoanInput { + return { + id: generateLoanId(), + name: 'Nouveau prêt', + amount: 0, + rate: 0, + duration: 25, + deferral: 0, + insuranceRate: 0.003, + isMainLoan: false, + enabled: true, + }; +} + +// ─── Default state for "Neuf" ─────────────────────────────────────────────── + +export function defaultStateNeuf(): SimulationState { + return { + propertyType: 'neuf', + propertyPrice: 396000, + notaryFeesRate: 0.025, + guaranteeRate: 0.01, + bankFees: 1280, + worksCost: 0, + downPayment: 15000, + + currentRent: 1216, + rentEvolutionRate: 0.012, + currentUtilities: 160, + currentCharges: 100, + currentHomeInsurance: 32, + + projectedCharges: 150, + propertyTax: 100, + projectedUtilities: 80, + projectedHomeInsurance: 35, + + loans: [defaultPTZ(), defaultActionLogement(), defaultPrimoBoost(), defaultMainLoan()], + + firstYearDepreciation: -0.05, + annualAppreciation: 0.01, + saleFees: 0.07, + }; +} + +// ─── Default state for "Ancien" ───────────────────────────────────────────── + +export function defaultStateAncien(): SimulationState { + return { + propertyType: 'ancien', + propertyPrice: 396000, + notaryFeesRate: 0.08, + guaranteeRate: 0.01, + bankFees: 1280, + worksCost: 0, + downPayment: 15000, + + currentRent: 1216, + rentEvolutionRate: 0.012, + currentUtilities: 160, + currentCharges: 100, + currentHomeInsurance: 32, + + projectedCharges: 150, + propertyTax: 100, + projectedUtilities: 80, + projectedHomeInsurance: 35, + + // No PTZ for ancien + loans: [defaultActionLogement(), defaultPrimoBoost(), defaultMainLoan()], + + firstYearDepreciation: -0.05, + annualAppreciation: 0.01, + saleFees: 0.07, + }; +} + +// ─── Loan presets ─────────────────────────────────────────────────────────── + +export interface LoanPreset { + label: string; + description: string; + create: () => LoanInput; + neufOnly?: boolean; +} + +export const LOAN_PRESETS: LoanPreset[] = [ + { + label: 'Prêt Taux Zéro (PTZ)', + description: 'Prêt à 0% pour primo-accédants (neuf uniquement)', + create: defaultPTZ, + neufOnly: true, + }, + { + label: 'Action Logement', + description: 'Prêt employeur à taux réduit', + create: defaultActionLogement, + }, + { + label: 'Primo Boost', + description: 'Prêt aidé pour primo-accédants', + create: defaultPrimoBoost, + }, + { + label: 'Prêt personnalisé', + description: 'Ajouter un prêt avec des paramètres libres', + create: createEmptyLoan, + }, +]; diff --git a/src/lib/financial.ts b/src/lib/financial.ts new file mode 100644 index 0000000..09bb922 --- /dev/null +++ b/src/lib/financial.ts @@ -0,0 +1,54 @@ +/** + * Core financial functions replicating Excel's PMT, IPMT, PPMT. + * All functions return positive values (Excel returns negative for payments). + */ + +/** + * PMT — Fixed periodic payment for a loan. + * Equivalent to Excel: -PMT(rate, nper, pv) + */ +export function pmt(rate: number, nper: number, pv: number): number { + if (pv <= 0 || nper <= 0) return 0; + if (rate === 0) return pv / nper; + const factor = Math.pow(1 + rate, nper); + return (pv * rate * factor) / (factor - 1); +} + +/** + * IPMT — Interest portion of a specific payment period. + * Equivalent to Excel: -IPMT(rate, per, nper, pv) + * @param per 1-based period number + */ +export function ipmt(rate: number, per: number, nper: number, pv: number): number { + if (rate === 0 || pv <= 0 || nper <= 0) return 0; + const bal = remainingBalance(rate, per - 1, nper, pv); + return bal * rate; +} + +/** + * PPMT — Principal portion of a specific payment period. + * Equivalent to Excel: -PPMT(rate, per, nper, pv) + * @param per 1-based period number + */ +export function ppmt(rate: number, per: number, nper: number, pv: number): number { + if (pv <= 0 || nper <= 0) return 0; + return pmt(rate, nper, pv) - ipmt(rate, per, nper, pv); +} + +/** + * Remaining balance after `per` payments. + * @param per Number of payments already made (0 = initial balance) + */ +export function remainingBalance(rate: number, per: number, nper: number, pv: number): number { + if (pv <= 0 || nper <= 0) return 0; + if (per <= 0) return pv; + if (per >= nper) return 0; + + if (rate === 0) { + return pv - pmt(rate, nper, pv) * per; + } + + const payment = pmt(rate, nper, pv); + const factor = Math.pow(1 + rate, per); + return pv * factor - payment * (factor - 1) / rate; +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts new file mode 100644 index 0000000..30e3b71 --- /dev/null +++ b/src/lib/formatters.ts @@ -0,0 +1,49 @@ +/** + * Format a number as Euro currency (French locale). + * 396000 → "396 000 €" + */ +export function formatCurrency(value: number, decimals = 0): string { + return new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); +} + +/** + * Format a number as a short currency (for charts). + * 396000 → "396k €" + */ +export function formatCurrencyShort(value: number): string { + if (Math.abs(value) >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M €`; + } + if (Math.abs(value) >= 1_000) { + return `${(value / 1_000).toFixed(0)}k €`; + } + return `${value.toFixed(0)} €`; +} + +/** + * Format a rate as percentage. + * 0.0325 → "3,25 %" + */ +export function formatPercent(value: number, decimals = 2): string { + return new Intl.NumberFormat('fr-FR', { + style: 'percent', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); +} + +/** + * Format a plain number with French locale. + * 1234.56 → "1 234,56" + */ +export function formatNumber(value: number, decimals = 0): string { + return new Intl.NumberFormat('fr-FR', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..4247d51 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,156 @@ +// ─── Property Type ─────────────────────────────────────────────────────────── + +export type PropertyType = 'neuf' | 'ancien'; + +// ─── Loan Input ────────────────────────────────────────────────────────────── + +export interface LoanInput { + id: string; + name: string; + amount: number; + rate: number; // Annual rate as decimal (3.25% → 0.0325) + duration: number; // Years + deferral: number; // Deferred period in years + insuranceRate: number; // Annual rate on initial capital (0.3% → 0.003) + isMainLoan: boolean; // If true, amount = totalCredit - sum(others) + enabled: boolean; +} + +// ─── Simulation State (all inputs) ────────────────────────────────────────── + +export interface SimulationState { + // Project + propertyType: PropertyType; + propertyPrice: number; + notaryFeesRate: number; + guaranteeRate: number; + bankFees: number; + worksCost: number; + downPayment: number; + + // Current costs (tenant) + currentRent: number; + rentEvolutionRate: number; // Annual evolution (1.2% → 0.012) + currentUtilities: number; // EDF / fibre / eau + currentCharges: number; + currentHomeInsurance: number; + + // Projected costs (owner) + projectedCharges: number; // Copropriété + propertyTax: number; // Taxe foncière (monthly) + projectedUtilities: number; // EDF / fibre / eau + projectedHomeInsurance: number; + + // Loans + loans: LoanInput[]; + + // Resale parameters + firstYearDepreciation: number; // e.g., -0.05 (5% loss first year) + annualAppreciation: number; // e.g., 0.01 (1% per year) + saleFees: number; // e.g., 0.07 (7% agency + notary) +} + +// ─── Monthly Amortization (per loan) ──────────────────────────────────────── + +export interface MonthlyRow { + month: number; // 1-based global month + monthInYear: number; // 1-12 + year: number; // 1-based year + interest: number; + principal: number; + insurance: number; + remaining: number; + payment: number; // interest + principal + insurance +} + +// ─── Yearly Amortization (per loan) ───────────────────────────────────────── + +export interface YearlyRow { + year: number; + interest: number; + principal: number; + insurance: number; + remaining: number; + payment: number; +} + +// ─── Loan Computation Result ──────────────────────────────────────────────── + +export interface LoanResult { + loan: LoanInput; + effectiveAmount: number; // Actual amount (auto-calculated for main) + monthlyPayment: number; // Monthly principal + interest (excl. insurance) + monthlyInsurance: number; + monthly: MonthlyRow[]; + yearly: YearlyRow[]; + totalInterest: number; + totalInsurance: number; + totalCost: number; // amount + totalInterest + totalInsurance +} + +// ─── Yearly Summary (aggregated across all loans) ─────────────────────────── + +export interface YearlySummary { + year: number; + + // Credits aggregated + totalInterest: number; + totalPrincipal: number; + totalInsurance: number; + totalRemaining: number; + totalPayment: number; + + // Projected monthly costs (as owner) + monthlyInterest: number; + monthlyPrincipal: number; + monthlyCharges: number; // charges + tax + utilities + home insurance + monthlyLoanInsurance: number; + monthlyTotal: number; + capitalRatio: number; // Part of payment building equity + yearlyTotal: number; + + // Current monthly costs (as tenant) + currentRent: number; + currentCharges: number; // utilities + charges + insurance + currentMonthlyTotal: number; + currentYearlyTotal: number; + + // Comparison + difference: number; // projected - current (negative = savings) + + // Resale projection + resalePrice: number; + netResale: number; // After fees - remaining debt - down payment +} + +// ─── Final Simulation Results ─────────────────────────────────────────────── + +export interface SimulationResults { + // Financing + notaryFees: number; + guaranteeFees: number; + totalFinancing: number; + totalCredit: number; + mainLoanAmount: number; + + // Per-loan results + loanResults: LoanResult[]; + + // Yearly summaries (up to 30 years) + yearlySummaries: YearlySummary[]; + + // Global totals + totalCapital: number; + totalInterest: number; + totalInsurance: number; + grandTotal: number; + + // Year 1 monthly snapshot + monthlyPaymentYear1: number; + projectedMonthlyCharges: number; + projectedMonthlyTotal: number; + currentMonthlyTotal: number; + + // Max duration across all loans + maxDuration: number; +}