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
« 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.
3Ported from arive-spec/src/domain/stateCompliance.ts with snake_case dict output
4matching admin.types.ts / ComplianceCheck contract.
5"""
7from __future__ import annotations
9from typing import Any
11from domain.result import AppError, Result, err, ok
13IC_PROCESSOR_LICENSE: set[str] = {"AK", "FL", "MT", "NV", "WA"}
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}
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}
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}
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}
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}
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}
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}
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
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}
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())
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"))
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 })
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 })
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 })