feat: core financial engine (PMT, amortization, simulation calculations)
This commit is contained in:
267
src/lib/calculations.ts
Normal file
267
src/lib/calculations.ts
Normal 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
174
src/lib/defaults.ts
Normal 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
54
src/lib/financial.ts
Normal 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
49
src/lib/formatters.ts
Normal 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
156
src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user