Coverage for domain/state_compliance.py: 78%

52 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +0000

1"""State mortgage licensing compliance (SAFE Act baseline) for MLOs and LPs. 

2 

3Ported from arive-spec/src/domain/stateCompliance.ts with snake_case dict output 

4matching admin.types.ts / ComplianceCheck contract. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import Any 

10 

11from domain.result import AppError, Result, err, ok 

12 

13IC_PROCESSOR_LICENSE: set[str] = {"AK", "FL", "MT", "NV", "WA"} 

14 

15STATE_ED: dict[str, int] = { 

16 "AK": 0, "AL": 0, "AR": 0, "AZ": 0, "CA": 0, "CO": 2, "CT": 0, "DC": 0, 

17 "DE": 0, "FL": 0, "GA": 0, "HI": 0, "IA": 0, "ID": 2, "IL": 0, "IN": 0, 

18 "KS": 0, "KY": 0, "LA": 0, "MA": 0, "MD": 0, "ME": 0, "MI": 0, "MN": 0, 

19 "MO": 0, "MS": 0, "MT": 0, "NC": 4, "ND": 0, "NE": 0, "NH": 0, "NJ": 0, 

20 "NM": 0, "NV": 2, "NY": 0, "OH": 0, "OK": 0, "OR": 0, "PA": 0, "RI": 0, 

21 "SC": 0, "SD": 0, "TN": 0, "TX": 3, "UT": 2, "VA": 0, "VT": 0, "WA": 4, 

22 "WI": 0, "WV": 4, "WY": 0, 

23} 

24 

25STATE_CE: dict[str, int] = { 

26 "CO": 2, "ID": 2, "MT": 1, "NV": 3, "NC": 4, "TX": 3, "UT": 2, "WA": 1, "WV": 4, 

27} 

28 

29BOND_MIN: dict[str, int] = { 

30 "CA": 50000, "FL": 50000, "TX": 50000, "NY": 50000, "NV": 75000, "WA": 20000, 

31 "GA": 50000, "CO": 25000, "AZ": 25000, "IL": 25000, "NC": 75000, "VA": 25000, 

32 "MD": 50000, "NJ": 150000, "PA": 100000, 

33} 

34 

35REGULATOR: dict[str, str] = { 

36 "FL": "FL Office of Financial Regulation (OFR)", 

37 "TX": "TX Dept. of Savings & Mortgage Lending (SML)", 

38 "CA": "CA Dept. of Financial Protection & Innovation (DFPI)", 

39 "NY": "NY Dept. of Financial Services (DFS)", 

40 "GA": "GA Dept. of Banking & Finance", 

41 "CO": "CO Division of Real Estate", 

42 "NV": "NV Mortgage Lending Division", 

43 "WA": "WA Dept. of Financial Institutions (DFI)", 

44 "IL": "IL Dept. of Financial & Professional Regulation (IDFPR)", 

45 "NC": "NC Commissioner of Banks", 

46 "VA": "VA Bureau of Financial Institutions", 

47 "AZ": "AZ Dept. of Insurance & Financial Institutions", 

48 "NJ": "NJ Dept. of Banking & Insurance", 

49 "PA": "PA Dept. of Banking & Securities", 

50 "MD": "MD Office of the Commissioner of Financial Regulation", 

51} 

52 

53STATE_DISCLOSURES: dict[str, list[str]] = { 

54 "CA": ["CA Mortgage Loan Disclosure Statement (RE Form 882/883)", "Per-diem interest disclosure"], 

55 "NY": ["NY Pre-Application Disclosure & Fee Agreement", "Subprime/high-cost (Banking Law §6-l/6-m) checks"], 

56 "TX": ["TX Mortgage Company Disclosure (7 TAC §80.200)", "Consumer complaint/recovery-fund notice"], 

57 "FL": ["FL Mortgage Brokerage Agreement disclosures"], 

58 "IL": ["IL High Risk Home Loan Act disclosures"], 

59 "MA": ["MA Right to Counsel / predatory-lending disclosures"], 

60 "NC": ["NC rate-lock agreement disclosure"], 

61} 

62 

63STATE_NAMES: dict[str, str] = { 

64 "AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", "CA": "California", 

65 "CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", "DC": "District of Columbia", 

66 "FL": "Florida", "GA": "Georgia", "HI": "Hawaii", "ID": "Idaho", "IL": "Illinois", 

67 "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", "KY": "Kentucky", "LA": "Louisiana", 

68 "ME": "Maine", "MD": "Maryland", "MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", 

69 "MS": "Mississippi", "MO": "Missouri", "MT": "Montana", "NE": "Nebraska", "NV": "Nevada", 

70 "NH": "New Hampshire", "NJ": "New Jersey", "NM": "New Mexico", "NY": "New York", 

71 "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma", 

72 "OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina", 

73 "SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah", "VT": "Vermont", 

74 "VA": "Virginia", "WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin", 

75 "WY": "Wyoming", 

76} 

77 

78LICENSE_NAMES: dict[str, dict[str, str]] = { 

79 "TX": {"company": "TX-SML Mortgage Company License", "mlo": "TX-SML Residential MLO License"}, 

80 "NY": {"company": "NY Mortgage Banker/Broker License", "mlo": "NY MLO License"}, 

81 "CA": {"company": "CA-DFPI Financing Law / CRMLA License", "mlo": "CA-DFPI MLO License"}, 

82 "FL": {"company": "FL Mortgage Lender/Broker License", "mlo": "FL Loan Originator License"}, 

83} 

84 

85 

86def _build_state_rules() -> list[dict[str, Any]]: 

87 rules = [] 

88 for code in sorted(STATE_NAMES.keys()): 

89 name = STATE_NAMES[code] 

90 custom_lic = LICENSE_NAMES.get(code, {}) 

91 has_ic = code in IC_PROCESSOR_LICENSE 

92 rule = { 

93 "state_code": code, 

94 "state_name": name, 

95 "regulator": REGULATOR.get(code, f"{name} financial-institutions regulator"), 

96 "company_license": custom_lic.get("company", f"{code} Mortgage Broker/Lender License"), 

97 "mlo_license": custom_lic.get("mlo", f"{code} Mortgage Loan Originator License"), 

98 "mlo_state_education_hours": STATE_ED.get(code, 0), 

99 "ce_hours_annual": 8 + STATE_CE.get(code, 0), 

100 "surety_bond_min": BOND_MIN.get(code, 25000), 

101 "mcr_frequency": "Quarterly", 

102 "renewal_window": "Nov 1 – Dec 31 (NMLS annual renewal)", 

103 "independent_processor_licensed": has_ic, 

104 "w2_processor_exempt": True, 

105 "state_disclosures": STATE_DISCLOSURES.get(code, []), 

106 "notes": ( 

107 "Independent-contractor processors require state licensing; W-2 clerical processors exempt." 

108 if has_ic else None 

109 ), 

110 } 

111 rules.append(rule) 

112 return rules 

113 

114 

115STATE_COMPLIANCE: list[dict[str, Any]] = _build_state_rules() 

116_BY_STATE: dict[str, dict[str, Any]] = {r["state_code"]: r for r in STATE_COMPLIANCE} 

117 

118 

119def get_state_rule(state_code: str) -> dict[str, Any] | None: 

120 if not state_code: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 return None 

122 return _BY_STATE.get(state_code.strip().upper()) 

123 

124 

125def validate_state_compliance( 

126 state_code: str, 

127 lo: dict[str, Any] | None = None, 

128 processor: dict[str, Any] | None = None, 

129) -> Result: 

130 """Validate an LO's (and optionally a processor's) licensing for a property state.""" 

131 rule = get_state_rule(state_code) 

132 if not rule: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true

133 return err(AppError("UNKNOWN_STATE", f'No compliance profile for state "{state_code}".', "state_code")) 

134 

135 lo_issues: list[dict[str, str]] = [] 

136 if not lo: 

137 lo_issues.append({"severity": "fatal", "code": "LO_MISSING", "message": "No loan officer assigned."}) 

138 else: 

139 licenses = lo.get("licenses", []) 

140 lic = next( 

141 (row for row in licenses if row.get("state_code", "").upper() == rule["state_code"]), None 

142 ) 

143 if not lic: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true

144 lo_issues.append({ 

145 "severity": "fatal", 

146 "code": "LO_UNLICENSED", 

147 "message": f"{lo.get('name', 'Loan Officer')} is not licensed in {rule['state_name']} ({rule['mlo_license']}).", 

148 }) 

149 else: 

150 if not lic.get("active", False): 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true

151 lo_issues.append({ 

152 "severity": "fatal", 

153 "code": "LO_INACTIVE", 

154 "message": f"{lo.get('name', 'Loan Officer')}'s {rule['state_code']} license is inactive.", 

155 }) 

156 if not lic.get("sponsored", False): 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true

157 lo_issues.append({ 

158 "severity": "fatal", 

159 "code": "LO_UNSPONSORED", 

160 "message": f"{lo.get('name', 'Loan Officer')} holds a {rule['state_code']} license but is not sponsored by the company there.", 

161 }) 

162 

163 proc_issues: list[dict[str, str]] = [] 

164 proc_type = processor.get("type") if processor else None 

165 if processor and proc_type: 

166 if proc_type == "IndependentContractor" and rule["independent_processor_licensed"]: 166 ↛ 174line 166 didn't jump to line 174 because the condition on line 166 was always true

167 licensed_states = [s.upper() for s in processor.get("licensed_states", [])] 

168 if rule["state_code"] not in licensed_states: 168 ↛ 181line 168 didn't jump to line 181 because the condition on line 168 was always true

169 proc_issues.append({ 

170 "severity": "fatal", 

171 "code": "LP_UNLICENSED", 

172 "message": f"{rule['state_name']} licenses independent-contractor processors; this processor is not licensed here.", 

173 }) 

174 elif proc_type == "IndependentContractor" and not rule["independent_processor_licensed"]: 

175 proc_issues.append({ 

176 "severity": "warning", 

177 "code": "LP_REVIEW", 

178 "message": f"{rule['state_name']} generally does not license processors, but confirm the processor does not take applications or negotiate terms.", 

179 }) 

180 

181 compliant = all(i["severity"] != "fatal" for i in lo_issues) and all(i["severity"] != "fatal" for i in proc_issues) 

182 return ok({ 

183 "state_code": rule["state_code"], 

184 "state_name": rule["state_name"], 

185 "compliant": compliant, 

186 "loan_officer": { 

187 "id": lo.get("id") if lo else None, 

188 "name": lo.get("name") if lo else None, 

189 "issues": lo_issues, 

190 }, 

191 "processor": { 

192 "type": proc_type, 

193 "issues": proc_issues, 

194 }, 

195 "rule": rule, 

196 })