Coverage for apps/underwriting/providers.py: 88%
75 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +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)
99class AusEngineProvider(Protocol):
100 def run(self, engine: str, ratios: dict) -> AusRunResult: ...
103class DevAusEngine:
104 """Deterministic dev stub — NOT an agency decision."""
106 def run(self, engine: str, ratios: dict) -> AusRunResult:
107 dti = ratios["total_dti_pct"]
108 if dti <= Decimal("43"): 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was always true
109 rec, risk = "Approve/Eligible", "Accept"
110 elif dti <= Decimal("50"):
111 rec, risk = "Refer/Eligible", "Refer"
112 else:
113 rec, risk = "Refer/Caution", "Caution"
114 label = "DU" if engine == "du" else "LPA"
115 return AusRunResult(
116 engine=engine,
117 recommendation=f"{label}: {rec}",
118 risk_class=risk,
119 housing_pct=ratios["housing_dti_pct"],
120 dti_pct=dti,
121 ltv_pct=ratios["ltv_pct"],
122 cltv_pct=ratios["cltv_pct"],
123 messages=[
124 {
125 "code": "DEV-STUB",
126 "text": "Development stub — not an agency decision.",
127 "severity": "info",
128 }
129 ],
130 )
133class AgencyAusEngine:
134 """Real Fannie DU / Freddie LPA — wired at certification (partner credentials)."""
136 def run(self, engine: str, ratios: dict) -> AusRunResult:
137 raise NotImplementedError("DU/LPA not configured; set AUS_ENGINE=dev for local.")
140def get_aus_engine(user=None, org=None) -> AusEngineProvider:
141 """Resolve the AUS engine for this user/org (user creds -> org creds -> env default);
142 fall back to the dev stub when nothing real is configured."""
143 from apps.integrations.resolver import resolve_credentials
145 cred = resolve_credentials("aus", user=user, org=org)
146 if cred is not None and cred.provider != "dev": 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 return AgencyAusEngine()
148 return DevAusEngine()