Coverage for domain/qualification.py: 100%

40 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +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 are dropped; proposed housing falls back to 

5the subject P&I. Reuses the ported ``calc.dti`` / ``calc.round2``. 

6""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass 

11from decimal import Decimal 

12 

13from domain.calc import Number, dti, round2 

14 

15_ZERO = Decimal("0.00") 

16 

17 

18@dataclass 

19class IncomeItem: 

20 monthly_amt: Number 

21 current_ind: bool = True 

22 

23 

24@dataclass 

25class LiabilityItem: 

26 monthly_payment_amt: Number 

27 exclude_from_dti_ind: bool = False 

28 omit_ind: bool = False 

29 

30 

31@dataclass 

32class HousingItem: 

33 payment_amt: Number 

34 timing_type: str = "Proposed" 

35 

36 

37@dataclass(frozen=True) 

38class QualificationResult: 

39 monthly_income: Decimal 

40 monthly_debt: Decimal 

41 housing_expense: Decimal 

42 housing_dti_pct: Decimal 

43 total_dti_pct: Decimal 

44 

45 

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

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

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

49 

50 

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

52 """Sum of liability monthly payments, excluding DTI-excluded / omitted items.""" 

53 return round2( 

54 sum( 

55 ( 

56 round2(liab.monthly_payment_amt) 

57 for liab in liabilities 

58 if not liab.exclude_from_dti_ind and not liab.omit_ind 

59 ), 

60 _ZERO, 

61 ) 

62 ) 

63 

64 

65def proposed_housing_expense( 

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

67) -> Decimal: 

68 """Proposed housing = sum of proposed housing records; else the subject P&I fallback.""" 

69 proposed = [round2(h.payment_amt) for h in housing if h.timing_type == "Proposed"] 

70 if proposed: 

71 return round2(sum(proposed, _ZERO)) 

72 return round2(fallback_pi) if fallback_pi is not None else _ZERO 

73 

74 

75def qualify( 

76 incomes: list[IncomeItem], 

77 liabilities: list[LiabilityItem], 

78 housing: list[HousingItem], 

79 fallback_pi: Number | None = None, 

80) -> QualificationResult: 

81 income = total_monthly_income(incomes) 

82 debt = total_monthly_debt(liabilities) 

83 housing_expense = proposed_housing_expense(housing, fallback_pi) 

84 ratios = dti(housing_expense, debt, income) 

85 return QualificationResult( 

86 monthly_income=income, 

87 monthly_debt=debt, 

88 housing_expense=housing_expense, 

89 housing_dti_pct=ratios.housing_pct, 

90 total_dti_pct=ratios.total_pct, 

91 )