Coverage for apps/underwriting/providers.py: 96%
82 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-05 19:46 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-05 19:46 +0000
1"""Pluggable credit reporting agency (CRA) adapter.
3A ``DevCreditProvider`` returns deterministic scores + sample tradelines for local/dev;
4the real MeridianLink/CRA adapter is a drop-in at certification (selected by setting),
5the same seam as the Cognito verifier.
6"""
8from __future__ import annotations
10from dataclasses import dataclass, field
11from decimal import Decimal
12from typing import Protocol
15@dataclass
16class ScoreResult:
17 borrower_id: int
18 equifax: int
19 experian: int
20 transunion: int
21 mid: int
24@dataclass
25class TradelineResult:
26 borrower_id: int
27 liability_type: str
28 creditor_name: str
29 monthly_payment_amt: Decimal
30 unpaid_balance_amt: Decimal
33@dataclass
34class CreditResult:
35 report_id: str
36 scores: list[ScoreResult] = field(default_factory=list)
37 tradelines: list[TradelineResult] = field(default_factory=list)
40class CreditProvider(Protocol):
41 def order(self, loan, borrowers, request_type: str) -> CreditResult: ...
44class DevCreditProvider:
45 """Deterministic dev stub — NOT a real credit pull."""
47 def order(self, loan, borrowers, request_type: str) -> CreditResult:
48 scores: list[ScoreResult] = []
49 tradelines: list[TradelineResult] = []
50 for b in borrowers:
51 base = 680 + (b.id % 60) # deterministic 680–739
52 eq, ex, tu = base, base + 5, base - 3
53 mid = sorted((eq, ex, tu))[1]
54 scores.append(ScoreResult(b.id, eq, ex, tu, mid))
55 tradelines.append(
56 TradelineResult(
57 b.id, "Revolving", "Dev Bank Card", Decimal("150.00"), Decimal("3200.00")
58 )
59 )
60 tradelines.append(
61 TradelineResult(
62 b.id, "Installment", "Dev Auto Loan", Decimal("420.00"), Decimal("18500.00")
63 )
64 )
65 return CreditResult(report_id=f"DEVCR-{loan.id}", scores=scores, tradelines=tradelines)
68class MeridianLinkProvider:
69 """Real CRA adapter — wired at certification (credentials via admin config)."""
71 def order(self, loan, borrowers, request_type: str) -> CreditResult:
72 raise NotImplementedError("MeridianLink not configured; set CREDIT_PROVIDER=dev for local.")
75def get_credit_provider(user=None, org=None) -> CreditProvider:
76 """Resolve the credit adapter for this user/org (user creds -> org creds -> env default);
77 fall back to the dev stub when nothing real is configured."""
78 from apps.integrations.resolver import resolve_credentials
80 cred = resolve_credentials("credit", user=user, org=org)
81 if cred is not None and cred.provider != "dev":
82 return MeridianLinkProvider()
83 return DevCreditProvider()
86# --- AUS engines ---------------------------------------------------------------
87@dataclass
88class AusRunResult:
89 engine: str
90 recommendation: str
91 risk_class: str
92 housing_pct: Decimal
93 dti_pct: Decimal
94 ltv_pct: Decimal
95 cltv_pct: Decimal
96 messages: list = field(default_factory=list)
97 findings_conditions: list[str] = field(default_factory=list)
98 verifications: list[dict] = field(default_factory=list)
101class AusEngineProvider(Protocol):
102 def run(self, engine: str, ratios: dict) -> AusRunResult: ...
105class DevAusEngine:
106 """Deterministic dev stub — NOT an agency decision."""
108 def run(self, engine: str, ratios: dict) -> AusRunResult:
109 dti = ratios["total_dti_pct"]
110 if dti <= Decimal("43"):
111 rec, risk = "Approve/Eligible", "Accept"
112 elif dti <= Decimal("50"):
113 rec, risk = "Refer/Eligible", "Refer"
114 else:
115 rec, risk = "Refer/Caution", "Caution"
116 label = "DU" if engine == "du" else "LPA"
118 if engine == "du":
119 conditions = [
120 "Provide most recent 30 consecutive days paystubs for all wage earners.",
121 "Provide IRS Form W-2 for preceding two calendar years.",
122 "Obtain appraisal report confirming property value meets Fannie Mae guidelines.",
123 "Verify standard hazard insurance policy naming lender as loss payee.",
124 ]
125 verifs = [
126 {"type": "Employment", "status": "Required"},
127 {"type": "Income", "status": "Required"},
128 {"type": "Assets", "status": "Recommended"},
129 ]
130 else:
131 conditions = [
132 "Provide recent paystub dated within 45 days of note.",
133 "Provide IRS Form W-2 for preceding one calendar year.",
134 "Verify two months asset statements for required closing funds.",
135 "Verify satisfactory final inspection report per Freddie Mac Form 442.",
136 ]
137 verifs = [
138 {"type": "Employment", "status": "Required"},
139 {"type": "Income", "status": "Required"},
140 {"type": "Assets", "status": "Required"},
141 ]
143 return AusRunResult(
144 engine=engine,
145 recommendation=f"{label}: {rec}",
146 risk_class=risk,
147 housing_pct=ratios["housing_dti_pct"],
148 dti_pct=dti,
149 ltv_pct=ratios["ltv_pct"],
150 cltv_pct=ratios["cltv_pct"],
151 messages=[
152 {
153 "code": "DEV-STUB",
154 "text": "Development stub — not an agency decision.",
155 "severity": "info",
156 }
157 ],
158 findings_conditions=conditions,
159 verifications=verifs,
160 )
163class AgencyAusEngine:
164 """Real Fannie DU / Freddie LPA — wired at certification (partner credentials)."""
166 def run(self, engine: str, ratios: dict) -> AusRunResult:
167 raise NotImplementedError("DU/LPA not configured; set AUS_ENGINE=dev for local.")
170def get_aus_engine(user=None, org=None) -> AusEngineProvider:
171 """Resolve the AUS engine for this user/org (user creds -> org creds -> env default);
172 fall back to the dev stub when nothing real is configured."""
173 from apps.integrations.resolver import resolve_credentials
175 cred = resolve_credentials("aus", user=user, org=org)
176 if cred is not None and cred.provider != "dev": 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 return AgencyAusEngine()
178 return DevAusEngine()