Coverage for apps/pricing_ppe/api.py: 89%
92 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 14:06 +0000
1"""Pricing router: product pricing, ad-hoc quote, and rate lock (mounted under /api/loans).
3Thin ORM<->domain adapter over ``domain/pricing.py`` — the engine stays pure. Follows the
4credit/AUS router shape: ``_scoped_loan`` + ``ensure_staff_scope`` + ``requires(...)`` guards.
5"""
7from __future__ import annotations
9from datetime import timedelta
10from decimal import Decimal
11from uuid import UUID
13from django.utils import timezone
14from ninja import Router
16from apps.accounts.auth import ensure_staff_scope, requires
17from apps.core_los.models import Loan, LoanTransaction
18from apps.pricing_ppe.models import RateLock, RateProduct
19from apps.pricing_ppe.schemas import (
20 PricingResponseOut,
21 PricingResultOut,
22 QuoteIn,
23 RateLockCreate,
24 RateLockOut,
25 RateLockReadOut,
26)
27from domain.pricing import (
28 PricingScenario,
29 RateProductInput,
30 price_scenario,
31 recommend,
32 to_pricing_result,
33)
34from domain.result import AppError, DomainError
36router = Router(tags=["pricing"])
38_CENTS = Decimal("0.01")
39_HUNDRED = Decimal("100")
42def _scoped_loan(request, guid: UUID) -> Loan:
43 loan = Loan.objects.filter(guid=guid).first()
44 if loan is None: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true
45 raise DomainError([AppError("NOT_FOUND", "Loan not found")])
46 ensure_staff_scope(request.principal, loan)
47 return loan
50def _txn(loan: Loan) -> LoanTransaction | None:
51 try:
52 return loan.transaction
53 except LoanTransaction.DoesNotExist:
54 return None
57def representative_fico(loan: Loan) -> int | None:
58 """Loan-level representative FICO: the lowest per-borrower mid score across the loan's
59 most recent credit order (the standard 'representative credit score' rule)."""
60 order = loan.credit_orders.order_by("-created_at").first()
61 if order is None:
62 return None
63 mids = [s.mid for s in order.scores.all() if s.mid]
64 return min(mids) if mids else None
67def _active_products() -> list[RateProductInput]:
68 return [p.to_domain() for p in RateProduct.objects.filter(is_active=True)]
71def _build_scenario(loan: Loan, overrides: QuoteIn | None = None) -> PricingScenario:
72 txn = _txn(loan)
73 o = overrides or QuoteIn()
74 txn_amount = txn.base_loan_amt if txn else None
75 loan_amount = o.loan_amount if o.loan_amount is not None else txn_amount
76 stored_value = (txn.appraised_value_amt or txn.purchase_price_amt) if txn else None
77 property_value = o.property_value if o.property_value is not None else stored_value
78 fico = o.fico if o.fico is not None else representative_fico(loan)
80 if loan_amount is None or property_value is None: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true
81 raise DomainError(
82 [AppError("PRICING_INCOMPLETE", "Loan amount and property value are required to price")]
83 )
84 if fico is None:
85 raise DomainError(
86 [AppError("PRICING_NO_FICO", "No representative FICO; pull credit or supply a score")]
87 )
89 kwargs: dict = {
90 "loan_amount": loan_amount,
91 "property_value": property_value,
92 "fico": fico,
93 "occupancy": o.occupancy or loan.occupancy,
94 "term_months": o.term_months or (txn.term_months if txn else 360),
95 }
96 for fld in ("monthly_taxes", "monthly_hazard", "monthly_hoa", "monthly_mi"):
97 val = getattr(o, fld)
98 if val is not None: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 kwargs[fld] = val
100 return PricingScenario(**kwargs)
103@router.get("/{guid}/pricing/products", response=PricingResultOut, auth=requires("LOAN_VIEW"))
104def pricing_products(request, guid: UUID):
105 loan = _scoped_loan(request, guid)
106 priced = price_scenario(_build_scenario(loan), _active_products())
107 return to_pricing_result(priced)
110@router.post("/{guid}/pricing/quote", response=PricingResponseOut, auth=requires("LOAN_VIEW"))
111def pricing_quote(request, guid: UUID, payload: QuoteIn):
112 loan = _scoped_loan(request, guid)
113 priced = price_scenario(_build_scenario(loan, payload), _active_products())
114 result = to_pricing_result(priced)
115 return {
116 "count": result.count,
117 "products": result.products,
118 "ineligible_products": result.ineligible_products,
119 "recommendation": recommend(priced, payload.goal) if payload.goal else None,
120 }
123@router.get("/{guid}/pricing/lock", response=RateLockReadOut, auth=requires("LOAN_VIEW"))
124def get_lock(request, guid: UUID):
125 loan = _scoped_loan(request, guid)
126 try:
127 return {"lock": loan.rate_lock}
128 except RateLock.DoesNotExist:
129 return {"lock": None}
132@router.post("/{guid}/pricing/lock", response={201: RateLockOut}, auth=requires("LOAN_EDIT"))
133def create_lock(request, guid: UUID, payload: RateLockCreate):
134 loan = _scoped_loan(request, guid)
135 if RateLock.objects.filter(loan=loan).exists():
136 raise DomainError([AppError("CONFLICT", "Loan already has a rate lock")])
137 product = RateProduct.objects.filter(id=payload.product_id, is_active=True).first()
138 if product is None: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 raise DomainError([AppError("NOT_FOUND", "Rate product not found")])
141 lock_days = payload.lock_period_days or 30
142 txn = _txn(loan)
143 loan_amt = txn.base_loan_amt if txn else Decimal("0")
144 lo_comp_amt = (loan_amt * (product.comp_pct / _HUNDRED)).quantize(_CENTS)
145 locked_rate = payload.rate if payload.rate is not None else product.base_rate
146 locked_price = payload.price if payload.price is not None else product.base_price
148 lock = RateLock.objects.create(
149 loan=loan,
150 product=product,
151 locked_rate=locked_rate,
152 locked_price=locked_price,
153 lock_period_days=lock_days,
154 expires_at=timezone.now() + timedelta(days=lock_days),
155 lo_comp_amt=lo_comp_amt,
156 lo_comp_type=product.comp_type,
157 )
158 return 201, lock
161@router.get("/{guid}/lock", response=RateLockReadOut, auth=requires("LOAN_VIEW"))
162def get_lock_alias(request, guid: UUID):
163 return get_lock(request, guid)
166@router.post("/{guid}/lock", response={201: RateLockOut}, auth=requires("LOAN_EDIT"))
167def create_lock_alias(request, guid: UUID, payload: RateLockCreate):
168 return create_lock(request, guid, payload)