Initial commit

Generated by create-expo-app 3.5.3.
This commit is contained in:
Bastien COIGNOUX
2026-04-29 19:48:53 +02:00
commit ffc2e6b895
16 changed files with 9467 additions and 0 deletions

View 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;
/** 0100 : 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));
}