Coverage for domain/qualification.py: 100%
40 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"""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 are dropped; proposed housing falls back to
5the subject P&I. Reuses the ported ``calc.dti`` / ``calc.round2``.
6"""
8from __future__ import annotations
10from dataclasses import dataclass
11from decimal import Decimal
13from domain.calc import Number, dti, round2
15_ZERO = Decimal("0.00")
18@dataclass
19class IncomeItem:
20 monthly_amt: Number
21 current_ind: bool = True
24@dataclass
25class LiabilityItem:
26 monthly_payment_amt: Number
27 exclude_from_dti_ind: bool = False
28 omit_ind: bool = False
31@dataclass
32class HousingItem:
33 payment_amt: Number
34 timing_type: str = "Proposed"
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
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))
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 )
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
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 )