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
« 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)."""
3from __future__ import annotations
5import re
6from datetime import date
7from decimal import Decimal
9from django.utils import timezone
10from ninja import ModelSchema, Schema
11from pydantic import Field, field_validator
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
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})?$")
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()}
36def _safe_transaction(loan: Loan) -> LoanTransaction | None:
37 try:
38 return loan.transaction
39 except LoanTransaction.DoesNotExist:
40 return None
43def _safe_rate_lock(loan: Loan) -> RateLock | None:
44 try:
45 return loan.rate_lock
46 except RateLock.DoesNotExist:
47 return None
50def _current_residence(borrower: Borrower) -> Residence | None:
51 return borrower.residences.filter(residence_type=ResidenceType.CURRENT.value).first()
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 ]
73class LoanTransactionOut(ModelSchema):
74 class Meta:
75 model = LoanTransaction
76 exclude = ["loan"]
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
89 class Meta:
90 model = Borrower
91 exclude = ["ssn_encrypted", "loan"]
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
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 "***-**-****"
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
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
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
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
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)
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
139class LoanDetailOut(LoanSummaryOut):
140 transaction: LoanTransactionOut | None = None
141 borrowers: list[BorrowerOut] = []
144class PipelineRowOut(LoanSummaryOut):
145 """A loan summary plus the pipeline screen's working columns.
147 Resolvers read off relations the list queryset already fetched via
148 ``select_related``/``prefetch_related``/``annotate`` — no per-row query.
149 """
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
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 "—"
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")
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")
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
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
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
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"
198 @staticmethod
199 def resolve_open_task_count(obj: Loan) -> int:
200 return getattr(obj, "open_task_count", 0)
203class LoanListOut(Schema):
204 count: int
205 rows: list[PipelineRowOut]
208# --- Request schemas -----------------------------------------------------------
209class NewLoanBorrower(Schema):
210 first_name: str | None = None
211 last_name: str | None = None
212 email: str = ""
215class NewLoanTeam(Schema):
216 additional_lo_id: int | None = None
217 processor_id: int | None = None
218 loa_id: int | None = None
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()
233class NewLoanCreated(Schema):
234 guid: str
235 arive_loan_no: str
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
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
262class TeamMemberOut(Schema):
263 id: int
264 name: str
267class AgentOut(Schema):
268 id: int
269 name: str
270 company: str | None = None
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
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
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.
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
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
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
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"
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
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
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()
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
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
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()
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
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
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()
472# --- Saved views -----------------------------------------------------------------
473class SavedViewOut(ModelSchema):
474 owner_id: int
476 class Meta:
477 model = SavedView
478 exclude = ["owner"]
481class SavedViewCreate(Schema):
482 name: str
483 shared: bool = False
484 scope: str = "all"
485 filters: dict = {}
488# --- Contacts --------------------------------------------------------------------
489class BusinessContactOut(ModelSchema):
490 class Meta:
491 model = BusinessContact
492 exclude = ["created_at"]
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
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
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
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()
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
540# --- Leads ---------------------------------------------------------------------
541class LeadOut(ModelSchema):
542 converted_loan_guid: str | None = None
543 converted_loan_number: str | None = None
545 class Meta:
546 model = Lead
547 exclude = ["created_at", "converted_loan"]
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
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
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
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
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
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()
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
606class LeadStatsOut(Schema):
607 total: int
608 new: int
609 qualified: int
610 converted: int
613class LeadConversionOut(Schema):
614 loan_guid: str
615 loan_number: str
618# --- Loan workspace (loanFile) ---------------------------------------------------
619class LoanFileSummaryOut(Schema):
620 """GET /api/loans/{guid}/summary — the workspace header/summary card."""
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
633class ComplianceIssueOut(Schema):
634 severity: str
635 code: str
636 message: str
639class ComplianceOut(Schema):
640 """GET /api/loans/{guid}/compliance — loan officer licensing in the subject state."""
642 state_code: str
643 state_name: str
644 compliant: bool
645 loan_officer: dict
648class ActivityItemOut(Schema):
649 """One entry of GET /api/loans/{guid}/activity, newest first."""
651 at: str
652 type: str
653 label: str
656class PreApprovalOut(ModelSchema):
657 type: str = "PreApproval"
658 status: str = "Generated"
660 class Meta:
661 model = PreApproval
662 fields = ["id", "generated_at"]
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
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
689class McrExcludeInput(Schema):
690 exclude: bool