Coverage for apps/core_los/schemas.py: 86%

506 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 14:06 +0000

1"""Django Ninja schemas for the loan aggregate (header, transaction, borrowers).""" 

2 

3from __future__ import annotations 

4 

5import re 

6from datetime import date 

7from decimal import Decimal 

8 

9from django.utils import timezone 

10from ninja import ModelSchema, Schema 

11from pydantic import Field, field_validator 

12 

13from apps.application_1003.models import Borrower, Residence 

14from apps.core_los.models import ( 

15 BusinessContact, 

16 Lead, 

17 Loan, 

18 LoanTransaction, 

19 PreApproval, 

20 SavedView, 

21) 

22from apps.pricing_ppe.models import RateLock 

23from domain.enums import LoanPurpose, OccupancyType, ResidenceType 

24from domain.state_compliance import STATE_COMPLIANCE 

25 

26VALID_STATE_CODES = frozenset(r["state_code"] for r in STATE_COMPLIANCE) 

27EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$") 

28ZIP_RE = re.compile(r"^\d{5}(-\d{4})?$") 

29 

30# Backend `ResidenceBasisType` and the frontend `ResidencyBasis` contract name the 

31# same real-world concept differently; translate at this boundary (design.md). 

32_BASIS_TO_CONTRACT = {"Own": "Own", "Rent": "Rent", "LivingRentFree": "NoPrimaryExpense"} 

33_BASIS_FROM_CONTRACT = {v: k for k, v in _BASIS_TO_CONTRACT.items()} 

34 

35 

36def _safe_transaction(loan: Loan) -> LoanTransaction | None: 

37 try: 

38 return loan.transaction 

39 except LoanTransaction.DoesNotExist: 

40 return None 

41 

42 

43def _safe_rate_lock(loan: Loan) -> RateLock | None: 

44 try: 

45 return loan.rate_lock 

46 except RateLock.DoesNotExist: 

47 return None 

48 

49 

50def _current_residence(borrower: Borrower) -> Residence | None: 

51 return borrower.residences.filter(residence_type=ResidenceType.CURRENT.value).first() 

52 

53 

54# --- Response schemas ---------------------------------------------------------- 

55class LoanSummaryOut(ModelSchema): 

56 class Meta: 

57 model = Loan 

58 fields = [ 

59 "guid", 

60 "arive_loan_no", 

61 "stage", 

62 "purpose", 

63 "occupancy", 

64 "state_code", 

65 "county", 

66 "credit_pulled", 

67 "version", 

68 "created_at", 

69 "updated_at", 

70 ] 

71 

72 

73class LoanTransactionOut(ModelSchema): 

74 class Meta: 

75 model = LoanTransaction 

76 exclude = ["loan"] 

77 

78 

79class BorrowerOut(ModelSchema): 

80 # No SSN value is stored anywhere in this backend; always null (add-loan-file-api). 

81 ssn: str | None = None 

82 current_address_line: str | None = None 

83 current_city: str | None = None 

84 current_state_code: str | None = None 

85 current_postal_code: str | None = None 

86 current_residency_basis: str | None = None 

87 years_at_current_address: int | None = None 

88 

89 class Meta: 

90 model = Borrower 

91 exclude = ["ssn_encrypted", "loan"] 

92 

93 @staticmethod 

94 def resolve_ssn(obj: Borrower) -> str | None: 

95 if not getattr(obj, "ssn_encrypted", None): 

96 return None 

97 from apps.integrations.encryption import decrypt_secret 

98 

99 dec = decrypt_secret(obj.ssn_encrypted) 

100 if dec and len(dec) >= 4: 100 ↛ 102line 100 didn't jump to line 102 because the condition on line 100 was always true

101 return f"***-**-{dec[-4:]}" 

102 return "***-**-****" 

103 

104 @staticmethod 

105 def resolve_current_address_line(obj: Borrower) -> str | None: 

106 res = _current_residence(obj) 

107 return (res.address_line or None) if res else None 

108 

109 @staticmethod 

110 def resolve_current_city(obj: Borrower) -> str | None: 

111 res = _current_residence(obj) 

112 return (res.city or None) if res else None 

113 

114 @staticmethod 

115 def resolve_current_state_code(obj: Borrower) -> str | None: 

116 res = _current_residence(obj) 

117 return (res.state_code or None) if res else None 

118 

119 @staticmethod 

120 def resolve_current_postal_code(obj: Borrower) -> str | None: 

121 res = _current_residence(obj) 

122 return (res.postal_code or None) if res else None 

123 

124 @staticmethod 

125 def resolve_current_residency_basis(obj: Borrower) -> str | None: 

126 res = _current_residence(obj) 

127 if res is None or not res.basis_type: 

128 return None 

129 return _BASIS_TO_CONTRACT.get(res.basis_type, res.basis_type) 

130 

131 @staticmethod 

132 def resolve_years_at_current_address(obj: Borrower) -> int | None: 

133 res = _current_residence(obj) 

134 if res is None or res.duration_months is None: 

135 return None 

136 return res.duration_months // 12 

137 

138 

139class LoanDetailOut(LoanSummaryOut): 

140 transaction: LoanTransactionOut | None = None 

141 borrowers: list[BorrowerOut] = [] 

142 

143 

144class PipelineRowOut(LoanSummaryOut): 

145 """A loan summary plus the pipeline screen's working columns. 

146 

147 Resolvers read off relations the list queryset already fetched via 

148 ``select_related``/``prefetch_related``/``annotate`` — no per-row query. 

149 """ 

150 

151 borrower_name: str 

152 loan_amount: Decimal 

153 ltv_percent: Decimal 

154 note_rate_percent: Decimal | None = None 

155 product_name: str | None = None 

156 lender_name: str | None = None 

157 lock_status: str 

158 open_task_count: int 

159 

160 @staticmethod 

161 def resolve_borrower_name(obj: Loan) -> str: 

162 primary = next(iter(getattr(obj, "_primary_borrowers", [])), None) 

163 return f"{primary.first_name} {primary.last_name}" if primary else "—" 

164 

165 @staticmethod 

166 def resolve_loan_amount(obj: Loan) -> Decimal: 

167 txn = _safe_transaction(obj) 

168 return txn.base_loan_amt if txn else Decimal("0.00") 

169 

170 @staticmethod 

171 def resolve_ltv_percent(obj: Loan) -> Decimal: 

172 txn = _safe_transaction(obj) 

173 if txn and txn.ltv_ratio_percent is not None: 

174 return txn.ltv_ratio_percent 

175 return Decimal("0.00") 

176 

177 @staticmethod 

178 def resolve_note_rate_percent(obj: Loan) -> Decimal | None: 

179 txn = _safe_transaction(obj) 

180 return txn.note_rate_percent if txn else None 

181 

182 @staticmethod 

183 def resolve_product_name(obj: Loan) -> str | None: 

184 lock = _safe_rate_lock(obj) 

185 return lock.product.name if lock else None 

186 

187 @staticmethod 

188 def resolve_lender_name(obj: Loan) -> str | None: 

189 lock = _safe_rate_lock(obj) 

190 return lock.product.investor if lock else None 

191 

192 @staticmethod 

193 def resolve_lock_status(obj: Loan) -> str: 

194 lock = _safe_rate_lock(obj) 

195 active = lock and lock.status in ("LOCKED", "RELOCKED") and lock.expires_at > timezone.now() 

196 return "Locked" if active else "NotLocked" 

197 

198 @staticmethod 

199 def resolve_open_task_count(obj: Loan) -> int: 

200 return getattr(obj, "open_task_count", 0) 

201 

202 

203class LoanListOut(Schema): 

204 count: int 

205 rows: list[PipelineRowOut] 

206 

207 

208# --- Request schemas ----------------------------------------------------------- 

209class NewLoanBorrower(Schema): 

210 first_name: str | None = None 

211 last_name: str | None = None 

212 email: str = "" 

213 

214 

215class NewLoanTeam(Schema): 

216 additional_lo_id: int | None = None 

217 processor_id: int | None = None 

218 loa_id: int | None = None 

219 

220 

221class NewLoanInput(Schema): 

222 loan_officer_id: int 

223 # Optional (not required) so a request omitting either reaches validate_create_loan 

224 # and gets the ORG-00001-coded error, rather than being intercepted earlier by 

225 # Ninja's own required-field validation (wire-origination-rules design.md D2). 

226 state_code: str = "" 

227 occupancy: OccupancyType | None = None 

228 purpose: LoanPurpose 

229 borrower: NewLoanBorrower 

230 team: NewLoanTeam = NewLoanTeam() 

231 

232 

233class NewLoanCreated(Schema): 

234 guid: str 

235 arive_loan_no: str 

236 

237 

238class LoanHeaderPatch(Schema): 

239 version: int # required: optimistic concurrency token 

240 stage: str | None = None 

241 purpose: LoanPurpose | None = None 

242 occupancy: OccupancyType | None = None 

243 state_code: str | None = None 

244 county: str | None = None 

245 credit_pulled: bool | None = None 

246 # Loan-team assignments (pipeline "me" scope); None clears the role. 

247 additional_lo_id: int | None = None 

248 processor_id: int | None = None 

249 loa_id: int | None = None 

250 

251 @field_validator("state_code") 

252 @classmethod 

253 def validate_state_code(cls, v: str | None) -> str | None: 

254 if v is None or not v.strip(): 

255 return None 

256 code = v.strip().upper() 

257 if code not in VALID_STATE_CODES: 

258 raise ValueError(f"Invalid US state code: '{code}'") 

259 return code 

260 

261 

262class TeamMemberOut(Schema): 

263 id: int 

264 name: str 

265 

266 

267class AgentOut(Schema): 

268 id: int 

269 name: str 

270 company: str | None = None 

271 

272 

273class LoanTeamOut(Schema): 

274 loan_officer: TeamMemberOut 

275 additional_lo: TeamMemberOut | None = None 

276 processor: TeamMemberOut | None = None 

277 assistant_processor: TeamMemberOut | None = None 

278 loa: TeamMemberOut | None = None 

279 agent: AgentOut | None = None 

280 

281 

282class TeamPatch(Schema): 

283 loan_officer_id: int | None = None 

284 additional_lo_id: int | None = None 

285 processor_id: int | None = None 

286 assistant_processor_id: int | None = None 

287 loa_id: int | None = None 

288 agent_contact_id: int | None = None 

289 

290 

291class TransactionPatch(Schema): 

292 base_loan_amt: Decimal | None = None 

293 note_amt: Decimal | None = None 

294 purchase_price_amt: Decimal | None = None 

295 appraised_value_amt: Decimal | None = None 

296 note_rate_percent: Decimal | None = None 

297 term_months: int | None = None 

298 mortgage_type: str | None = None 

299 amortization_type: str | None = None 

300 # AUS/MISMO blocker inputs (details/08) 

301 lien_priority_type: str | None = None 

302 total_subordinate_financing_amt: Decimal | None = None 

303 refinance_cash_out_type: str | None = None 

304 refinance_cash_out_amt: Decimal | None = None 

305 # Derived ratios (LTV/CLTV/HCLTV, down payment) are server-owned and ignored here. 

306 

307 @field_validator( 

308 "base_loan_amt", 

309 "note_amt", 

310 "purchase_price_amt", 

311 "appraised_value_amt", 

312 "total_subordinate_financing_amt", 

313 "refinance_cash_out_amt", 

314 check_fields=False, 

315 ) 

316 @classmethod 

317 def validate_amounts(cls, v: Decimal | None) -> Decimal | None: 

318 if v is not None: 318 ↛ 323line 318 didn't jump to line 323 because the condition on line 318 was always true

319 if v < Decimal("0.00"): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 raise ValueError("Amount cannot be negative") 

321 if v > Decimal("999999999.99"): 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 raise ValueError("Amount cannot exceed $999,999,999.99") 

323 return v 

324 

325 @field_validator("note_rate_percent", check_fields=False) 

326 @classmethod 

327 def validate_rate(cls, v: Decimal | None) -> Decimal | None: 

328 if v is not None: 328 ↛ 331line 328 didn't jump to line 331 because the condition on line 328 was always true

329 if v < Decimal("0.000") or v > Decimal("30.000"): 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true

330 raise ValueError("Note rate percent must be between 0% and 30%") 

331 return v 

332 

333 @field_validator("term_months", check_fields=False) 

334 @classmethod 

335 def validate_term(cls, v: int | None) -> int | None: 

336 if v is not None and not (1 <= v <= 480): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 raise ValueError("Term must be between 1 and 480 months") 

338 return v 

339 

340 

341class BorrowerCreate(Schema): 

342 first_name: str 

343 last_name: str 

344 email: str 

345 middle_name: str = "" 

346 suffix: str = "" 

347 is_primary: bool = False 

348 cell_phone: str = "" 

349 home_phone: str = "" 

350 work_phone: str = "" 

351 marital_status: str = "" 

352 dependent_count: int | None = None 

353 dependent_ages: str | None = None 

354 credit_auth_ind: bool = False 

355 birth_date: date | None = None 

356 # AUS/MISMO blocker fields (details/08) 

357 itin_ssn_ind: bool = False 

358 citizen_residency_type: str = "" 

359 role: str = "Borrower" 

360 classification: str = "Primary" 

361 

362 @field_validator("first_name", "last_name") 

363 @classmethod 

364 def validate_names(cls, v: str) -> str: 

365 s = v.strip() if v else "" 

366 if not s: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true

367 raise ValueError("Name cannot be blank.") 

368 if len(s) > 100: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true

369 raise ValueError("Name cannot exceed 100 characters.") 

370 return s 

371 

372 @field_validator("email") 

373 @classmethod 

374 def validate_email(cls, v: str) -> str: 

375 clean = v.strip() if v else "" 

376 if not clean or not EMAIL_RE.match(clean): 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 raise ValueError(f"Invalid email address: '{clean}'") 

378 return clean 

379 

380 @field_validator("cell_phone", "home_phone", "work_phone") 

381 @classmethod 

382 def validate_phone(cls, v: str | None) -> str: 

383 if not v or not v.strip(): 

384 return "" 

385 d = "".join(ch for ch in v if ch.isdigit()) 

386 if len(d) != 10: 

387 raise ValueError(f"Phone number must contain exactly 10 digits (got {len(d)})") 

388 return v.strip() 

389 

390 

391class BorrowerPatch(Schema): 

392 first_name: str | None = None 

393 middle_name: str | None = None 

394 last_name: str | None = None 

395 suffix: str | None = None 

396 email: str | None = None 

397 cell_phone: str | None = None 

398 home_phone: str | None = None 

399 work_phone: str | None = None 

400 ssn: str | None = None 

401 is_primary: bool | None = None 

402 marital_status: str | None = None 

403 dependent_count: int | None = None 

404 dependent_ages: str | None = None 

405 credit_auth_ind: bool | None = None 

406 birth_date: date | None = None 

407 itin_ssn_ind: bool | None = None 

408 citizen_residency_type: str | None = None 

409 role: str | None = None 

410 classification: str | None = None 

411 # Not Borrower columns — resolved into the borrower's Residence(Current) row. 

412 current_address_line: str | None = None 

413 current_city: str | None = None 

414 current_state_code: str | None = None 

415 current_postal_code: str | None = None 

416 current_residency_basis: str | None = None 

417 years_at_current_address: int | None = None 

418 

419 @field_validator("email") 

420 @classmethod 

421 def validate_email(cls, v: str | None) -> str | None: 

422 if v is None or not v.strip(): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 return None 

424 clean = v.strip() 

425 if not EMAIL_RE.match(clean): 

426 raise ValueError(f"Invalid email address: {clean}") 

427 return clean 

428 

429 @field_validator("cell_phone", "home_phone", "work_phone") 

430 @classmethod 

431 def validate_phone(cls, v: str | None) -> str | None: 

432 if v is None or not v.strip(): 

433 return "" 

434 d = "".join(ch for ch in v if ch.isdigit()) 

435 if len(d) != 10: 

436 raise ValueError(f"Phone number must contain exactly 10 digits (got {len(d)})") 

437 return v.strip() 

438 

439 @field_validator("current_state_code") 

440 @classmethod 

441 def validate_state_code(cls, v: str | None) -> str | None: 

442 if v is None or not v.strip(): 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true

443 return None 

444 code = v.strip().upper() 

445 if code not in VALID_STATE_CODES: 

446 raise ValueError(f"Invalid US state code: '{code}'") 

447 return code 

448 

449 @field_validator("current_postal_code") 

450 @classmethod 

451 def validate_postal_code(cls, v: str | None) -> str | None: 

452 if v is None or not v.strip(): 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

453 return None 

454 clean = v.strip() 

455 if not ZIP_RE.match(clean): 

456 raise ValueError("Postal code must be a valid 5-digit US ZIP (e.g. 12345 or 12345-6789)") 

457 return clean 

458 

459 @field_validator("ssn") 

460 @classmethod 

461 def validate_ssn(cls, v: str | None) -> str | None: 

462 if v is None or not v.strip(): 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true

463 return None 

464 d = "".join(ch for ch in v if ch.isdigit()) 

465 if len(d) != 9: 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 raise ValueError(f"SSN must contain exactly 9 digits (got {len(d)})") 

467 if d.startswith("000") or d.startswith("666") or d.startswith("9"): 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true

468 raise ValueError("SSN contains an invalid SSA area number") 

469 return v.strip() 

470 

471 

472# --- Saved views ----------------------------------------------------------------- 

473class SavedViewOut(ModelSchema): 

474 owner_id: int 

475 

476 class Meta: 

477 model = SavedView 

478 exclude = ["owner"] 

479 

480 

481class SavedViewCreate(Schema): 

482 name: str 

483 shared: bool = False 

484 scope: str = "all" 

485 filters: dict = {} 

486 

487 

488# --- Contacts -------------------------------------------------------------------- 

489class BusinessContactOut(ModelSchema): 

490 class Meta: 

491 model = BusinessContact 

492 exclude = ["created_at"] 

493 

494 

495class BusinessContactCreate(Schema): 

496 name: str = Field(..., min_length=1) 

497 company: str | None = None 

498 type: str = Field(..., min_length=1) 

499 email: str | None = None 

500 phone: str | None = None 

501 

502 @field_validator("name") 

503 @classmethod 

504 def validate_name(cls, v: str) -> str: 

505 s = v.strip() 

506 if not s: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 raise ValueError("Name cannot be blank.") 

508 return s 

509 

510 @field_validator("email") 

511 @classmethod 

512 def validate_email(cls, v: str | None) -> str | None: 

513 if v is None or not v.strip(): 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true

514 return None 

515 clean = v.strip() 

516 if not EMAIL_RE.match(clean): 516 ↛ 518line 516 didn't jump to line 518 because the condition on line 516 was always true

517 raise ValueError(f"Invalid email address: {clean}") 

518 return clean 

519 

520 @field_validator("phone") 

521 @classmethod 

522 def validate_phone(cls, v: str | None) -> str | None: 

523 if v is None or not v.strip(): 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true

524 return None 

525 d = "".join(ch for ch in v if ch.isdigit()) 

526 if len(d) != 10: 526 ↛ 528line 526 didn't jump to line 528 because the condition on line 526 was always true

527 raise ValueError(f"Phone number must contain exactly 10 digits (got {len(d)})") 

528 return v.strip() 

529 

530 

531class DirectoryBorrowerOut(Schema): 

532 name: str 

533 email: str | None = None 

534 phone: str | None = None 

535 loan_guid: str 

536 loan_number: str 

537 stage: str 

538 

539 

540# --- Leads --------------------------------------------------------------------- 

541class LeadOut(ModelSchema): 

542 converted_loan_guid: str | None = None 

543 converted_loan_number: str | None = None 

544 

545 class Meta: 

546 model = Lead 

547 exclude = ["created_at", "converted_loan"] 

548 

549 @staticmethod 

550 def resolve_converted_loan_guid(obj: Lead) -> str | None: 

551 return str(obj.converted_loan.guid) if obj.converted_loan_id else None 

552 

553 @staticmethod 

554 def resolve_converted_loan_number(obj: Lead) -> str | None: 

555 return obj.converted_loan.arive_loan_no if obj.converted_loan_id else None 

556 

557 

558class NewLeadInput(Schema): 

559 first_name: str = Field(..., min_length=1) 

560 last_name: str = Field(..., min_length=1) 

561 email: str | None = None 

562 phone: str | None = None 

563 source: str = Field(..., min_length=1) 

564 purpose: str | None = None 

565 est_loan_amount: int | None = None 

566 

567 @field_validator("first_name", "last_name") 

568 @classmethod 

569 def validate_names(cls, v: str) -> str: 

570 s = v.strip() 

571 if not s: 571 ↛ 572line 571 didn't jump to line 572 because the condition on line 571 was never true

572 raise ValueError("Name cannot be blank.") 

573 return s 

574 

575 @field_validator("email") 

576 @classmethod 

577 def validate_email(cls, v: str | None) -> str | None: 

578 if v is None or not v.strip(): 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true

579 return None 

580 clean = v.strip() 

581 if not EMAIL_RE.match(clean): 

582 raise ValueError(f"Invalid email address: {clean}") 

583 return clean 

584 

585 @field_validator("phone") 

586 @classmethod 

587 def validate_phone(cls, v: str | None) -> str | None: 

588 if v is None or not v.strip(): 588 ↛ 589line 588 didn't jump to line 589 because the condition on line 588 was never true

589 return None 

590 d = "".join(ch for ch in v if ch.isdigit()) 

591 if len(d) != 10: 

592 raise ValueError(f"Phone number must contain exactly 10 digits (got {len(d)})") 

593 return v.strip() 

594 

595 @field_validator("est_loan_amount") 

596 @classmethod 

597 def validate_est_loan_amount(cls, v: int | None) -> int | None: 

598 if v is not None: 

599 if v < 0: 

600 raise ValueError("Estimated loan amount cannot be negative.") 

601 if v > 999999999: 

602 raise ValueError("Estimated loan amount cannot exceed $999,999,999.") 

603 return v 

604 

605 

606class LeadStatsOut(Schema): 

607 total: int 

608 new: int 

609 qualified: int 

610 converted: int 

611 

612 

613class LeadConversionOut(Schema): 

614 loan_guid: str 

615 loan_number: str 

616 

617 

618# --- Loan workspace (loanFile) --------------------------------------------------- 

619class LoanFileSummaryOut(Schema): 

620 """GET /api/loans/{guid}/summary — the workspace header/summary card.""" 

621 

622 borrower_name: str 

623 amount: Decimal 

624 value: Decimal 

625 ltv: Decimal 

626 rate: Decimal | None = None 

627 product: str | None = None 

628 lock: dict | None = None 

629 open_tasks: int 

630 conditions: int 

631 

632 

633class ComplianceIssueOut(Schema): 

634 severity: str 

635 code: str 

636 message: str 

637 

638 

639class ComplianceOut(Schema): 

640 """GET /api/loans/{guid}/compliance — loan officer licensing in the subject state.""" 

641 

642 state_code: str 

643 state_name: str 

644 compliant: bool 

645 loan_officer: dict 

646 

647 

648class ActivityItemOut(Schema): 

649 """One entry of GET /api/loans/{guid}/activity, newest first.""" 

650 

651 at: str 

652 type: str 

653 label: str 

654 

655 

656class PreApprovalOut(ModelSchema): 

657 type: str = "PreApproval" 

658 status: str = "Generated" 

659 

660 class Meta: 

661 model = PreApproval 

662 fields = ["id", "generated_at"] 

663 

664 

665# --- Reports & MCR (loan sub-resources; org-wide report endpoints are in 

666# reports_schemas.py) -------------------------------------------------------------- 

667class McrRevenueOut(Schema): 

668 broker_compensation_bps: int 

669 broker_compensation_amt: Decimal 

670 pass_through_fees: Decimal 

671 # Always 0 — no Loan Estimate baseline is persisted to compute a real cure 

672 # against (add-reports-mcr-api design.md). 

673 tolerance_cure: Decimal 

674 

675 

676class McrLineOut(Schema): 

677 loan_guid: str 

678 loan_number: str 

679 borrower_name: str 

680 mcr_application_date: str 

681 state: str 

682 loan_purpose: str 

683 loan_amount: Decimal 

684 rate: Decimal | None = None 

685 exclude_from_mcr: bool 

686 revenue: McrRevenueOut 

687 

688 

689class McrExcludeInput(Schema): 

690 exclude: bool