Coverage for domain/aus.py: 95%

82 statements  

« 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``. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass, field 

11from decimal import Decimal 

12 

13from domain.result import AppError, Result, err, ok 

14 

15 

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 

28 

29 

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 

41 

42 

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" 

50 

51 

52@dataclass 

53class AusEligibility: 

54 du_eligible: bool 

55 lpa_eligible: bool 

56 issues: list[AusIssue] 

57 

58 

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 ) 

135 

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 ) 

140 

141 return AusEligibility(du_eligible=blocks("du"), lpa_eligible=blocks("lpa"), issues=issues) 

142 

143 

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])