Coverage for domain/pos.py: 90%
280 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
1"""Borrower portal (Borrower POS) rules — pure, no Django (add-borrower-portal-api design.md D5–D7).
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"""
9from __future__ import annotations
11import re
12from datetime import date
13from typing import Any, cast
15from domain.result import AppError
17MAX_TEXT = 100
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}$")
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."
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")
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)
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")
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}
68ETHNICITIES = ("Hispanic", "NotHispanic", "NoProvide")
69SEXES = ("Male", "Female", "NoProvide")
70RACES = (
71 "AmericanIndianOrAlaskaNative", "Asian", "BlackOrAfricanAmerican",
72 "NativeHawaiianOrOtherPacificIslander", "White", "NoProvide",
73)
76# --- small helpers --------------------------------------------------------------
77def digits(value: str) -> str:
78 return "".join(ch for ch in value if ch.isdigit())
81def blank(value: Any) -> bool:
82 return value is None or (isinstance(value, str) and value.strip() == "")
85def is_email(value: str) -> bool:
86 return bool(_EMAIL_RE.match(value.strip()))
89def is_us_phone(value: str) -> bool:
90 return len(digits(value)) == 10
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 )
102def _is_number(value: Any) -> bool:
103 return isinstance(value, int | float) and not isinstance(value, bool)
106def _is_positive(value: Any) -> bool:
107 return _is_number(value) and value > 0
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
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
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
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
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
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
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
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 {}
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
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 {}
203def validate_step(section: str, data: Any, current: dict, today: date) -> dict[str, str]:
204 """One Application section (borrower-pos ``validateStep``).
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] = {}
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."
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."
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
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."
275 elif section == "income":
276 _require_amount(e, "monthly_amt", data.get("monthly_amt"), "Enter your monthly income.")
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 )
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."
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."
303 elif section == "declarations":
304 e.update(_validate_answers(data.get("declarations"), DECLARATION_KEYS, "declarations"))
306 elif section == "additional":
307 e.update(_validate_answers(data.get("answers"), MORE_QUESTION_KEYS, "answers"))
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.'
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."
328 return e
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
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")
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 []
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
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
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
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)
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)
417_MASKED_SSN_RE = re.compile(r"^\*{3}-\*{2}-\d{4}$")
420def is_masked_ssn(value: Any) -> bool:
421 return isinstance(value, str) and bool(_MASKED_SSN_RE.match(value))
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:]}"