Coverage for domain/disclosures.py: 88%
33 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"""Disclosure package ordering & sequential status gating (spec §13.3–§13.5).
3Enforces federal TRID sequencing:
4- Initial Disclosures (Loan Estimate) before Closing Disclosures (CD).
5- Closing Disclosure requires the loan to be at or after 'APPROVED_WITH_CONDITION'.
6- Closing Package requires the loan to be at or after 'CLEAR_TO_CLOSE'.
7"""
9from __future__ import annotations
11from collections.abc import Sequence
12from typing import Any
14from domain.result import AppError, Result, err, ok
16_STAGE_ORDER: tuple[str, ...] = (
17 "APPLICATION_INTAKE",
18 "PREAPPROVED",
19 "QUALIFICATION",
20 "LOAN_SETUP",
21 "DISCLOSURE_SENT",
22 "UNDERWRITING_SUBMITTED",
23 "APPROVED_WITH_CONDITION",
24 "RE_SUBMITTAL",
25 "CLEAR_TO_CLOSE",
26 "DOCS_OUT",
27 "DOCS_SIGNED",
28 "LOAN_FUNDED",
29)
32def _is_at_or_after(loan_stage: str, target_stage: str) -> bool:
33 try:
34 idx_current = _STAGE_ORDER.index(loan_stage)
35 idx_target = _STAGE_ORDER.index(target_stage)
36 return idx_current >= idx_target
37 except ValueError:
38 return False
41def _get_field(obj: Any, key: str, default: Any = None) -> Any:
42 if isinstance(obj, dict): 42 ↛ 44line 42 didn't jump to line 44 because the condition on line 42 was always true
43 return obj.get(key, default)
44 return getattr(obj, key, default)
47def can_generate_package(
48 loan_stage: str,
49 existing_packages: Sequence[Any],
50 package_type: str,
51) -> Result:
52 """Validate whether a disclosure package can be legally and operationally generated.
54 Returns ``ok(True)`` on success, or ``err(AppError(...))`` with error codes
55 ``DISC_ORDER`` or ``DISC_STAGE``.
56 """
57 has_initials = any(
58 _get_field(p, "type", _get_field(p, "package_type")) in ("LoanEstimate", "InitialDisclosure")
59 and _get_field(p, "status") != "Draft"
60 for p in existing_packages
61 )
63 if package_type in ("PreApproval", "ChangeOfCircumstance", "LoanEstimate", "InitialDisclosure"):
64 return ok(True)
66 if package_type == "ClosingDisclosure":
67 if not has_initials:
68 return err(
69 AppError(
70 code="DISC_ORDER",
71 message="Generate Initial Disclosures before a Closing Disclosure.",
72 )
73 )
74 if loan_stage not in ("APPROVED_WITH_CONDITION", "RE_SUBMITTAL") and not _is_at_or_after(
75 loan_stage, "APPROVED_WITH_CONDITION"
76 ):
77 return err(
78 AppError(
79 code="DISC_STAGE",
80 message='Loan must be "Approved w/ Conditions" before a Closing Disclosure.',
81 )
82 )
83 return ok(True)
85 if package_type == "ClosingPackage": 85 ↛ 102line 85 didn't jump to line 102 because the condition on line 85 was always true
86 if not has_initials:
87 return err(
88 AppError(
89 code="DISC_ORDER",
90 message="Generate Initial Disclosures before a Closing Package.",
91 )
92 )
93 if not _is_at_or_after(loan_stage, "CLEAR_TO_CLOSE") and loan_stage != "CLEAR_TO_CLOSE":
94 return err(
95 AppError(
96 code="DISC_STAGE",
97 message='Loan must be "Clear to Close" before a Closing Package.',
98 )
99 )
100 return ok(True)
102 return ok(True)