Coverage for apps/core_los/api.py: 94%
301 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 aggregate CRUD router (mounted at /api/loans).
3Every endpoint is permission-gated via ``requires(...)`` and org-scoped via the
4auth scope helpers.
5"""
7from __future__ import annotations
9from decimal import Decimal
10from uuid import UUID
12from django.core.exceptions import FieldDoesNotExist
13from django.db import models
14from django.db import transaction as db_transaction
15from django.db.models import Count, Prefetch, Q
16from django.utils import timezone
17from ninja import Router
19from apps.accounts.auth import ensure_staff_scope, requires, staff_loan_queryset
20from apps.accounts.models import License, User
21from apps.application_1003.models import Borrower, Residence
22from apps.core_los.models import (
23 BusinessContact,
24 Loan,
25 LoanStageHistory,
26 LoanTransaction,
27 PreApproval,
28 next_loan_number,
29)
30from apps.core_los.reports_api import _pass_through_fees
31from apps.core_los.schemas import (
32 _BASIS_FROM_CONTRACT,
33 ActivityItemOut,
34 BorrowerCreate,
35 BorrowerOut,
36 BorrowerPatch,
37 ComplianceOut,
38 LoanDetailOut,
39 LoanFileSummaryOut,
40 LoanHeaderPatch,
41 LoanListOut,
42 LoanSummaryOut,
43 LoanTeamOut,
44 LoanTransactionOut,
45 McrExcludeInput,
46 McrLineOut,
47 NewLoanCreated,
48 NewLoanInput,
49 PreApprovalOut,
50 TeamPatch,
51 TransactionPatch,
52)
53from apps.pricing_ppe.models import RateLock
54from domain.calc import TransactionInput, recalc_transaction
55from domain.conditions import OPEN_CONDITION_STATUSES
56from domain.enums import STATE_NAMES, LoanStage, MaritalStatusType, ResidenceType
57from domain.lifecycle import assert_transition
58from domain.reports import broker_compensation
59from domain.result import AppError, DomainError
60from domain.validation import (
61 BorrowerInput,
62 CreateLoanInput,
63 LicenseInput,
64 LoanOfficerInput,
65 validate_create_loan,
66 validate_licensing,
67 validate_stage_required_fields,
68)
70router = Router(tags=["loans"])
72_CONFLICT = "Loan was modified by another user; refresh and retry"
75def _get_loan(guid: UUID) -> Loan:
76 loan = Loan.objects.filter(guid=guid).first()
77 if loan is None:
78 raise DomainError([AppError("NOT_FOUND", "Loan not found")])
79 return loan
82def _apply_recalc(txn: LoanTransaction) -> None:
83 """Recompute server-owned derived ratios from the pure domain math."""
84 result = recalc_transaction(
85 TransactionInput(
86 base_loan_amt=txn.base_loan_amt,
87 note_amt=txn.note_amt,
88 purchase_price_amt=txn.purchase_price_amt,
89 appraised_value_amt=txn.appraised_value_amt,
90 down_payment_amt=txn.down_payment_amt,
91 )
92 )
93 txn.note_amt = result.note_amt
94 txn.ltv_ratio_percent = result.ltv_ratio_percent
95 txn.cltv_ratio_percent = result.cltv_ratio_percent
96 txn.hcltv_ratio_percent = result.hcltv_ratio_percent
97 if result.down_payment_amt is not None: 97 ↛ exitline 97 didn't return from function '_apply_recalc' because the condition on line 97 was always true
98 txn.down_payment_amt = result.down_payment_amt
101def _transaction(loan: Loan) -> LoanTransaction | None:
102 try:
103 return loan.transaction
104 except LoanTransaction.DoesNotExist:
105 return None
108def _detail(loan: Loan) -> LoanDetailOut:
109 txn = _transaction(loan)
110 return LoanDetailOut( # type: ignore[call-arg] # ninja ModelSchema fields not visible to mypy
111 **LoanSummaryOut.from_orm(loan).model_dump(),
112 transaction=LoanTransactionOut.from_orm(txn) if txn else None,
113 borrowers=[BorrowerOut.from_orm(b) for b in loan.borrowers.all()],
114 )
117def _snapshot(loan: Loan) -> dict:
118 """Plain dict of the loan for the pure validation functions."""
119 txn = _transaction(loan)
120 return {
121 "purpose": loan.purpose,
122 "county": loan.county,
123 "state_code": loan.state_code,
124 "transaction": {
125 "base_loan_amt": txn.base_loan_amt if txn else None,
126 "purchase_price_amt": txn.purchase_price_amt if txn else None,
127 "appraised_value_amt": txn.appraised_value_amt if txn else None,
128 },
129 }
132@router.get("", response=LoanListOut, auth=requires("LOAN_VIEW"))
133def list_loans(request, scope: str = "all"):
134 qs = (
135 staff_loan_queryset(request.principal, scope)
136 .select_related("transaction", "rate_lock", "rate_lock__product")
137 .prefetch_related(
138 Prefetch(
139 "borrowers",
140 queryset=Borrower.objects.filter(is_primary=True),
141 to_attr="_primary_borrowers",
142 )
143 )
144 .annotate(open_task_count=Count("tasks", filter=Q(tasks__completed=False)))
145 .order_by("-created_at")
146 )
147 return {"count": qs.count(), "rows": list(qs)}
150def _loan_officer_input(loan_officer_id: int) -> LoanOfficerInput:
151 licenses = License.objects.filter(user_id=loan_officer_id)
152 return LoanOfficerInput(
153 licenses=[
154 LicenseInput(state_code=lic.state_code, active=lic.active, sponsored=lic.sponsored)
155 for lic in licenses
156 ]
157 )
160def _build_loan(
161 *,
162 loan_officer_id: int,
163 state_code: str,
164 occupancy: str | None,
165 purpose: str,
166 first_name: str | None,
167 last_name: str | None,
168 email: str,
169 additional_lo_id: int | None = None,
170 processor_id: int | None = None,
171 loa_id: int | None = None,
172) -> Loan:
173 """Validate + create a loan, its $0 transaction, and its primary borrower.
175 Shared by ``create_loan`` (``POST /api/loans``) and ``convert_lead``
176 (``POST /api/leads/{id}/convert``) so the ORG-00001/00002 origination rules run
177 exactly once (design.md D1 of add-leads-api). Raises ``DomainError`` on validation
178 failure or an unknown loan officer; callers don't need their own try/except.
179 """
180 officer = User.objects.filter(pk=loan_officer_id).first()
181 if officer is None: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 raise DomainError([AppError("NOT_FOUND", "Loan officer not found")])
184 result = validate_create_loan(
185 CreateLoanInput(
186 purpose=purpose,
187 occupancy=occupancy,
188 state_code=state_code,
189 borrower=BorrowerInput(first_name=first_name, last_name=last_name, email=email or None),
190 ),
191 _loan_officer_input(loan_officer_id),
192 )
193 if not result.ok:
194 raise DomainError(result.errors)
196 with db_transaction.atomic():
197 loan = Loan.objects.create(
198 arive_loan_no=next_loan_number(),
199 org_unit_id=officer.org_unit_id,
200 loan_officer_id=loan_officer_id,
201 additional_lo_id=additional_lo_id,
202 processor_id=processor_id,
203 loa_id=loa_id,
204 purpose=purpose,
205 occupancy=occupancy,
206 state_code=state_code,
207 stage=LoanStage.APPLICATION_INTAKE.value,
208 )
209 LoanTransaction.objects.create(
210 loan=loan,
211 base_loan_amt=Decimal("0.00"),
212 note_amt=Decimal("0.00"),
213 down_payment_amt=Decimal("0.00"),
214 )
215 Borrower.objects.create(
216 loan=loan, is_primary=True, first_name=first_name, last_name=last_name, email=email,
217 )
218 return loan
221@router.post("", response={201: NewLoanCreated}, auth=requires("LOAN_CREATE"))
222def create_loan(request, payload: NewLoanInput):
223 loan = _build_loan(
224 loan_officer_id=payload.loan_officer_id,
225 state_code=payload.state_code,
226 occupancy=payload.occupancy,
227 purpose=payload.purpose,
228 first_name=payload.borrower.first_name,
229 last_name=payload.borrower.last_name,
230 email=payload.borrower.email,
231 additional_lo_id=payload.team.additional_lo_id,
232 processor_id=payload.team.processor_id,
233 loa_id=payload.team.loa_id,
234 )
235 return 201, {"guid": str(loan.guid), "arive_loan_no": loan.arive_loan_no}
238@router.get("/{guid}", response=LoanDetailOut, auth=requires("LOAN_VIEW"))
239def get_loan(request, guid: UUID):
240 loan = _get_loan(guid)
241 ensure_staff_scope(request.principal, loan)
242 return _detail(loan)
245@router.patch("/{guid}", response=LoanDetailOut, auth=requires("LOAN_EDIT"))
246def update_loan(request, guid: UUID, payload: LoanHeaderPatch):
247 loan = _get_loan(guid)
248 ensure_staff_scope(request.principal, loan)
249 data = payload.model_dump(exclude_unset=True)
250 version = data.pop("version")
251 if loan.version != version:
252 raise DomainError([AppError("CONFLICT", _CONFLICT)])
254 new_stage = data.get("stage")
255 if new_stage and new_stage != loan.stage:
256 try:
257 target = LoanStage(new_stage)
258 except ValueError as exc:
259 raise DomainError(
260 [AppError("VALIDATION", f"Unknown stage: {new_stage}", "stage")]
261 ) from exc
262 open_categories = frozenset(
263 loan.conditions.filter(status__in=[s.value for s in OPEN_CONDITION_STATUSES])
264 .values_list("category", flat=True)
265 )
266 transition = assert_transition(LoanStage(loan.stage), target, open_categories)
267 if not transition.ok:
268 raise DomainError(transition.errors)
269 required = validate_stage_required_fields(_snapshot(loan), new_stage)
270 if not required.ok:
271 raise DomainError(required.errors)
273 data["updated_at"] = timezone.now() # QuerySet.update bypasses auto_now
274 with db_transaction.atomic():
275 updated = Loan.objects.filter(guid=guid, version=version).update(
276 version=version + 1, **data
277 )
278 if updated == 0: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 raise DomainError([AppError("CONFLICT", _CONFLICT)])
280 if new_stage and new_stage != loan.stage:
281 LoanStageHistory.objects.create(loan=loan, stage=new_stage)
282 return _detail(Loan.objects.get(guid=guid))
285def _team_member(user: User | None) -> dict | None:
286 if user is None:
287 return None
288 return {"id": user.id, "name": f"{user.first_name} {user.last_name}".strip()}
291def _agent_out(contact: BusinessContact | None) -> dict | None:
292 if contact is None: 292 ↛ 294line 292 didn't jump to line 294 because the condition on line 292 was always true
293 return None
294 return {"id": contact.id, "name": contact.name, "company": contact.company}
297def _team_detail(loan: Loan) -> dict:
298 return {
299 "loan_officer": _team_member(loan.loan_officer),
300 "additional_lo": _team_member(loan.additional_lo),
301 "processor": _team_member(loan.processor),
302 "assistant_processor": _team_member(loan.assistant_processor),
303 "loa": _team_member(loan.loa),
304 "agent": _agent_out(loan.agent_contact),
305 }
308_TEAM_SELECT_RELATED = (
309 "loan_officer", "additional_lo", "processor", "assistant_processor", "loa", "agent_contact",
310)
313@router.get("/{guid}/team", response=LoanTeamOut, auth=requires("LOAN_VIEW"))
314def get_team(request, guid: UUID):
315 loan = _get_loan(guid)
316 ensure_staff_scope(request.principal, loan)
317 loan = Loan.objects.select_related(*_TEAM_SELECT_RELATED).get(guid=guid)
318 return _team_detail(loan)
321@router.put("/{guid}/team", response=LoanTeamOut, auth=requires("LOAN_EDIT"))
322def update_team(request, guid: UUID, payload: TeamPatch):
323 loan = _get_loan(guid)
324 ensure_staff_scope(request.principal, loan)
325 data = payload.model_dump(exclude_unset=True)
327 if "processor_id" in data and not request.principal.has("MODIFY_LOAN_PROCESSOR"):
328 raise DomainError(
329 [AppError("FORBIDDEN", "You don't have permission to change the processor.")]
330 )
331 if "loan_officer_id" in data and data["loan_officer_id"] is None:
332 raise DomainError(
333 [AppError("VALIDATION", "A loan officer is required.", "loan_officer_id")]
334 )
336 user_fields = [
337 "loan_officer_id", "additional_lo_id", "processor_id", "assistant_processor_id", "loa_id",
338 ]
339 for field in user_fields:
340 value = data.get(field)
341 if field in data and value is not None and not User.objects.filter(pk=value).exists():
342 raise DomainError([AppError("VALIDATION", f"Unknown user {value}.", field)])
343 if "agent_contact_id" in data:
344 value = data["agent_contact_id"]
345 if value is not None and not BusinessContact.objects.filter(pk=value).exists(): 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true
346 raise DomainError(
347 [AppError("VALIDATION", f"Unknown contact {value}.", "agent_contact_id")]
348 )
350 new_lo_id = data.get("loan_officer_id")
351 if new_lo_id is not None and new_lo_id != loan.loan_officer_id:
352 new_officer = User.objects.get(pk=new_lo_id)
353 licensing = validate_licensing(
354 _loan_officer_input(new_lo_id), loan.state_code, loan.occupancy
355 )
356 if not licensing.ok:
357 raise DomainError(licensing.errors)
358 data["org_unit_id"] = new_officer.org_unit_id
360 with db_transaction.atomic():
361 Loan.objects.filter(guid=guid).update(**data)
363 return _team_detail(Loan.objects.select_related(*_TEAM_SELECT_RELATED).get(guid=guid))
366@router.patch("/{guid}/transaction", response=LoanTransactionOut, auth=requires("LOAN_EDIT"))
367def update_transaction(request, guid: UUID, payload: TransactionPatch):
368 loan = _get_loan(guid)
369 ensure_staff_scope(request.principal, loan)
370 with db_transaction.atomic():
371 txn, _ = LoanTransaction.objects.get_or_create(
372 loan=loan,
373 defaults={"base_loan_amt": Decimal("0.00"), "note_amt": Decimal("0.00")},
374 )
375 for field, value in payload.model_dump(exclude_unset=True).items():
376 setattr(txn, field, value)
377 _apply_recalc(txn)
378 txn.save()
379 return txn
382@router.get("/{guid}/borrowers", response=list[BorrowerOut], auth=requires("LOAN_VIEW"))
383def list_borrowers(request, guid: UUID):
384 loan = _get_loan(guid)
385 ensure_staff_scope(request.principal, loan)
386 return list(loan.borrowers.all())
389@router.post("/{guid}/borrowers", response={201: BorrowerOut}, auth=requires("LOAN_EDIT"))
390def add_borrower(request, guid: UUID, payload: BorrowerCreate):
391 loan = _get_loan(guid)
392 ensure_staff_scope(request.principal, loan)
393 borrower = Borrower.objects.create(loan=loan, **payload.model_dump())
394 return 201, borrower
397_RESIDENCE_PATCH_FIELDS = {
398 "current_address_line": "address_line",
399 "current_city": "city",
400 "current_state_code": "state_code",
401 "current_postal_code": "postal_code",
402}
403_MARITAL_STATUS_VALUES = {m.value for m in MaritalStatusType}
406def _coerce_required_text_nulls(model: type[models.Model], data: dict) -> dict:
407 """A PATCH schema types every text field `str | None` so a field can be omitted, but a
408 CharField/TextField column without `null=True` rejects an explicit `null` the client
409 sends to clear it (Django convention: text columns use blank='', never NULL). Treat
410 `null` for one of those the same as "clear this field" instead of letting it reach the
411 DB and raise an IntegrityError."""
412 for field, value in data.items():
413 if value is None:
414 try:
415 f = model._meta.get_field(field)
416 except FieldDoesNotExist:
417 continue
418 if isinstance(f, (models.CharField, models.TextField)) and not f.null: 418 ↛ 412line 418 didn't jump to line 412 because the condition on line 418 was always true
419 data[field] = ""
420 return data
423@router.patch("/{guid}/borrowers/{borrower_id}", response=BorrowerOut, auth=requires("LOAN_EDIT"))
424def update_borrower(request, guid: UUID, borrower_id: int, payload: BorrowerPatch):
425 loan = _get_loan(guid)
426 ensure_staff_scope(request.principal, loan)
427 borrower = Borrower.objects.filter(pk=borrower_id, loan=loan).first()
428 if borrower is None:
429 raise DomainError([AppError("NOT_FOUND", "Borrower not found on this loan")])
431 data = payload.model_dump(exclude_unset=True)
432 if ("first_name" in data and not data["first_name"]) or (
433 "last_name" in data and not data["last_name"]
434 ):
435 raise DomainError([AppError("VALIDATION", "First and last name are required")])
436 if data.get("marital_status") not in (None, "", *_MARITAL_STATUS_VALUES):
437 raise DomainError([AppError("VALIDATION", "Unknown marital_status", "marital_status")])
439 if "ssn" in data:
440 raw_ssn = data.pop("ssn")
441 if raw_ssn: 441 ↛ 447line 441 didn't jump to line 447 because the condition on line 441 was always true
442 from apps.integrations.encryption import encrypt_secret
444 clean = "".join(ch for ch in str(raw_ssn) if ch.isdigit())
445 borrower.ssn_encrypted = encrypt_secret(clean)
446 else:
447 borrower.ssn_encrypted = None
449 residence_data = {}
450 for contract_field, model_field in _RESIDENCE_PATCH_FIELDS.items():
451 if contract_field in data:
452 residence_data[model_field] = data.pop(contract_field)
453 if "current_residency_basis" in data:
454 basis = data.pop("current_residency_basis")
455 residence_data["basis_type"] = _BASIS_FROM_CONTRACT.get(basis, basis) if basis else ""
456 if "years_at_current_address" in data:
457 years = data.pop("years_at_current_address")
458 residence_data["duration_months"] = years * 12 if years is not None else None
460 if residence_data:
461 residence, _ = Residence.objects.get_or_create(
462 borrower=borrower, residence_type=ResidenceType.CURRENT.value
463 )
464 for field, value in _coerce_required_text_nulls(Residence, residence_data).items():
465 setattr(residence, field, value)
466 residence.save()
468 for field, value in _coerce_required_text_nulls(Borrower, data).items():
469 setattr(borrower, field, value)
470 borrower.save()
471 return borrower
474def _active_rate_lock(loan: Loan):
475 """The loan's rate lock, only when it's actually in force (same rule as the
476 pipeline row's `lock_status` — `apps/core_los/schemas.py`)."""
477 try:
478 lock = loan.rate_lock
479 except RateLock.DoesNotExist:
480 return None
481 if lock.status in ("LOCKED", "RELOCKED") and lock.expires_at > timezone.now(): 481 ↛ 483line 481 didn't jump to line 483 because the condition on line 481 was always true
482 return lock
483 return None
486@router.get("/{guid}/summary", response=LoanFileSummaryOut, auth=requires("LOAN_VIEW"))
487def get_loan_summary(request, guid: UUID):
488 loan = _get_loan(guid)
489 ensure_staff_scope(request.principal, loan)
490 txn = _transaction(loan)
491 primary = loan.borrowers.filter(is_primary=True).first()
493 value = Decimal("0.00")
494 if txn and txn.appraised_value_amt is not None: 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
495 value = txn.appraised_value_amt
496 elif txn and txn.purchase_price_amt is not None:
497 value = txn.purchase_price_amt
499 lock = _active_rate_lock(loan)
500 ltv = Decimal("0.00")
501 if txn and txn.ltv_ratio_percent is not None: 501 ↛ 503line 501 didn't jump to line 503 because the condition on line 501 was always true
502 ltv = txn.ltv_ratio_percent
503 return {
504 "borrower_name": f"{primary.first_name} {primary.last_name}" if primary else "—",
505 "amount": txn.base_loan_amt if txn else Decimal("0.00"),
506 "value": value,
507 "ltv": ltv,
508 "rate": txn.note_rate_percent if txn else None,
509 "product": lock.product.name if lock else None,
510 "lock": (
511 {
512 "product": lock.product.name,
513 "rate": lock.locked_rate,
514 "expires_at": lock.expires_at.isoformat(),
515 }
516 if lock
517 else None
518 ),
519 "open_tasks": loan.tasks.filter(completed=False).count(),
520 "conditions": loan.conditions.count(),
521 }
524def _compliance_issue(loan: Loan) -> dict | None:
525 # `Loan.loan_officer` is a required FK, so this branch is defensive/unreachable
526 # with today's schema; kept for the documented `LO_MISSING` contract code.
527 officer = loan.loan_officer
528 if officer is None: 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true
529 return {"severity": "fatal", "code": "LO_MISSING", "message": "No loan officer assigned."}
530 lic = License.objects.filter(user_id=officer.id, state_code=loan.state_code).first()
531 if lic is None:
532 return {
533 "severity": "fatal",
534 "code": "LO_UNLICENSED",
535 "message": f"{officer.first_name} {officer.last_name} holds no license in "
536 f"{loan.state_code}.",
537 }
538 if not lic.active:
539 return {
540 "severity": "fatal",
541 "code": "LO_INACTIVE",
542 "message": f"{officer.first_name} {officer.last_name}'s {loan.state_code} license "
543 "is not active.",
544 }
545 if not lic.sponsored:
546 return {
547 "severity": "fatal",
548 "code": "LO_UNSPONSORED",
549 "message": f"{officer.first_name} {officer.last_name}'s {loan.state_code} license "
550 "is not sponsored.",
551 }
552 return None
555@router.get("/{guid}/compliance", response=ComplianceOut, auth=requires("LOAN_VIEW"))
556def get_loan_compliance(request, guid: UUID):
557 loan = _get_loan(guid)
558 ensure_staff_scope(request.principal, loan)
559 officer = loan.loan_officer
560 issue = _compliance_issue(loan)
561 return {
562 "state_code": loan.state_code,
563 "state_name": STATE_NAMES.get(loan.state_code, loan.state_code),
564 "compliant": issue is None,
565 "loan_officer": {
566 "id": officer.id if officer else None,
567 "name": f"{officer.first_name} {officer.last_name}" if officer else "",
568 "issues": [issue] if issue else [],
569 },
570 }
573@router.post(
574 "/{guid}/preapproval", response={201: PreApprovalOut}, auth=requires("LOAN_EDIT")
575)
576def issue_preapproval(request, guid: UUID):
577 loan = _get_loan(guid)
578 ensure_staff_scope(request.principal, loan)
579 if loan.stage == LoanStage.APPLICATION_INTAKE.value:
580 raise DomainError(
581 [AppError("PREAPPROVAL_STAGE", "No pre-approval letter while in App Intake")]
582 )
583 return 201, PreApproval.objects.create(loan=loan)
586@router.get(
587 "/{guid}/activity", response=list[ActivityItemOut], auth=requires("LOAN_VIEW")
588)
589def get_loan_activity(request, guid: UUID):
590 loan = _get_loan(guid)
591 ensure_staff_scope(request.principal, loan)
593 items = [
594 {"at": h.entered_at.isoformat(), "type": "stage", "label": f"Entered {h.stage}"}
595 for h in loan.stage_history.all()
596 ]
597 lock = _active_rate_lock(loan)
598 if lock:
599 items.append(
600 {
601 "at": lock.locked_at.isoformat(),
602 "type": "lock",
603 "label": f"Locked {lock.product.name} @ {lock.locked_rate}%",
604 }
605 )
606 items.extend(
607 {"at": p.generated_at.isoformat(), "type": "disc", "label": "Pre-approval letter issued"}
608 for p in loan.preapprovals.all()
609 )
610 items.sort(key=lambda item: item["at"], reverse=True)
611 return items[:50]
614def _mcr_line(loan: Loan) -> dict:
615 txn = _transaction(loan)
616 primary = loan.borrowers.filter(is_primary=True).first()
617 amt = txn.base_loan_amt if txn else Decimal("0.00")
618 return {
619 "loan_guid": str(loan.guid),
620 "loan_number": loan.arive_loan_no,
621 "borrower_name": f"{primary.first_name} {primary.last_name}" if primary else "—",
622 "mcr_application_date": loan.created_at.isoformat(),
623 "state": loan.state_code,
624 "loan_purpose": loan.purpose,
625 "loan_amount": amt,
626 "rate": txn.note_rate_percent if txn else None,
627 "exclude_from_mcr": loan.exclude_from_mcr,
628 "revenue": {
629 "broker_compensation_bps": 200,
630 "broker_compensation_amt": broker_compensation(amt),
631 "pass_through_fees": _pass_through_fees(loan),
632 "tolerance_cure": Decimal("0.00"),
633 },
634 }
637@router.get("/{guid}/mcr", response=McrLineOut, auth=requires("LOAN_VIEW"))
638def get_mcr_line(request, guid: UUID):
639 loan = _get_loan(guid)
640 ensure_staff_scope(request.principal, loan)
641 return _mcr_line(loan)
644@router.patch(
645 "/{guid}/mcr-exclude", response=McrLineOut, auth=requires("MANAGE_MCR_STATE_REPORTS")
646)
647def set_mcr_exclude(request, guid: UUID, payload: McrExcludeInput):
648 loan = _get_loan(guid)
649 ensure_staff_scope(request.principal, loan)
650 loan.exclude_from_mcr = payload.exclude
651 loan.save(update_fields=["exclude_from_mcr"])
652 return _mcr_line(loan)