Files
mdb/src/core/juge/marginCalculator.ts
Bastien COIGNOUX bd325fe456 init
2026-05-03 20:18:33 +02:00

197 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { DealTrafficLight } from '../../domain/dealSignals';
import {
DEFAULT_VAT_ON_MARGIN_RATE,
DVF_DISCOUNT_FLASH_PCT,
MIN_NET_MARGIN_PCT,
} from './thresholds';
export type { DealTrafficLight };
export interface JugeInputs {
purchasePrice: number;
resalePrice: number;
surfaceM2: number;
dvfReferencePriceM2?: number | null;
worksTotal: number;
notaryFeeRate: number;
saleAgencyFeeRate: number;
miscAcquisitionCost: number;
miscSaleCost: number;
carryingMonths: number;
carryingAnnualRate: number;
carryingPrincipal?: number | null;
vatOnMarginRate?: number;
}
export interface JugeResult {
totalInvested: number;
netResaleProceeds: number;
grossMarginBeforeVat: number;
vatOnMargin: number;
netMarginAfterVat: number;
netMarginPct: number;
breakEvenResalePrice: number;
purchasePricePerM2: number;
dvfDiscountPct: number | null;
/** Sous-cotation vs DVF (prix / m² ≤ référence × (1 20 %)). */
dvfUnderMarketFlash: boolean;
trafficLight: DealTrafficLight;
scoreDeal: number;
}
function carryingCostEUR(input: JugeInputs): number {
const principal =
input.carryingPrincipal != null && input.carryingPrincipal > 0
? input.carryingPrincipal
: input.purchasePrice;
return principal * input.carryingAnnualRate * (input.carryingMonths / 12);
}
export function evaluateDeal(input: JugeInputs): JugeResult {
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
const notaryFees = input.purchasePrice * input.notaryFeeRate;
const carrying = carryingCostEUR(input);
const totalInvested =
input.purchasePrice +
notaryFees +
input.worksTotal +
input.miscAcquisitionCost +
carrying;
const agencyFees = input.resalePrice * input.saleAgencyFeeRate;
const netResaleProceeds =
input.resalePrice - agencyFees - input.miscSaleCost;
const grossMarginBeforeVat = netResaleProceeds - totalInvested;
const vatOnMargin = Math.max(0, grossMarginBeforeVat) * vatRate;
const netMarginAfterVat = grossMarginBeforeVat - vatOnMargin;
const netMarginPct =
totalInvested > 0 ? netMarginAfterVat / totalInvested : 0;
const purchasePricePerM2 =
input.surfaceM2 > 0 ? input.purchasePrice / input.surfaceM2 : 0;
let dvfDiscountPct: number | null = null;
let dvfUnderMarketFlash = false;
if (
input.dvfReferencePriceM2 != null &&
input.dvfReferencePriceM2 > 0 &&
input.surfaceM2 > 0
) {
dvfDiscountPct =
(input.dvfReferencePriceM2 - purchasePricePerM2) /
input.dvfReferencePriceM2;
dvfUnderMarketFlash =
purchasePricePerM2 <=
input.dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
}
const trafficLight = resolveTrafficLight(
netMarginPct,
dvfUnderMarketFlash,
dvfDiscountPct,
);
const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct);
return {
totalInvested,
netResaleProceeds,
grossMarginBeforeVat,
vatOnMargin,
netMarginAfterVat,
netMarginPct,
breakEvenResalePrice: computeBreakEvenResale(input),
purchasePricePerM2,
dvfDiscountPct,
dvfUnderMarketFlash,
trafficLight,
scoreDeal,
};
}
/** Marge : feu rouge sous 15 %. DVF : flash vert indépendant (signal dachat). */
function resolveTrafficLight(
netMarginPct: number,
dvfUnderMarketFlash: boolean,
dvfDiscountPct: number | null,
): DealTrafficLight {
if (netMarginPct < MIN_NET_MARGIN_PCT) {
return 'red';
}
if (dvfUnderMarketFlash) {
return 'green_flash_dvf';
}
if (dvfDiscountPct != null && dvfDiscountPct > 0.1) {
return 'green';
}
if (netMarginPct < 0.18) {
return 'orange';
}
return 'green';
}
function computeScoreDeal(
netMarginPct: number,
dvfDiscountPct: number | null,
): number {
const marginScore = Math.min(
100,
Math.max(0, (netMarginPct / MIN_NET_MARGIN_PCT) * 60),
);
const dvfBonus =
dvfDiscountPct != null && dvfDiscountPct > 0
? Math.min(40, dvfDiscountPct * 100)
: 0;
return Math.round(Math.min(100, marginScore + dvfBonus));
}
function computeBreakEvenResale(input: JugeInputs): number {
const notaryFees = input.purchasePrice * input.notaryFeeRate;
const carrying = carryingCostEUR(input);
const fixedCosts =
input.purchasePrice +
notaryFees +
input.worksTotal +
input.miscAcquisitionCost +
carrying +
input.miscSaleCost;
const agencyRate = input.saleAgencyFeeRate;
const coeff = 1 - agencyRate;
if (coeff <= 0) {
return Number.POSITIVE_INFINITY;
}
return fixedCosts / coeff;
}
/**
* Prix dachat max pour tenir une marge nette cible (ex. 15 % après TVA sur marge).
* Recalcul instantané quand la checklist visite augmente les travaux.
*/
export function maxPurchaseForTargetNetMarginPct(
input: Omit<JugeInputs, 'purchasePrice'>,
targetNetMarginPct: number,
): number {
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
let low = 0;
let high = Math.max(input.resalePrice * 1.5, 1);
for (let i = 0; i < 56; i++) {
const mid = (low + high) / 2;
const { netMarginPct } = evaluateDeal({
...input,
purchasePrice: mid,
carryingPrincipal: input.carryingPrincipal ?? mid,
vatOnMarginRate: vatRate,
});
if (netMarginPct >= targetNetMarginPct) {
low = mid;
} else {
high = mid;
}
}
return Math.max(0, Math.round(low));
}