Coverage for domain/qualification.py: 100%
68 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-05 19:46 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-05 19:46 +0000
1"""Loan qualification: aggregate 1003 financials into DTI.
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"""
10from __future__ import annotations
12from dataclasses import dataclass
13from decimal import Decimal
15from domain.calc import Number, dti, round2
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")
22@dataclass
23class IncomeItem:
24 monthly_amt: Number
25 current_ind: bool = True
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
37#: Housing-expense type holding the first-mortgage principal & interest.
38PI_EXPENSE_TYPE = "FirstMortgagePI"
41@dataclass
42class HousingItem:
43 payment_amt: Number
44 timing_type: str = "Proposed"
45 expense_type: str = PI_EXPENSE_TYPE
48@dataclass
49class RentalItem:
50 """A counted REO's monthly figures; ``None`` means not entered and counts as zero."""
52 gross_rent: Number | None = None
53 mortgage_pmt: Number | None = None
54 taxes_ins_hoa: Number | None = None
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
72def _amt(n: Number | None) -> Decimal:
73 return round2(n) if n is not None else _ZERO
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)
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
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))
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 )
110def proposed_housing_expense(
111 housing: list[HousingItem], fallback_pi: Number | None = None
112) -> Decimal:
113 """Proposed housing = P&I + every other proposed cost.
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)
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 )
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 )