feat: core financial engine (PMT, amortization, simulation calculations)

This commit is contained in:
2026-02-22 20:00:28 +01:00
parent 8f9674730d
commit 87370d8fad
5 changed files with 700 additions and 0 deletions

267
src/lib/calculations.ts Normal file
View File

@@ -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,
};
}

174
src/lib/defaults.ts Normal file
View File

@@ -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,
},
];

54
src/lib/financial.ts Normal file
View File

@@ -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;
}

49
src/lib/formatters.ts Normal file
View File

@@ -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);
}

156
src/lib/types.ts Normal file
View File

@@ -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;
}