Initial commit
Generated by create-expo-app 3.5.3.
This commit is contained in:
259
src/core/juge/marginCalculator.ts
Normal file
259
src/core/juge/marginCalculator.ts
Normal file
@ -0,0 +1,259 @@
|
||||
import {
|
||||
DEFAULT_VAT_ON_MARGIN_RATE,
|
||||
DVF_DISCOUNT_FLASH_PCT,
|
||||
MIN_NET_MARGIN_PCT,
|
||||
} from './thresholds';
|
||||
|
||||
export type DealTrafficLight = 'red' | 'orange' | 'green' | 'green_flash_dvf';
|
||||
|
||||
export interface JugeInputs {
|
||||
purchasePrice: number;
|
||||
resalePrice: number;
|
||||
surfaceM2: number;
|
||||
/** Prix m² de référence marché (DVF / étude locale). */
|
||||
dvfReferencePriceM2?: number | null;
|
||||
worksTotal: number;
|
||||
notaryFeeRate: number;
|
||||
saleAgencyFeeRate: number;
|
||||
miscAcquisitionCost: number;
|
||||
miscSaleCost: number;
|
||||
carryingMonths: number;
|
||||
carryingAnnualRate: number;
|
||||
carryingPrincipal?: number | null;
|
||||
/**
|
||||
* TVA sur marge : approximation prudentielle pour outil terrain.
|
||||
* Base imposable = marge économique avant TVA (cash hors TVA collectée/déductible).
|
||||
* Ajustez avec votre expert-comptable selon votre assiette réelle.
|
||||
*/
|
||||
vatOnMarginRate?: number;
|
||||
}
|
||||
|
||||
export interface JugeResult {
|
||||
totalInvested: number;
|
||||
netResaleProceeds: number;
|
||||
grossMarginBeforeVat: number;
|
||||
vatOnMargin: number;
|
||||
netMarginAfterVat: number;
|
||||
netMarginPct: number;
|
||||
breakEvenResalePrice: number;
|
||||
purchasePricePerM2: number;
|
||||
dvfDiscountPct: number | null;
|
||||
trafficLight: DealTrafficLight;
|
||||
/** 0–100 : marge nette normalisée vs seuil + bonus sous-cotation DVF. */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le Juge : synthèse financière go / no-go pour marchand de biens.
|
||||
*/
|
||||
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;
|
||||
if (
|
||||
input.dvfReferencePriceM2 != null &&
|
||||
input.dvfReferencePriceM2 > 0 &&
|
||||
input.surfaceM2 > 0
|
||||
) {
|
||||
dvfDiscountPct =
|
||||
(input.dvfReferencePriceM2 - purchasePricePerM2) /
|
||||
input.dvfReferencePriceM2;
|
||||
}
|
||||
|
||||
const trafficLight = resolveTrafficLight(
|
||||
netMarginPct,
|
||||
dvfDiscountPct,
|
||||
purchasePricePerM2,
|
||||
input.dvfReferencePriceM2,
|
||||
);
|
||||
|
||||
const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct);
|
||||
|
||||
return {
|
||||
totalInvested,
|
||||
netResaleProceeds,
|
||||
grossMarginBeforeVat,
|
||||
vatOnMargin,
|
||||
netMarginAfterVat,
|
||||
netMarginPct,
|
||||
breakEvenResalePrice: computeBreakEvenResale(input, vatRate),
|
||||
purchasePricePerM2,
|
||||
dvfDiscountPct,
|
||||
trafficLight,
|
||||
scoreDeal,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTrafficLight(
|
||||
netMarginPct: number,
|
||||
dvfDiscountPct: number | null,
|
||||
purchasePricePerM2: number,
|
||||
dvfReferencePriceM2?: number | null,
|
||||
): DealTrafficLight {
|
||||
const underDvfFlash =
|
||||
dvfReferencePriceM2 != null &&
|
||||
dvfReferencePriceM2 > 0 &&
|
||||
purchasePricePerM2 <= dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
|
||||
|
||||
if (netMarginPct < MIN_NET_MARGIN_PCT) {
|
||||
return underDvfFlash ? 'green_flash_dvf' : 'red';
|
||||
}
|
||||
if (underDvfFlash) {
|
||||
return 'green_flash_dvf';
|
||||
}
|
||||
if (dvfDiscountPct != null && dvfDiscountPct > 0.1) {
|
||||
return 'green';
|
||||
}
|
||||
return 'orange';
|
||||
}
|
||||
|
||||
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, vatRate: number): 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 effectiveCoeff = 1 - agencyRate - vatRate * (1 - agencyRate);
|
||||
if (effectiveCoeff <= 0) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return fixedCosts / effectiveCoeff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prix d'achat maximum pour respecter une marge nette cible (linéaire sur coûts).
|
||||
* Utile quand la checklist visite augmente les travaux : recalcul offre max.
|
||||
*/
|
||||
export function maxPurchaseForTargetNetMarginPct(
|
||||
input: Omit<JugeInputs, 'purchasePrice'>,
|
||||
targetNetMarginPct: number,
|
||||
): number {
|
||||
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
|
||||
const carryingPrincipalFallback = 0;
|
||||
|
||||
const agency = input.saleAgencyFeeRate;
|
||||
const netResaleCoeff = 1 - agency;
|
||||
|
||||
const numerator =
|
||||
netResaleCoeff * input.resalePrice -
|
||||
input.miscSaleCost -
|
||||
input.worksTotal -
|
||||
input.miscAcquisitionCost -
|
||||
(1 + targetNetMarginPct + vatRate * (1 + targetNetMarginPct)) *
|
||||
carryingPrincipalFallback;
|
||||
|
||||
const denominator =
|
||||
(1 + input.notaryFeeRate) *
|
||||
(1 +
|
||||
targetNetMarginPct +
|
||||
vatRate * (1 + targetNetMarginPct) +
|
||||
input.carryingAnnualRate * (input.carryingMonths / 12));
|
||||
|
||||
if (denominator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxPurchase = numerator / denominator;
|
||||
|
||||
const refine = (candidate: number): number => {
|
||||
const trial: JugeInputs = {
|
||||
...input,
|
||||
purchasePrice: candidate,
|
||||
carryingPrincipal: input.carryingPrincipal ?? candidate,
|
||||
};
|
||||
const { netMarginPct } = evaluateDeal(trial);
|
||||
return netMarginPct - targetNetMarginPct;
|
||||
};
|
||||
|
||||
maxPurchase = binarySearchPurchase(input, targetNetMarginPct, vatRate);
|
||||
|
||||
void refine;
|
||||
return Math.max(0, Math.round(maxPurchase));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dichotomique robuste (le lien marge ↔ prix d'achat est affine par morceaux
|
||||
* mais les arrondis / TVA max(0,·) peuvent créer des irrégularités légères).
|
||||
*/
|
||||
export function maxPurchaseForTargetNetMarginPctRobust(
|
||||
input: Omit<JugeInputs, 'purchasePrice'>,
|
||||
targetNetMarginPct: number,
|
||||
): number {
|
||||
return binarySearchPurchase(input, targetNetMarginPct);
|
||||
}
|
||||
|
||||
function binarySearchPurchase(
|
||||
input: Omit<JugeInputs, 'purchasePrice'>,
|
||||
targetNetMarginPct: number,
|
||||
vatRate: number = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = input.resalePrice * 1.5;
|
||||
for (let i = 0; i < 48; 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));
|
||||
}
|
||||
Reference in New Issue
Block a user