Coverage for domain/qualification.py: 100%

68 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-05 19:46 +0000

1"""Loan qualification: aggregate 1003 financials into DTI. 

2 

3Pure and Django-free. Income (current records) is the canonical DTI income source; 

4liabilities flagged exclude-from-DTI / omit (or tied to a counted REO) are dropped; 

5proposed housing falls back to the subject P&I. Counted REO rows add their net rental 

6cash flow (Fannie Mae 75% rule): a positive net is income, a negative net is debt. 

7Reuses the ported ``calc.dti`` / ``calc.round2``. 

8""" 

9 

10from __future__ import annotations 

11 

12from dataclasses import dataclass 

13from decimal import Decimal 

14 

15from domain.calc import Number, dti, round2 

16 

17_ZERO = Decimal("0.00") 

18#: Share of gross rent that counts toward qualification (Fannie Mae vacancy/maintenance factor). 

19RENTAL_QUALIFYING_FACTOR = Decimal("0.75") 

20 

21 

22@dataclass 

23class IncomeItem: 

24 monthly_amt: Number 

25 current_ind: bool = True 

26 

27 

28@dataclass 

29class LiabilityItem: 

30 monthly_payment_amt: Number 

31 exclude_from_dti_ind: bool = False 

32 omit_ind: bool = False 

33 #: Secures a counted REO whose expenses already carry this payment. 

34 linked_reo_counted: bool = False 

35 

36 

37#: Housing-expense type holding the first-mortgage principal & interest. 

38PI_EXPENSE_TYPE = "FirstMortgagePI" 

39 

40 

41@dataclass 

42class HousingItem: 

43 payment_amt: Number 

44 timing_type: str = "Proposed" 

45 expense_type: str = PI_EXPENSE_TYPE 

46 

47 

48@dataclass 

49class RentalItem: 

50 """A counted REO's monthly figures; ``None`` means not entered and counts as zero.""" 

51 

52 gross_rent: Number | None = None 

53 mortgage_pmt: Number | None = None 

54 taxes_ins_hoa: Number | None = None 

55 

56 

57@dataclass(frozen=True) 

58class QualificationResult: 

59 monthly_income: Decimal 

60 monthly_debt: Decimal 

61 housing_expense: Decimal 

62 housing_dti_pct: Decimal 

63 total_dti_pct: Decimal 

64 rental_net_income: Decimal = _ZERO 

65 rental_net_loss: Decimal = _ZERO 

66 #: Sum of present (current) housing records - comparison only, not used for DTI. 

67 present_housing_expense: Decimal = _ZERO 

68 #: P&I calculated from the loan terms; ``None`` when the loan has no transaction. 

69 proposed_pi_calculated: Decimal | None = None 

70 

71 

72def _amt(n: Number | None) -> Decimal: 

73 return round2(n) if n is not None else _ZERO 

74 

75 

76def net_rental_cash_flow(item: RentalItem) -> Decimal: 

77 """Gross rent x 75% minus (mortgage payment + taxes/insurance/HOA), to the cent.""" 

78 qualifying_rent = _amt(item.gross_rent) * RENTAL_QUALIFYING_FACTOR 

79 expenses = _amt(item.mortgage_pmt) + _amt(item.taxes_ins_hoa) 

80 return round2(qualifying_rent - expenses) 

81 

82 

83def rental_totals(rentals: list[RentalItem]) -> tuple[Decimal, Decimal]: 

84 """(net rental income, net rental loss) - positive nets summed, negative nets as a positive sum.""" 

85 nets = [net_rental_cash_flow(r) for r in rentals] 

86 income = round2(sum((n for n in nets if n > 0), _ZERO)) 

87 loss = round2(sum((-n for n in nets if n < 0), _ZERO)) 

88 return income, loss 

89 

90 

91def total_monthly_income(incomes: list[IncomeItem]) -> Decimal: 

92 """Canonical DTI income: sum of current income records.""" 

93 return round2(sum((round2(i.monthly_amt) for i in incomes if i.current_ind), _ZERO)) 

94 

95 

96def total_monthly_debt(liabilities: list[LiabilityItem]) -> Decimal: 

97 """Sum of liability monthly payments, excluding DTI-excluded / omitted / REO-carried items.""" 

98 return round2( 

99 sum( 

100 ( 

101 round2(liab.monthly_payment_amt) 

102 for liab in liabilities 

103 if not (liab.exclude_from_dti_ind or liab.omit_ind or liab.linked_reo_counted) 

104 ), 

105 _ZERO, 

106 ) 

107 ) 

108 

109 

110def proposed_housing_expense( 

111 housing: list[HousingItem], fallback_pi: Number | None = None 

112) -> Decimal: 

113 """Proposed housing = P&I + every other proposed cost. 

114 

115 P&I is the sum of proposed P&I records when any exist (a manual override), otherwise the 

116 subject P&I fallback calculated from the loan terms (zero without a transaction). 

117 """ 

118 proposed = [h for h in housing if h.timing_type == "Proposed"] 

119 pi_rows = [round2(h.payment_amt) for h in proposed if h.expense_type == PI_EXPENSE_TYPE] 

120 pi = sum(pi_rows, _ZERO) if pi_rows else _amt(fallback_pi) 

121 others = sum( 

122 (round2(h.payment_amt) for h in proposed if h.expense_type != PI_EXPENSE_TYPE), _ZERO 

123 ) 

124 return round2(pi + others) 

125 

126 

127def present_housing_expense(housing: list[HousingItem]) -> Decimal: 

128 """Present (current) housing = sum of present records; for comparison, not DTI.""" 

129 return round2( 

130 sum((round2(h.payment_amt) for h in housing if h.timing_type == "Present"), _ZERO) 

131 ) 

132 

133 

134def qualify( 

135 incomes: list[IncomeItem], 

136 liabilities: list[LiabilityItem], 

137 housing: list[HousingItem], 

138 fallback_pi: Number | None = None, 

139 rentals: list[RentalItem] | None = None, 

140) -> QualificationResult: 

141 rental_income, rental_loss = rental_totals(rentals or []) 

142 income = total_monthly_income(incomes) + rental_income 

143 debt = total_monthly_debt(liabilities) + rental_loss 

144 housing_expense = proposed_housing_expense(housing, fallback_pi) 

145 ratios = dti(housing_expense, debt, income) 

146 return QualificationResult( 

147 monthly_income=income, 

148 monthly_debt=debt, 

149 housing_expense=housing_expense, 

150 housing_dti_pct=ratios.housing_pct, 

151 total_dti_pct=ratios.total_pct, 

152 rental_net_income=rental_income, 

153 rental_net_loss=rental_loss, 

154 present_housing_expense=present_housing_expense(housing), 

155 proposed_pi_calculated=round2(fallback_pi) if fallback_pi is not None else None, 

156 )