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