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

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 

98 

99class AusEngineProvider(Protocol): 

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

101 

102 

103class DevAusEngine: 

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

105 

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 ) 

131 

132 

133class AgencyAusEngine: 

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

135 

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

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

138 

139 

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 

144 

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