1992ee63be
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
8.6 KiB
Python
219 lines
8.6 KiB
Python
"""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)
|
||
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)
|
||
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
|