Coverage for domain/aus.py: 95%
82 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"""AUS eligibility pre-flight, ported from ``arive-spec/src/domain/aus.ts``.
3Pure, deterministic readiness checks that must clear before a DU/LPA submission. Adapted
4to the current model: the subject-property street-address check is advisory (we have no
5Property model yet), upgrading to blocking when a Property slice lands.
6"""
8from __future__ import annotations
10from dataclasses import dataclass, field
11from decimal import Decimal
13from domain.result import AppError, Result, err, ok
16@dataclass
17class AusBorrower:
18 first_name: str
19 last_name: str
20 is_primary: bool = False
21 active: bool = True
22 has_ssn: bool = False
23 has_dob: bool = False
24 credit_auth: bool = False
25 has_demographics: bool = False
26 has_declarations: bool = False
27 has_current_residence: bool = False
30@dataclass
31class AusContext:
32 credit_pulled: bool
33 stage: str
34 purpose: str
35 base_loan_amt: Decimal | None
36 property_value: Decimal | None
37 borrowers: list[AusBorrower] = field(default_factory=list)
38 income_count: int = 0
39 asset_count: int = 0
40 has_subject_address: bool = False
43@dataclass
44class AusIssue:
45 category: str
46 code: str
47 message: str
48 engines: list[str] = field(default_factory=list)
49 severity: str = "block" # "block" | "warn"
52@dataclass
53class AusEligibility:
54 du_eligible: bool
55 lpa_eligible: bool
56 issues: list[AusIssue]
59def check_aus_eligibility(ctx: AusContext) -> AusEligibility:
60 issues: list[AusIssue] = []
61 if not ctx.credit_pulled:
62 issues.append(AusIssue("credit", "AUS-CREDIT", "Credit must be pulled before running AUS."))
63 active = [b for b in ctx.borrowers if b.active]
64 if not any(b.is_primary for b in active): 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true
65 issues.append(AusIssue("borrowers", "AUS-BORR", "A primary borrower is required."))
66 for b in active:
67 who = f"{b.first_name} {b.last_name}"
68 if not b.has_ssn:
69 issues.append(AusIssue("borrowers", "AUS-SSN", f"SSN missing for {who}."))
70 if not b.has_dob:
71 issues.append(AusIssue("borrowers", "AUS-DOB", f"Date of birth missing for {who}."))
72 if not b.credit_auth:
73 issues.append(
74 AusIssue("declaration", "AUS-CONSENT", f"Credit authorization missing for {who}.")
75 )
76 if not b.has_declarations:
77 issues.append(
78 AusIssue(
79 "declaration",
80 "AUS-DECL",
81 f"URLA declarations missing for {who}.",
82 severity="warn",
83 )
84 )
85 if not b.has_current_residence:
86 issues.append(
87 AusIssue(
88 "residence",
89 "AUS-RES",
90 f"Current residence missing for {who}.",
91 severity="warn",
92 )
93 )
94 if ctx.income_count == 0:
95 issues.append(AusIssue("incomes", "AUS-INC", "At least one income record is required."))
96 if not ctx.base_loan_amt: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 issues.append(AusIssue("pricing", "AUS-AMT", "Base loan amount is required."))
98 if not ctx.property_value: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 issues.append(AusIssue("pricing", "AUS-VAL", "Property value is required."))
100 # Subject-property address — advisory until a Property model exists.
101 if not ctx.has_subject_address:
102 issues.append(
103 AusIssue(
104 "residence",
105 "AUS-ADDR",
106 "Subject-property street address is recommended.",
107 severity="warn",
108 )
109 )
110 # HMDA demographic — advisory.
111 pb = next((b for b in active if b.is_primary), None)
112 if pb and not pb.has_demographics:
113 issues.append(
114 AusIssue(
115 "demographic",
116 "AUS-HMDA",
117 "HMDA demographic information (or refusal) is recommended.",
118 severity="warn",
119 )
120 )
121 if ctx.stage in ("ADVERSE", "SUSPENDED"):
122 issues.append(
123 AusIssue("status", "AUS-STATUS", f"Loan status {ctx.stage} cannot be submitted to AUS.")
124 )
125 # Engine divergence: Freddie LPA expects at least one reserves asset; DU is more lenient.
126 if ctx.asset_count == 0:
127 issues.append(
128 AusIssue(
129 "assets",
130 "LPA-ASSET",
131 "LPA requires at least one asset/reserves record.",
132 engines=["lpa"],
133 )
134 )
136 def blocks(engine: str) -> bool:
137 return not any(
138 i.severity != "warn" and (not i.engines or engine in i.engines) for i in issues
139 )
141 return AusEligibility(du_eligible=blocks("du"), lpa_eligible=blocks("lpa"), issues=issues)
144def assert_aus_ready(ctx: AusContext, engine: str = "du") -> Result:
145 eligibility = check_aus_eligibility(ctx)
146 relevant = [
147 i
148 for i in eligibility.issues
149 if i.severity != "warn" and (not i.engines or engine in i.engines)
150 ]
151 if not relevant:
152 return ok(True)
153 return err(*[AppError(code=i.code, message=i.message, field=i.category) for i in relevant])