Coverage for apps/underwriting/aus_api.py: 94%
74 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"""AUS router: eligibility pre-flight + dual DU/LPA runs + comparison (under /api/loans)."""
3import uuid
4from decimal import Decimal
5from uuid import UUID
7from ninja import Router
9from apps.accounts.auth import ensure_staff_scope, requires
10from apps.application_1003.models import Declaration, Demographics, SubjectProperty
11from apps.core_los.models import Loan, LoanTransaction
12from apps.underwriting.models import AusRun
13from apps.underwriting.providers import get_aus_engine
14from apps.underwriting.schemas import AusComparisonOut, AusEligibilityOut, AusRunOut
15from domain.aus import AusBorrower, AusContext, assert_aus_ready, check_aus_eligibility
16from domain.calc import monthly_pi
17from domain.qualification import HousingItem, IncomeItem, LiabilityItem, qualify
18from domain.result import AppError, DomainError
20router = Router(tags=["aus"])
23def _scoped_loan(request, guid: UUID) -> Loan:
24 loan = Loan.objects.filter(guid=guid).first()
25 if loan is None: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 raise DomainError([AppError("NOT_FOUND", "Loan not found")])
27 ensure_staff_scope(request.principal, loan)
28 return loan
31def _txn(loan: Loan) -> LoanTransaction | None:
32 try:
33 return loan.transaction
34 except LoanTransaction.DoesNotExist:
35 return None
38def _build_ctx(loan: Loan) -> AusContext:
39 txn = _txn(loan)
40 value = (txn.appraised_value_amt or txn.purchase_price_amt) if txn else None
41 borrowers = [
42 AusBorrower(
43 first_name=b.first_name,
44 last_name=b.last_name,
45 is_primary=b.is_primary,
46 active=True,
47 has_ssn=b.ssn_encrypted is not None,
48 has_dob=b.birth_date is not None,
49 credit_auth=b.credit_auth_ind,
50 has_demographics=Demographics.objects.filter(borrower=b).exists(),
51 has_declarations=Declaration.objects.filter(borrower=b).exists(),
52 has_current_residence=b.residences.filter(residence_type="Current").exists(),
53 )
54 for b in loan.borrowers.all()
55 ]
56 sp = SubjectProperty.objects.filter(loan=loan).first()
57 return AusContext(
58 credit_pulled=loan.credit_pulled,
59 stage=loan.stage,
60 purpose=loan.purpose,
61 base_loan_amt=txn.base_loan_amt if txn else None,
62 property_value=value,
63 borrowers=borrowers,
64 income_count=loan.incomes.count(),
65 asset_count=loan.assets.count(),
66 has_subject_address=bool(sp and sp.address_line),
67 )
70def _ratios(loan: Loan) -> dict:
71 txn = _txn(loan)
72 incomes = [IncomeItem(i.monthly_amt, i.current_ind) for i in loan.incomes.all()]
73 liabilities = [
74 LiabilityItem(liab.monthly_payment_amt, liab.exclude_from_dti_ind, liab.omit_ind)
75 for liab in loan.liabilities.all()
76 ]
77 housing = [HousingItem(h.payment_amt, h.timing_type) for h in loan.housing_expenses.all()]
78 fallback = (
79 monthly_pi(txn.base_loan_amt, txn.note_rate_percent or 0, txn.term_months) if txn else None
80 )
81 q = qualify(incomes, liabilities, housing, fallback_pi=fallback)
82 ltv = txn.ltv_ratio_percent if (txn and txn.ltv_ratio_percent is not None) else Decimal("0.00")
83 cltv = txn.cltv_ratio_percent if (txn and txn.cltv_ratio_percent is not None) else ltv
84 return {
85 "total_dti_pct": q.total_dti_pct,
86 "housing_dti_pct": q.housing_dti_pct,
87 "ltv_pct": ltv,
88 "cltv_pct": cltv,
89 }
92def _eligibility_payload(elig) -> dict:
93 return {
94 "du_eligible": elig.du_eligible,
95 "lpa_eligible": elig.lpa_eligible,
96 "issues": [
97 {
98 "category": i.category,
99 "code": i.code,
100 "message": i.message,
101 "engines": i.engines,
102 "severity": i.severity,
103 }
104 for i in elig.issues
105 ],
106 }
109@router.get("/{guid}/aus/check", response=AusEligibilityOut, auth=requires("LOAN_VIEW"))
110def aus_check(request, guid: UUID):
111 loan = _scoped_loan(request, guid)
112 return _eligibility_payload(check_aus_eligibility(_build_ctx(loan)))
115def _run(request, guid: UUID, engine: str):
116 loan = _scoped_loan(request, guid)
117 ready = assert_aus_ready(_build_ctx(loan), engine)
118 if not ready.ok:
119 raise DomainError(ready.errors)
120 result = get_aus_engine(org=loan.org_unit).run(engine, _ratios(loan))
121 casefile_prefix = "DU" if engine.lower() == "du" else "LPA"
122 casefile_id = f"{casefile_prefix}-{uuid.uuid4().hex[:8].upper()}"
123 run = AusRun.objects.create(
124 loan=loan,
125 engine=engine,
126 casefile_id=casefile_id,
127 recommendation=result.recommendation,
128 risk_class=result.risk_class,
129 housing_pct=result.housing_pct,
130 dti_pct=result.dti_pct,
131 ltv_pct=result.ltv_pct,
132 cltv_pct=result.cltv_pct,
133 messages=result.messages,
134 )
135 return 201, run
138@router.post("/{guid}/aus/du", response={201: AusRunOut}, auth=requires("LOAN_EDIT"))
139def run_du(request, guid: UUID):
140 return _run(request, guid, "du")
143@router.post("/{guid}/aus/lpa", response={201: AusRunOut}, auth=requires("LOAN_EDIT"))
144def run_lpa(request, guid: UUID):
145 return _run(request, guid, "lpa")
148@router.get("/{guid}/aus", response=AusComparisonOut, auth=requires("LOAN_VIEW"))
149def aus_results(request, guid: UUID):
150 loan = _scoped_loan(request, guid)
151 runs = list(loan.aus_runs.all().order_by("-submitted_at"))
152 du = next((r for r in runs if r.engine == "du"), None)
153 lpa = next((r for r in runs if r.engine == "lpa"), None)
154 return {"du": du, "lpa": lpa, "runs": runs}
157@router.get("/{guid}/aus/compare", response=AusComparisonOut, auth=requires("LOAN_VIEW"))
158def aus_compare(request, guid: UUID):
159 return aus_results(request, guid)