Coverage for domain/pos.py: 90%

280 statements  

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

1"""Borrower portal (Borrower POS) rules — pure, no Django (add-borrower-portal-api design.md D5–D7). 

2 

3Mirrors the borrower portal's own validators (``borrower-pos/src/services/{me,pos,auth}/ 

4validation.ts``) so the server rejects exactly what the screens would. Each validator returns 

5``{field: message}``; an empty dict means valid. ``first_error`` turns that into the shared 

6``VALIDATION`` error. 

7""" 

8 

9from __future__ import annotations 

10 

11import re 

12from datetime import date 

13from typing import Any, cast 

14 

15from domain.result import AppError 

16 

17MAX_TEXT = 100 

18 

19_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$") 

20_SSN_RE = re.compile(r"^(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}$") 

21_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") 

22 

23EMAIL_MSG = "Enter a valid email, e.g. name@example.com." 

24PHONE_MSG = "Enter a 10-digit US phone number." 

25PASSWORD_MSG = "Use at least 8 characters with a letter and a number." 

26TOO_LONG_MSG = f"Keep this under {MAX_TEXT} characters." 

27AMOUNT_MSG = "Enter an amount greater than $0." 

28MAX_AMOUNT = 999_999_999.99 

29MAX_AMOUNT_MSG = "Amount cannot exceed $999,999,999.99." 

30 

31US_STATES = frozenset( 

32 """AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH 

33 NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY""".split() 

34) 

35PURPOSES = ("Purchase", "Refinance") 

36OCCUPANCIES = ("PrimaryResidence", "SecondHome", "Investment") 

37REO_DISPOSITIONS = ("Retain", "Sell", "PendingSale", "RentalProperty") 

38 

39# The twelve sections PATCH /api/pos/step accepts. 

40SECTIONS = ( 

41 "mortgage", "personal", "coborrower", "employment", "income", "assets", "liabilities", 

42 "reo", "declarations", "additional", "demographics", "credit", 

43) 

44# The ten tracked steps, in Application order. 

45STEP_KEYS = ( 

46 "mortgage", "personal", "employment", "income", "assets", "liabilities", "reo", 

47 "declarations", "demographics", "credit", 

48) 

49 

50# Declarations tab (Arive ``DQ``) and More questions tab (Arive ``AQ``). 

51DECLARATION_KEYS = ("A", "B", "E", "I", "N", "O") 

52MORE_QUESTION_KEYS = ("K", "J", "G") 

53 

54# Arive's declaration letters -> the 1003 ``Declaration`` field each answer is written to 

55# (design.md D6). "A" (primary home) is a Yes/No string field; the rest are booleans. 

56DECLARATION_FIELDS = { 

57 "A": "intent_to_occupy_type", 

58 "B": "owned_property_past_three_years_ind", 

59 "E": "undisclosed_mortgage_app_ind", 

60 "I": "outstanding_judgments_ind", 

61 "N": "prior_foreclosure_ind", 

62 "O": "bankruptcy_ind", 

63 "K": "comaker_endorser_ind", 

64 "J": "delinquent_federal_debt_ind", 

65 "G": "clean_energy_lien_ind", 

66} 

67 

68ETHNICITIES = ("Hispanic", "NotHispanic", "NoProvide") 

69SEXES = ("Male", "Female", "NoProvide") 

70RACES = ( 

71 "AmericanIndianOrAlaskaNative", "Asian", "BlackOrAfricanAmerican", 

72 "NativeHawaiianOrOtherPacificIslander", "White", "NoProvide", 

73) 

74 

75 

76# --- small helpers -------------------------------------------------------------- 

77def digits(value: str) -> str: 

78 return "".join(ch for ch in value if ch.isdigit()) 

79 

80 

81def blank(value: Any) -> bool: 

82 return value is None or (isinstance(value, str) and value.strip() == "") 

83 

84 

85def is_email(value: str) -> bool: 

86 return bool(_EMAIL_RE.match(value.strip())) 

87 

88 

89def is_us_phone(value: str) -> bool: 

90 return len(digits(value)) == 10 

91 

92 

93def is_strong_password(value: str) -> bool: 

94 return ( 

95 isinstance(value, str) 

96 and len(value) >= 8 

97 and any(ch.isalpha() for ch in value) 

98 and any(ch.isdigit() for ch in value) 

99 ) 

100 

101 

102def _is_number(value: Any) -> bool: 

103 return isinstance(value, int | float) and not isinstance(value, bool) 

104 

105 

106def _is_positive(value: Any) -> bool: 

107 return _is_number(value) and value > 0 

108 

109 

110def age_on(birth: date, today: date) -> int: 

111 age = today.year - birth.year 

112 if (today.month, today.day) < (birth.month, birth.day): 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true

113 age -= 1 

114 return age 

115 

116 

117def first_error(errors: dict[str, str]) -> AppError | None: 

118 """The first problem as the shared VALIDATION error (the screens show one per field; the 

119 API reports the first, as the portal's mock server does).""" 

120 for field, message in errors.items(): 

121 return AppError("VALIDATION", message, field) 

122 return None 

123 

124 

125def _require_text(e: dict, field: str, value: Any, message: str) -> None: 

126 if blank(value): 

127 e[field] = message 

128 elif not isinstance(value, str): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 e[field] = message 

130 elif len(value.strip()) > MAX_TEXT: 

131 e[field] = TOO_LONG_MSG 

132 

133 

134def _require_amount(e: dict, field: str, value: Any, message: str) -> None: 

135 if value is None: 

136 e[field] = message 

137 elif not _is_positive(value): 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 e[field] = AMOUNT_MSG 

139 elif isinstance(value, (int, float)) and value > MAX_AMOUNT: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 e[field] = MAX_AMOUNT_MSG 

141 

142 

143def _optional_amount(e: dict, field: str, value: Any) -> None: 

144 if value is not None: 

145 if not _is_positive(value): 

146 e[field] = AMOUNT_MSG 

147 elif isinstance(value, (int, float)) and value > MAX_AMOUNT: 

148 e[field] = MAX_AMOUNT_MSG 

149 

150 

151# --- account rules (borrower-auth) --------------------------------------------- 

152def validate_register(name: str, email: str, password: str) -> dict[str, str]: 

153 e: dict[str, str] = {} 

154 _require_text(e, "name", name, "Enter your full name.") 

155 if blank(email) or not is_email(email): 

156 e["email"] = EMAIL_MSG 

157 if not is_strong_password(password): 

158 e["password"] = PASSWORD_MSG 

159 return e 

160 

161 

162def validate_profile(name: str, email: str, phone: str) -> dict[str, str]: 

163 e: dict[str, str] = {} 

164 _require_text(e, "name", name, "Enter your full name.") 

165 if blank(email) or not is_email(email): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 e["email"] = EMAIL_MSG 

167 if not blank(phone) and not is_us_phone(phone): 

168 e["phone"] = PHONE_MSG 

169 return e 

170 

171 

172def validate_new_password(current: str, new: str) -> dict[str, str]: 

173 """The new password's rules (the current one is checked against the stored hash by the 

174 caller first).""" 

175 if not is_strong_password(new): 

176 return {"next": PASSWORD_MSG} 

177 if new == current: 

178 return {"next": "Choose a password different from your current one."} 

179 return {} 

180 

181 

182# --- application rules (borrower-application) ----------------------------------- 

183def validate_start(data: dict) -> dict[str, str]: 

184 e: dict[str, str] = {} 

185 if data.get("purpose") not in PURPOSES: 

186 e["purpose"] = "Choose a purpose." 

187 if data.get("occupancy") not in OCCUPANCIES: 

188 e["occupancy"] = "Choose an occupancy." 

189 code = str(data.get("state_code") or "").strip().upper() 

190 if not code: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 e["state_code"] = "Choose a state." 

192 elif code not in US_STATES: 

193 e["state_code"] = "Choose a US state." 

194 return e 

195 

196 

197def _validate_answers(answers: Any, keys: tuple[str, ...], field: str) -> dict[str, str]: 

198 if not isinstance(answers, dict) or any(not isinstance(answers.get(k), bool) for k in keys): 

199 return {field: "Answer every question."} 

200 return {} 

201 

202 

203def validate_step(section: str, data: Any, current: dict, today: date) -> dict[str, str]: 

204 """One Application section (borrower-pos ``validateStep``). 

205 

206 ``current`` is ``{"transaction": {...}, "borrower": {"has_ssn": bool, "birth_date": str}}``: 

207 Mortgage and About you update in place, so they are checked as they will be after the save. 

208 """ 

209 if section not in SECTIONS: 

210 return {"section": "Unknown section."} 

211 if not isinstance(data, dict): 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 return {"data": "Send the section's fields."} 

213 e: dict[str, str] = {} 

214 

215 if section == "mortgage": 

216 t = {**(current.get("transaction") or {}), **data} 

217 _optional_amount(e, "purchase_price_amt", t.get("purchase_price_amt")) 

218 _optional_amount(e, "appraised_value_amt", t.get("appraised_value_amt")) 

219 _optional_amount(e, "base_loan_amt", t.get("base_loan_amt")) 

220 if t.get("purchase_price_amt") is None and t.get("appraised_value_amt") is None: 

221 e["purchase_price_amt"] = "Enter the purchase price or the property value." 

222 loan = t.get("base_loan_amt") 

223 if _is_positive(loan) and "base_loan_amt" not in e: 

224 value, price = t.get("appraised_value_amt"), t.get("purchase_price_amt") 

225 if _is_positive(value) and cast(Any, loan) > value: 

226 e["base_loan_amt"] = "Loan amount can't be more than the property value." 

227 elif _is_positive(price) and cast(Any, loan) > price: 

228 e["base_loan_amt"] = "Loan amount can't be more than the purchase price." 

229 

230 elif section == "personal": 

231 borrower = current.get("borrower") or {} 

232 ssn = data.get("ssn") 

233 if is_masked_ssn(ssn) and borrower.get("has_ssn"): 

234 ssn = None # the masked value read back from GET /pos/my: keep the stored SSN 

235 if ssn is None: 

236 if not borrower.get("has_ssn"): 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 e["ssn"] = "Enter your SSN." 

238 elif blank(ssn): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 e["ssn"] = "Enter your SSN." 

240 elif not isinstance(ssn, str) or not _SSN_RE.match(ssn): 

241 e["ssn"] = "Enter a valid SSN, e.g. 123-45-6789." 

242 birth = data.get("birth_date", borrower.get("birth_date")) 

243 if blank(birth): 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 e["birth_date"] = "Enter your date of birth." 

245 else: 

246 parsed = _parse_date(birth) 

247 if parsed is None: 

248 e["birth_date"] = "Enter a valid date." 

249 elif parsed > today: 

250 e["birth_date"] = "Date of birth can't be in the future." 

251 elif age_on(parsed, today) < 18: 

252 e["birth_date"] = "You must be at least 18." 

253 elif age_on(parsed, today) > 120: 

254 e["birth_date"] = "Enter a valid date of birth." 

255 

256 elif section == "coborrower": 

257 _require_text(e, "first_name", data.get("first_name"), "Enter the first name.") 

258 _require_text(e, "last_name", data.get("last_name"), "Enter the last name.") 

259 email, phone = data.get("email"), data.get("cell_phone") 

260 if not blank(email) and not (isinstance(email, str) and is_email(email)): 

261 e["email"] = EMAIL_MSG 

262 if not blank(phone) and not (isinstance(phone, str) and is_us_phone(phone)): 

263 e["cell_phone"] = PHONE_MSG 

264 

265 elif section == "employment": 

266 _require_text(e, "employer_name", data.get("employer_name"), "Enter the employer name.") 

267 title = data.get("position_title") 

268 if not blank(title) and (not isinstance(title, str) or len(title.strip()) > MAX_TEXT): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 e["position_title"] = TOO_LONG_MSG 

270 _optional_amount(e, "monthly_income_amt", data.get("monthly_income_amt")) 

271 years = data.get("years_in_line_of_work") 

272 if years is not None and not (_is_number(years) and 0 <= years <= 60): 

273 e["years_in_line_of_work"] = "Enter years as a number from 0 to 60." 

274 

275 elif section == "income": 

276 _require_amount(e, "monthly_amt", data.get("monthly_amt"), "Enter your monthly income.") 

277 

278 elif section == "assets": 

279 _require_text(e, "institution_name", data.get("institution_name"), "Enter the institution.") 

280 _require_amount( 

281 e, "cash_or_market_value_amt", data.get("cash_or_market_value_amt"), "Enter the value." 

282 ) 

283 

284 elif section == "liabilities": 

285 _require_text(e, "creditor_name", data.get("creditor_name"), "Enter the creditor.") 

286 payment, balance = data.get("monthly_payment_amt"), data.get("unpaid_balance_amt") 

287 _require_amount(e, "monthly_payment_amt", payment, "Enter the monthly payment.") 

288 _require_amount(e, "unpaid_balance_amt", balance, "Enter the unpaid balance.") 

289 if "monthly_payment_amt" not in e and "unpaid_balance_amt" not in e and cast(Any, payment) > balance: 

290 e["monthly_payment_amt"] = "Monthly payment can't be more than the unpaid balance." 

291 

292 elif section == "reo": 

293 _require_amount( 

294 e, "property_value_amt", data.get("property_value_amt"), "Enter the property value." 

295 ) 

296 usage = data.get("property_usage_type") 

297 if usage is not None and usage not in OCCUPANCIES: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 e["property_usage_type"] = "Choose how the property is used." 

299 disposition = data.get("disposition_status_type") 

300 if disposition is not None and disposition not in REO_DISPOSITIONS: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 e["disposition_status_type"] = "Choose what you plan to do with the property." 

302 

303 elif section == "declarations": 

304 e.update(_validate_answers(data.get("declarations"), DECLARATION_KEYS, "declarations")) 

305 

306 elif section == "additional": 

307 e.update(_validate_answers(data.get("answers"), MORE_QUESTION_KEYS, "answers")) 

308 

309 elif section == "demographics": 

310 ethnicity, sex, race = data.get("ethnicity"), data.get("sex"), data.get("race") or [] 

311 if ethnicity is not None and ethnicity not in ETHNICITIES: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 e["ethnicity"] = "Choose an ethnicity." 

313 if sex is not None and sex not in SEXES: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 e["sex"] = "Choose a sex." 

315 if not isinstance(race, list) or any(r not in RACES for r in race): 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 e["race"] = "Choose races from the list." 

317 elif not ethnicity and not sex and not race: 

318 e["demographics"] = ( 

319 'Make a choice for at least one question, or choose "I do not wish to provide".' 

320 ) 

321 elif "NoProvide" in race and len(race) > 1: 

322 e["race"] = '"No Provide" can\'t be combined with other races.' 

323 

324 elif section == "credit": 324 ↛ 328line 324 didn't jump to line 328 because the condition on line 324 was always true

325 if not isinstance(data.get("consent"), bool): 

326 e["consent"] = "Tick or untick the consent box." 

327 

328 return e 

329 

330 

331def _parse_date(value: Any) -> date | None: 

332 if not isinstance(value, str) or not _DATE_RE.match(value): 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 return None 

334 try: 

335 return date.fromisoformat(value) 

336 except ValueError: 

337 return None 

338 

339 

340# --- progress --------------------------------------------------------------------- 

341# The four steps that belong to the loan, and the six each borrower answers for themselves. 

342COMMON_STEP_KEYS = ("mortgage", "assets", "liabilities", "reo") 

343BORROWER_STEP_KEYS = ("personal", "employment", "income", "declarations", "demographics", "credit") 

344 

345 

346def _borrowers_of(app: dict) -> list[dict]: 

347 """The per-borrower blocks, falling back to the single compat ``borrower`` block.""" 

348 borrowers = app.get("borrowers") 

349 if borrowers: 349 ↛ 351line 349 didn't jump to line 351 because the condition on line 349 was always true

350 return borrowers 

351 b = app.get("borrower") 

352 return [b] if b else [] 

353 

354 

355def _common_step_done(app: dict, key: str) -> bool: 

356 if key == "mortgage": 

357 t = app.get("transaction") or {} 

358 return bool(t.get("purchase_price_amt") or t.get("appraised_value_amt")) 

359 if key == "assets": 

360 return len(app.get("assets") or []) > 0 

361 if key == "liabilities": 

362 return len(app.get("liabilities") or []) > 0 

363 if key == "reo": 363 ↛ 365line 363 didn't jump to line 365 because the condition on line 363 was always true

364 return len(app.get("real_estates") or []) > 0 

365 return False 

366 

367 

368def _borrower_step_done(b: dict, key: str) -> bool: 

369 if key == "personal": 

370 return bool(b.get("ssn") and b.get("birth_date")) 

371 if key == "employment": 

372 return len(b.get("employers") or []) > 0 

373 if key == "income": 

374 return len(b.get("incomes") or []) > 0 

375 if key == "declarations": 

376 return len(b.get("declarations") or {}) > 0 

377 if key == "demographics": 

378 demo = b.get("demographics") or {} 

379 return bool(demo.get("ethnicity") or demo.get("sex") or demo.get("race")) 

380 if key == "credit": 380 ↛ 382line 380 didn't jump to line 382 because the condition on line 380 was always true

381 return bool(b.get("credit_report_auth_ind")) 

382 return False 

383 

384 

385def steps_of(app: dict | None) -> dict[str, bool]: 

386 """The ten tracked steps over the API snapshot. A common step is done when its loan data 

387 exists; a per-borrower step is done when EVERY borrower on the loan has completed it.""" 

388 if not app: 

389 return {k: False for k in STEP_KEYS} 

390 borrowers = _borrowers_of(app) 

391 out: dict[str, bool] = {} 

392 for key in STEP_KEYS: 

393 if key in COMMON_STEP_KEYS: 

394 out[key] = _common_step_done(app, key) 

395 else: 

396 out[key] = bool(borrowers) and all(_borrower_step_done(b, key) for b in borrowers) 

397 return out 

398 

399 

400def percent_complete(steps: dict[str, bool]) -> int: 

401 done = sum(1 for k in STEP_KEYS if steps.get(k)) 

402 return round(done / len(STEP_KEYS) * 100) 

403 

404 

405def percent_complete_of(app: dict | None) -> int: 

406 """Expanded completion: the common steps once, the per-borrower steps for every borrower.""" 

407 if not app: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 return 0 

409 borrowers = _borrowers_of(app) 

410 total = len(COMMON_STEP_KEYS) + len(BORROWER_STEP_KEYS) * max(len(borrowers), 1) 

411 done = sum(1 for k in COMMON_STEP_KEYS if _common_step_done(app, k)) 

412 for b in borrowers: 

413 done += sum(1 for k in BORROWER_STEP_KEYS if _borrower_step_done(b, k)) 

414 return round(done / total * 100) 

415 

416 

417_MASKED_SSN_RE = re.compile(r"^\*{3}-\*{2}-\d{4}$") 

418 

419 

420def is_masked_ssn(value: Any) -> bool: 

421 return isinstance(value, str) and bool(_MASKED_SSN_RE.match(value)) 

422 

423 

424def mask_ssn(ssn_digits: str | None) -> str | None: 

425 """``***-**-1234`` for a stored SSN (design.md D7); None when none is stored.""" 

426 if not ssn_digits: 

427 return None 

428 return f"***-**-{ssn_digits[-4:]}"