"""Cœur métier du module Financement Bancaire : apport initial + gate check. Fonctions PURES (aucune I/O, aucun horodatage) → testables et déterministes. Elles matérialisent l'exigence Michel (DIRECTIVE + amendement 2026-08-03) : AUCUN dossier n'est transmis à la banque tant que les 4 conditions ne sont pas remplies : 1. apport_initial_complet — dépôt initial (20 % résident RD · 30 % étranger) intégralement versé et vérifié ; 2. documents_exiges — tous les documents `is_required` de la banque choisie au statut « Validé WAG » ou « Envoyé banque » ; 3. autorisations_signees — toutes les autorisations signées (date non nulle) ; 4. validation_wag — un conseiller WAG a validé (`wag_validated_by`). Anti-invention (#6) : les taux d'apport (20/30) et la liste des documents/ autorisations ne sont JAMAIS codés en dur ici — ils proviennent du contrat `financement_spec.json` via `GateConfig`. Changer le contrat change le gate, sans toucher ce code. """ from __future__ import annotations from typing import Any # Ordre canonique des 4 clés de condition du gate (= ordre du contrat / bannière). CONDITION_KEYS = ( "apport_initial_complet", "documents_exiges", "autorisations_signees", "validation_wag", ) class GateConfig: """Vue en lecture du contrat, indexée pour le calcul du gate.""" def __init__(self, spec: dict) -> None: self.taux_apport_pct: dict[str, float] = { rt["code"]: float(rt["taux_apport_pct"]) for rt in spec["apport"]["residence_types"] } self.documents: list[dict] = list(spec["documents"]) self.autorisations: list[dict] = list(spec["autorisations"]) self.statuts_recevables: set[str] = set(spec["document_statuses_recevables"]) self.banques: dict[str, dict] = {b["id"]: b for b in spec["banques"]} @classmethod def from_spec(cls, spec: dict) -> "GateConfig": return cls(spec) def _round1(x: float) -> float: """Arrondi à 1 décimale, stable pour un artefact diffable.""" return round(x + 0.0, 1) def apport_requis_usd(prix_usd: float, residence_type: str, cfg: GateConfig) -> float: """Apport requis = prix × taux (20 % résident RD, 30 % étranger).""" if residence_type not in cfg.taux_apport_pct: raise KeyError( f"residence_type {residence_type!r} inconnu " f"(attendu : {sorted(cfg.taux_apport_pct)})." ) return round(float(prix_usd) * cfg.taux_apport_pct[residence_type] / 100.0, 2) def apport_verse_usd(dossier: dict) -> float: """Somme des versements enregistrés (0 si aucun).""" total = 0.0 for p in dossier.get("paiements", []): total += float(p.get("montant_verse_usd", 0) or 0) return round(total, 2) def required_document_ids(residence_type: str, cfg: GateConfig) -> list[str]: """Documents `is_required` applicables au type de résidence du client. Un document est exigé si `is_required` ET (`applies_to` == "all" OU `applies_to` == residence_type). Les documents « étrangers uniquement » ne s'appliquent donc pas à un résident RD. """ out: list[str] = [] for d in cfg.documents: if not d.get("is_required"): continue applies = d.get("applies_to", "all") if applies in ("all", residence_type): out.append(d["id"]) return out def _cond_apport(dossier: dict, cfg: GateConfig) -> dict: residence = dossier.get("residence_type") required = apport_requis_usd(dossier.get("prix_usd", 0), residence, cfg) paid = apport_verse_usd(dossier) remaining = round(max(0.0, required - paid), 2) ok = paid + 1e-9 >= required pct = _round1(min(100.0, (paid / required * 100.0) if required > 0 else 100.0)) # Un versement dans la bande [99.95 %, 100 %[ (p. ex. 19 999/20 000 USD) # remonterait à 100.0 par l'arrondi 1-décimale, alors que le gate BLOQUE # (ok=False, remaining>0). La barre de progression afficherait « 100 % ✓ » # tout en refusant la soumission, et la raison lirait « reste 1 USD · 100 %». # Invariant d'affichage : percent == 100.0 ⟺ ok — un dossier incomplet # plafonne à 99.9 (plus grande valeur 1-décimale strictement < 100). if not ok and pct >= 100.0: pct = 99.9 return { "ok": ok, "required": required, "paid": paid, "remaining": remaining, "percent": pct, } def _cond_documents(dossier: dict, cfg: GateConfig) -> dict: residence = dossier.get("residence_type") required_ids = required_document_ids(residence, cfg) by_id = {d["id"]: d for d in dossier.get("documents", [])} deposited = 0 validated = 0 for did in required_ids: statut = (by_id.get(did) or {}).get("statut", "Non déposé") if statut != "Non déposé": deposited += 1 if statut in cfg.statuts_recevables: validated += 1 total = len(required_ids) # Contrairement à _cond_apport (ratio USD *continu*, cf. fix 093114) et à # l'agrégat (moyenne de 4, cf. fix 110124), AUCUN plafond « non ok → 99.9 » # n'est requis ici : le dénominateur est la liste de documents du contrat # (≤ 13 is_required), un domaine BORNÉ. round1 ne peut remonter à # [99.95 %, 100 %[ par le bas qu'à partir de total ≥ 2000 (car # (total-1)/total ≥ 0.9995) — inatteignable pour une liste de documents. # Donc ok=False ⟹ percent < 100 tient déjà : l'invariant d'affichage # percent == 100.0 ⟺ ok est préservé sans garde. (même logique que les 3 # sites `:g`-domaine-borné · ne pas ajouter de plafond redondant · #5) pct = _round1((validated / total * 100.0) if total > 0 else 100.0) return { "ok": validated == total, "total": total, "deposited": deposited, "validated": validated, "percent": pct, } def _cond_autorisations(dossier: dict, cfg: GateConfig) -> dict: required_ids = [a["id"] for a in cfg.autorisations] by_id = {a["id"]: a for a in dossier.get("autorisations", [])} signed = 0 for aid in required_ids: if (by_id.get(aid) or {}).get("signature_date"): signed += 1 total = len(required_ids) # Domaine BORNÉ (4 autorisations au contrat), a fortiori inférieur au seuil # total ≥ 2000 requis pour que round1 remonte à [99.95 %, 100 %[ par le bas : # pas de plafond « non ok → 99.9 » nécessaire ici (cf. note _cond_documents). # L'invariant percent == 100.0 ⟺ ok tient sans garde. (ne pas ajouter · #5) pct = _round1((signed / total * 100.0) if total > 0 else 100.0) return {"ok": signed == total, "total": total, "signed": signed, "percent": pct} def _cond_validation_wag(dossier: dict) -> dict: validated_by = dossier.get("wag_validated_by") ok = bool(validated_by) return {"ok": ok, "validated_by": validated_by, "percent": 100.0 if ok else 0.0} def gate_status(dossier: dict, cfg: GateConfig) -> dict: """État complet du gate — forme consommée par le frontend (barres 0-100 %). Correspond au contrat de l'endpoint `GET /api/hypotheque/dossier/{id}/gate-status` (DIRECTIVE amendement). """ conditions = { "apport_initial_complet": _cond_apport(dossier, cfg), "documents_exiges": _cond_documents(dossier, cfg), "autorisations_signees": _cond_autorisations(dossier, cfg), "validation_wag": _cond_validation_wag(dossier), } can_submit = all(conditions[k]["ok"] for k in CONDITION_KEYS) completed = sum(1 for k in CONDITION_KEYS if conditions[k]["ok"]) overall = _round1( sum(conditions[k]["percent"] for k in CONDITION_KEYS) / len(CONDITION_KEYS) ) # Même invariant qu'au niveau condition (cf. _cond_apport) mais à l'AGRÉGAT : # la moyenne de 4 percents peut arrondir à 100.0 alors qu'une condition reste # bloquante — p. ex. apport plafonné à 99.9 (non ok) + les 3 autres à 100.0 → # (99.9 + 300) / 4 = 99.975 → round1 = 100.0. La barre GLOBALE afficherait # « 100 % » tout en refusant la soumission (403). On plafonne donc à 99.9. # Invariant d'affichage : overall_percent == 100.0 ⟺ can_submit. if not can_submit and overall >= 100.0: overall = 99.9 return { "can_submit": can_submit, "conditions_total": len(CONDITION_KEYS), "conditions_completed": completed, "overall_percent": overall, "conditions": conditions, } def can_submit_dossier(dossier: dict, cfg: GateConfig) -> tuple[bool, list[str]]: """(peut_soumettre, raisons_de_blocage) — vide si soumissible. `submit_to_bank()` doit renvoyer 403 + ces raisons tant que la liste n'est pas vide. Ordre des raisons = ordre canonique des conditions. """ status = gate_status(dossier, cfg) reasons: list[str] = [] c = status["conditions"] if not c["apport_initial_complet"]["ok"]: a = c["apport_initial_complet"] reasons.append( f"Apport initial incomplet : {a['paid']:.0f}/{a['required']:.0f} USD versés " f"(reste {a['remaining']:.0f} USD · {a['percent']}%)." ) if not c["documents_exiges"]["ok"]: d = c["documents_exiges"] reasons.append( f"Documents exigés incomplets : {d['validated']}/{d['total']} validés WAG." ) if not c["autorisations_signees"]["ok"]: s = c["autorisations_signees"] reasons.append( f"Autorisations non signées : {s['signed']}/{s['total']} signées." ) if not c["validation_wag"]["ok"]: reasons.append("Validation WAG manquante (aucun conseiller référent n'a validé).") return status["can_submit"], reasons