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

1"""Pluggable credit reporting agency (CRA) adapter. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass, field 

11from decimal import Decimal 

12from typing import Protocol 

13 

14 

15@dataclass 

16class ScoreResult: 

17 borrower_id: int 

18 equifax: int 

19 experian: int 

20 transunion: int 

21 mid: int 

22 

23 

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 

31 

32 

33@dataclass 

34class CreditResult: 

35 report_id: str 

36 scores: list[ScoreResult] = field(default_factory=list) 

37 tradelines: list[TradelineResult] = field(default_factory=list) 

38 

39 

40class CreditProvider(Protocol): 

41 def order(self, loan, borrowers, request_type: str) -> CreditResult: ... 

42 

43 

44class DevCreditProvider: 

45 """Deterministic dev stub — NOT a real credit pull.""" 

46 

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) 

66 

67 

68class MeridianLinkProvider: 

69 """Real CRA adapter — wired at certification (credentials via admin config).""" 

70 

71 def order(self, loan, borrowers, request_type: str) -> CreditResult: 

72 raise NotImplementedError("MeridianLink not configured; set CREDIT_PROVIDER=dev for local.") 

73 

74 

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 

79 

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() 

84 

85 

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) 

99 

100 

101class AusEngineProvider(Protocol): 

102 def run(self, engine: str, ratios: dict) -> AusRunResult: ... 

103 

104 

105class DevAusEngine: 

106 """Deterministic dev stub — NOT an agency decision.""" 

107 

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" 

117 

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 ] 

142 

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 ) 

161 

162 

163class AgencyAusEngine: 

164 """Real Fannie DU / Freddie LPA — wired at certification (partner credentials).""" 

165 

166 def run(self, engine: str, ratios: dict) -> AusRunResult: 

167 raise NotImplementedError("DU/LPA not configured; set AUS_ENGINE=dev for local.") 

168 

169 

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 

174 

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()