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

1"""Loan aggregate CRUD router (mounted at /api/loans). 

2 

3Every endpoint is permission-gated via ``requires(...)`` and org-scoped via the 

4auth scope helpers. 

5""" 

6 

7from __future__ import annotations 

8 

9from decimal import Decimal 

10from uuid import UUID 

11 

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 

18 

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) 

69 

70router = Router(tags=["loans"]) 

71 

72_CONFLICT = "Loan was modified by another user; refresh and retry" 

73 

74 

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 

80 

81 

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 

99 

100 

101def _transaction(loan: Loan) -> LoanTransaction | None: 

102 try: 

103 return loan.transaction 

104 except LoanTransaction.DoesNotExist: 

105 return None 

106 

107 

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 ) 

115 

116 

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 } 

130 

131 

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)} 

148 

149 

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 ) 

158 

159 

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. 

174 

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")]) 

183 

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) 

195 

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 

219 

220 

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} 

236 

237 

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) 

243 

244 

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)]) 

253 

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) 

272 

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)) 

283 

284 

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()} 

289 

290 

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} 

295 

296 

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 } 

306 

307 

308_TEAM_SELECT_RELATED = ( 

309 "loan_officer", "additional_lo", "processor", "assistant_processor", "loa", "agent_contact", 

310) 

311 

312 

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) 

319 

320 

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) 

326 

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 ) 

335 

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 ) 

349 

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 

359 

360 with db_transaction.atomic(): 

361 Loan.objects.filter(guid=guid).update(**data) 

362 

363 return _team_detail(Loan.objects.select_related(*_TEAM_SELECT_RELATED).get(guid=guid)) 

364 

365 

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 

380 

381 

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()) 

387 

388 

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 

395 

396 

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} 

404 

405 

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 

421 

422 

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")]) 

430 

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")]) 

438 

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 

443 

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 

448 

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 

459 

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() 

467 

468 for field, value in _coerce_required_text_nulls(Borrower, data).items(): 

469 setattr(borrower, field, value) 

470 borrower.save() 

471 return borrower 

472 

473 

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 

484 

485 

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() 

492 

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 

498 

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 } 

522 

523 

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 

553 

554 

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 } 

571 

572 

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) 

584 

585 

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) 

592 

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] 

612 

613 

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 } 

635 

636 

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) 

642 

643 

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)