Coverage for apps/fees_trid/api.py: 90%
89 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"""Fees router: fee set/list, Loan Estimate bucket summary, Change-of-Circumstance tolerance
2cure, and the QM points-and-fees / APR-APOR test (mounted under /api/loans).
4Thin ORM<->domain adapter over ``domain/fees.py``. Follows the credit/AUS/pricing shape:
5``_scoped_loan`` + ``ensure_staff_scope`` + ``requires(...)`` guards.
6"""
8from __future__ import annotations
10from decimal import Decimal
11from uuid import UUID
13from ninja import Router
15from apps.accounts.auth import ensure_staff_scope, requires
16from apps.core_los.models import Loan, LoanTransaction
17from apps.disclosures.services import loan_is_disclosed
18from apps.fees_trid.models import Fee
19from apps.fees_trid.schemas import (
20 FeeIn,
21 FeeOut,
22 FeeSetIn,
23 FeeSummaryOut,
24 QmResultOut,
25 ToleranceOut,
26)
27from apps.fees_trid.services import replace_fees
28from domain.enums import PaidByType, ToleranceBucket
29from domain.fees import (
30 FeeInput,
31 bucket_of,
32 compute_tolerance_cure,
33 qm_points_and_fees_test,
34 round2,
35)
36from domain.result import AppError, DomainError
38router = Router(tags=["fees"])
40ZERO = Decimal("0")
41_BUCKET_ORDER = (ToleranceBucket.ZERO, ToleranceBucket.TEN_PERCENT, ToleranceBucket.UNLIMITED)
44def _scoped_loan(request, guid: UUID) -> Loan:
45 loan = Loan.objects.filter(guid=guid).first()
46 if loan is None: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true
47 raise DomainError([AppError("NOT_FOUND", "Loan not found")])
48 ensure_staff_scope(request.principal, loan)
49 return loan
52def _txn(loan: Loan) -> LoanTransaction | None:
53 try:
54 return loan.transaction
55 except LoanTransaction.DoesNotExist:
56 return None
59@router.get("/{guid}/fees", response=list[FeeOut], auth=requires("LOAN_VIEW"))
60def list_fees(request, guid: UUID):
61 loan = _scoped_loan(request, guid)
62 return list(loan.fees.all())
65@router.put("/{guid}/fees", response=list[FeeOut], auth=requires("LOAN_EDIT"))
66def set_fees(request, guid: UUID, payload: FeeSetIn):
67 loan = _scoped_loan(request, guid)
68 if loan_is_disclosed(loan):
69 raise DomainError(
70 [AppError(
71 "COC_REQUIRED",
72 "This loan is disclosed — fee changes require a Change of Circumstance.",
73 )]
74 )
75 return replace_fees(loan, payload.fees)
78@router.post("/{guid}/fees", response={201: FeeOut}, auth=requires("LOAN_EDIT"))
79def add_fee(request, guid: UUID, payload: FeeIn):
80 loan = _scoped_loan(request, guid)
81 if loan_is_disclosed(loan): 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true
82 raise DomainError(
83 [AppError(
84 "COC_REQUIRED",
85 "This loan is disclosed — fee changes require a Change of Circumstance.",
86 )]
87 )
88 fee = Fee.objects.create(loan=loan, **payload.model_dump())
89 return 201, fee
92@router.get("/{guid}/fees/summary", response=FeeSummaryOut, auth=requires("LOAN_VIEW"))
93def fee_summary(request, guid: UUID):
94 loan = _scoped_loan(request, guid)
95 by_bucket: dict[str, Decimal] = {}
96 by_section: dict[str, Decimal] = {}
97 grand = ZERO
98 poc_total = ZERO
99 for f in loan.fees.all():
100 amt = f.total_amt or ZERO
101 if f.poc_ind:
102 poc_total += amt
103 continue
104 bucket = bucket_of(f).value
105 by_bucket[bucket] = by_bucket.get(bucket, ZERO) + amt
106 by_section[f.section] = by_section.get(f.section, ZERO) + amt
107 grand += amt
108 return {
109 "by_bucket": [{"bucket": b, "total": round2(t)} for b, t in by_bucket.items()],
110 "by_section": [{"section": s, "total": round2(t)} for s, t in by_section.items()],
111 "grand_total": round2(grand),
112 "poc_total": round2(poc_total),
113 }
116@router.post("/{guid}/fees/tolerance", response=ToleranceOut, auth=requires("LOAN_VIEW"))
117def fee_tolerance(request, guid: UUID, payload: FeeSetIn):
118 loan = _scoped_loan(request, guid)
119 baseline = [
120 FeeInput(
121 section=f.section,
122 total_amt=f.total_amt,
123 tolerance_bucket=f.tolerance_bucket,
124 poc_ind=f.poc_ind,
125 name=f.name,
126 paid_by=f.paid_by,
127 paid_to=f.paid_to,
128 )
129 for f in payload.fees
130 ]
131 result = compute_tolerance_cure(baseline, list(loan.fees.all()))
132 return {
133 "by_bucket": [
134 {
135 "bucket": b.value,
136 "baseline": result.by_bucket[b].baseline,
137 "revised": result.by_bucket[b].revised,
138 "variance": result.by_bucket[b].variance,
139 "cure": result.by_bucket[b].cure,
140 }
141 for b in _BUCKET_ORDER
142 ],
143 "total_cure": result.total_cure,
144 }
147@router.get("/{guid}/fees/qm", response=QmResultOut, auth=requires("LOAN_VIEW"))
148def fee_qm(
149 request,
150 guid: UUID,
151 loan_amount: Decimal | None = None,
152 points_and_fees: Decimal | None = None,
153 apr: Decimal | None = None,
154 apor: Decimal | None = None,
155):
156 loan = _scoped_loan(request, guid)
157 txn = _txn(loan)
158 amt = loan_amount if loan_amount is not None else (txn.base_loan_amt if txn else ZERO)
160 if points_and_fees is not None:
161 pf = points_and_fees
162 source = "supplied"
163 else:
164 pf = sum(
165 (
166 (f.total_amt or ZERO)
167 for f in loan.fees.all()
168 if not f.poc_ind and f.paid_by == PaidByType.BORROWER.value
169 ),
170 ZERO,
171 )
172 source = "auto: borrower-paid non-POC fees"
174 result = qm_points_and_fees_test(amt, pf, apr, apor)
175 return {
176 "loan_amount": result.loan_amount,
177 "points_and_fees": result.points_and_fees,
178 "threshold": result.threshold,
179 "pct": result.pct,
180 "passed": result.passed,
181 "qm_type": result.qm_type.value,
182 "apr_spread": result.apr_spread,
183 "findings": result.findings,
184 "points_and_fees_source": source,
185 }
188# NOTE: /{guid}/fees/{fee_id} is registered AFTER literal routes like /summary and /tolerance
189@router.delete("/{guid}/fees/{fee_id}", response={204: None}, auth=requires("LOAN_EDIT"))
190def delete_fee(request, guid: UUID, fee_id: int):
191 loan = _scoped_loan(request, guid)
192 if loan_is_disclosed(loan): 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 raise DomainError(
194 [AppError(
195 "COC_REQUIRED",
196 "This loan is disclosed — fee changes require a Change of Circumstance.",
197 )]
198 )
199 fee = loan.fees.filter(pk=fee_id).first()
200 if fee is None: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 raise DomainError([AppError("NOT_FOUND", "Fee not found on this loan")])
202 fee.delete()
203 return 204, None