197 lines
5.3 KiB
TypeScript
197 lines
5.3 KiB
TypeScript
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 d’achat). */
|
||
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 d’achat 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));
|
||
}
|