Coverage for apps/core_los/models.py: 93%
172 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"""Core loan aggregate: the loan header and its 1:1 financial transaction."""
3import uuid
4from decimal import Decimal
6from django.conf import settings
7from django.db import models
9from domain.enums import (
10 LienPriorityType,
11 RefinanceCashOutType,
12 enum_choices,
13 occupancy_choices,
14 purpose_choices,
15 stage_choices,
16)
19class Loan(models.Model):
20 """Loan header aggregate root."""
22 # Choices derive from the shared enum registry (domain.enums) so persistence and
23 # the API cannot drift from one source of truth.
24 STAGE_CHOICES = stage_choices()
25 PURPOSE_CHOICES = purpose_choices()
26 OCCUPANCY_CHOICES = occupancy_choices()
28 arive_loan_no = models.CharField(max_length=50, unique=True)
29 # Cross-DB link to Tireless's `loanAutomationTicketId` (ADR-002 seam hook; nullable —
30 # set when a loan is handed off / linked to Tireless; the tracker poll keys on it).
31 tireless_loan_id = models.CharField(max_length=64, null=True, blank=True, db_index=True)
32 guid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
33 org_unit = models.ForeignKey(
34 "accounts.OrgUnit", on_delete=models.PROTECT, related_name="loans"
35 )
36 loan_officer = models.ForeignKey(
37 settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="originated_loans"
38 )
39 # Optional loan-team roles beyond the originating loan officer (pipeline "me" scope).
40 additional_lo = models.ForeignKey(
41 settings.AUTH_USER_MODEL,
42 on_delete=models.SET_NULL,
43 null=True,
44 blank=True,
45 related_name="additional_lo_loans",
46 )
47 processor = models.ForeignKey(
48 settings.AUTH_USER_MODEL,
49 on_delete=models.SET_NULL,
50 null=True,
51 blank=True,
52 related_name="processor_loans",
53 )
54 loa = models.ForeignKey(
55 settings.AUTH_USER_MODEL,
56 on_delete=models.SET_NULL,
57 null=True,
58 blank=True,
59 related_name="loa_loans",
60 )
61 assistant_processor = models.ForeignKey(
62 settings.AUTH_USER_MODEL,
63 on_delete=models.SET_NULL,
64 null=True,
65 blank=True,
66 related_name="assistant_processor_loans",
67 )
68 # Buyer's/listing agent (a referral partner, add-loan-team-api); BusinessContact is global,
69 # not loan-scoped, so this is the only link from a loan to one.
70 agent_contact = models.ForeignKey(
71 "BusinessContact",
72 on_delete=models.SET_NULL,
73 null=True,
74 blank=True,
75 related_name="agent_loans",
76 )
77 stage = models.CharField(max_length=50, choices=STAGE_CHOICES, default="APPLICATION_INTAKE")
78 purpose = models.CharField(max_length=30, choices=PURPOSE_CHOICES)
79 occupancy = models.CharField(max_length=30, choices=OCCUPANCY_CHOICES)
80 state_code = models.CharField(max_length=2)
81 county = models.CharField(max_length=100, blank=True)
82 credit_pulled = models.BooleanField(default=False)
83 # Mortgage Call Report exclusion toggle (add-reports-mcr-api).
84 exclude_from_mcr = models.BooleanField(default=False)
85 version = models.PositiveIntegerField(default=1) # optimistic concurrency lock
86 created_at = models.DateTimeField(auto_now_add=True)
87 updated_at = models.DateTimeField(auto_now=True)
89 class Meta:
90 db_table = "loans"
91 indexes = [
92 models.Index(fields=["org_unit", "stage"]),
93 models.Index(fields=["loan_officer"]),
94 ]
96 def __str__(self) -> str:
97 return self.arive_loan_no
100class LoanTransaction(models.Model):
101 """Financial transaction information (1:1 with Loan)."""
103 loan = models.OneToOneField(
104 Loan, on_delete=models.CASCADE, primary_key=True, related_name="transaction"
105 )
106 base_loan_amt = models.DecimalField(max_digits=12, decimal_places=2)
107 note_amt = models.DecimalField(max_digits=12, decimal_places=2)
108 purchase_price_amt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
109 appraised_value_amt = models.DecimalField(
110 max_digits=12, decimal_places=2, null=True, blank=True
111 )
112 note_rate_percent = models.DecimalField(max_digits=6, decimal_places=3, null=True, blank=True)
113 ltv_ratio_percent = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
114 cltv_ratio_percent = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
115 hcltv_ratio_percent = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
116 down_payment_amt = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
117 mortgage_type = models.CharField(max_length=30, default="Conventional")
118 amortization_type = models.CharField(max_length=30, default="Fixed")
119 term_months = models.IntegerField(default=360)
120 # AUS/MISMO blocker inputs (details/08): lien position + the subordinate-financing amount
121 # behind CLTV (today only the ratio is stored), and refinance cash-out determination.
122 lien_priority_type = models.CharField(
123 max_length=20,
124 choices=enum_choices(LienPriorityType),
125 default=LienPriorityType.FIRST_LIEN.value,
126 )
127 total_subordinate_financing_amt = models.DecimalField(
128 max_digits=12, decimal_places=2, null=True, blank=True
129 )
130 refinance_cash_out_type = models.CharField(
131 max_length=20, choices=enum_choices(RefinanceCashOutType), blank=True
132 )
133 refinance_cash_out_amt = models.DecimalField(
134 max_digits=12, decimal_places=2, null=True, blank=True
135 )
137 class Meta:
138 db_table = "loan_transactions"
140 def __str__(self) -> str:
141 return f"Transaction for {self.loan_id}"
144class SavedView(models.Model):
145 """A staff user's saved pipeline scope+filter preset (/api/saved-views)."""
147 SCOPE_CHOICES = [("me", "me"), ("team", "team"), ("all", "all")]
149 owner = models.ForeignKey(
150 settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="saved_views"
151 )
152 name = models.CharField(max_length=60)
153 shared = models.BooleanField(default=False)
154 scope = models.CharField(max_length=10, choices=SCOPE_CHOICES, default="all")
155 filters = models.JSONField(default=dict, blank=True)
156 created_at = models.DateTimeField(auto_now_add=True)
158 class Meta:
159 db_table = "saved_views"
161 def __str__(self) -> str:
162 return f"{self.name} (owner {self.owner_id})"
165class BusinessContact(models.Model):
166 """A referral/service partner (realtor, title, appraiser, ...), global, not loan-scoped."""
168 CONTACT_TYPE_CHOICES = [
169 (t, t)
170 for t in (
171 "Realtor", "Title", "Appraiser", "Insurance", "Attorney", "Builder", "Lender", "Other",
172 )
173 ]
175 name = models.CharField(max_length=150)
176 company = models.CharField(max_length=150, null=True, blank=True)
177 type = models.CharField(max_length=20, choices=CONTACT_TYPE_CHOICES)
178 email = models.EmailField(null=True, blank=True)
179 phone = models.CharField(max_length=30, null=True, blank=True)
180 created_at = models.DateTimeField(auto_now_add=True)
182 class Meta:
183 db_table = "business_contacts"
185 def __str__(self) -> str:
186 return self.name
189class LoanNumberSequence(models.Model):
190 """Single-row counter generating `LMN-<n>` loan numbers (design.md D1)."""
192 next_value = models.PositiveIntegerField(default=10000)
194 class Meta:
195 db_table = "loan_number_sequence"
197 def __str__(self) -> str:
198 return f"next={self.next_value}"
201def next_loan_number() -> str:
202 """Atomically reserve and return the next `LMN-<n>` loan number.
204 Must be called inside the caller's own ``db_transaction.atomic()`` block —
205 ``select_for_update()`` only locks within an existing transaction.
206 """
207 seq, _ = LoanNumberSequence.objects.select_for_update().get_or_create(pk=1)
208 n = seq.next_value
209 seq.next_value = n + 1
210 seq.save(update_fields=["next_value"])
211 return f"LMN-{n}"
214class LoanStageHistory(models.Model):
215 """One entry per stage a loan has entered, written by `update_loan` going
216 forward (add-loan-file-api design.md) — backs the activity feed's `stage`
217 entries. A loan's history before this model existed is empty by construction."""
219 loan = models.ForeignKey(Loan, on_delete=models.CASCADE, related_name="stage_history")
220 stage = models.CharField(max_length=50, choices=Loan.STAGE_CHOICES)
221 entered_at = models.DateTimeField(auto_now_add=True)
223 class Meta:
224 db_table = "loan_stage_history"
225 indexes = [models.Index(fields=["loan", "entered_at"])]
227 def __str__(self) -> str:
228 return f"{self.loan_id}: {self.stage} @ {self.entered_at}"
231class PreApproval(models.Model):
232 """A generated pre-approval letter record (POST /api/loans/{guid}/preapproval).
234 Deliberately not a `disclosures.Disclosure` row — TRID package types and fields
235 (coc_reason, audit_findings, ...) don't apply here (add-loan-file-api design.md).
236 """
238 loan = models.ForeignKey(Loan, on_delete=models.CASCADE, related_name="preapprovals")
239 generated_at = models.DateTimeField(auto_now_add=True)
241 class Meta:
242 db_table = "preapprovals"
244 def __str__(self) -> str:
245 return f"PreApproval for {self.loan_id} @ {self.generated_at}"
248class Lead(models.Model):
249 """A prospect before (or after) becoming a loan (/api/leads)."""
251 SOURCE_CHOICES = [
252 (s, s) for s in ("Website", "Referral", "Zillow", "Repeat", "Realtor", "Other")
253 ]
254 STATUS_CHOICES = [
255 (s, s)
256 for s in ("New", "Contacted", "Qualified", "Nurture", "Converted", "Lost")
257 ]
259 first_name = models.CharField(max_length=100)
260 last_name = models.CharField(max_length=100)
261 email = models.EmailField(null=True, blank=True)
262 phone = models.CharField(max_length=30, null=True, blank=True)
263 source = models.CharField(max_length=20, choices=SOURCE_CHOICES)
264 purpose = models.CharField(max_length=30, choices=purpose_choices(), null=True, blank=True)
265 est_loan_amount = models.PositiveIntegerField(null=True, blank=True)
266 status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="New")
267 converted_loan = models.ForeignKey(
268 "core_los.Loan", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
269 )
270 created_at = models.DateTimeField(auto_now_add=True)
272 class Meta:
273 db_table = "leads"
275 def __str__(self) -> str:
276 return f"{self.first_name} {self.last_name} ({self.status})"
279class CustomReport(models.Model):
280 """A saved report definition (/api/custom-reports) — global, not per-user
281 (the contract has no owner field; built-ins and user reports share this table,
282 distinguished only by `system`, per add-report-builder-api design.md)."""
284 name = models.CharField(max_length=150)
285 columns = models.JSONField(default=list, blank=True)
286 filters = models.JSONField(default=list, blank=True)
287 group_by = models.CharField(max_length=50, null=True, blank=True)
288 metrics = models.JSONField(default=list, blank=True)
289 sort = models.JSONField(default=None, null=True, blank=True)
290 system = models.BooleanField(default=False)
291 created_at = models.DateTimeField(auto_now_add=True)
293 class Meta:
294 db_table = "custom_reports"
296 def __str__(self) -> str:
297 return self.name
300class CustomFieldDefinition(models.Model):
301 """Organization-defined custom loan field."""
303 key = models.CharField(max_length=100, unique=True)
304 label = models.CharField(max_length=200)
305 field_type = models.CharField(max_length=50) # text, number, date, checkbox, select
306 options = models.JSONField(null=True, blank=True)
307 section = models.CharField(max_length=100, default="Loan")
308 active = models.BooleanField(default=True)
309 created_at = models.DateTimeField(auto_now_add=True)
311 class Meta:
312 db_table = "custom_field_definitions"
313 ordering = ["id"]
315 def __str__(self) -> str:
316 return f"{self.label} ({self.key})"
319class LoanCustomFieldValues(models.Model):
320 """Custom field value bag for a specific loan."""
322 loan = models.OneToOneField(
323 Loan, on_delete=models.CASCADE, related_name="custom_fields_bag", primary_key=True
324 )
325 values = models.JSONField(default=dict, blank=True)
326 updated_at = models.DateTimeField(auto_now=True)
328 class Meta:
329 db_table = "loan_custom_field_values"
331 def __str__(self) -> str:
332 return f"Custom fields for Loan {self.loan_id}"
335class CustomTrackerDefinition(models.Model):
336 """Organization-defined custom milestone tracker."""
338 key = models.CharField(max_length=100, unique=True)
339 label = models.CharField(max_length=200)
340 category = models.CharField(max_length=100, default="Custom")
341 applies_to = models.CharField(max_length=50, default="AllLoans") # AllLoans, Purchase, Refinance
342 statuses = models.JSONField(default=list, blank=True)
343 active = models.BooleanField(default=True)
344 created_at = models.DateTimeField(auto_now_add=True)
346 class Meta:
347 db_table = "custom_tracker_definitions"
348 ordering = ["id"]
350 def __str__(self) -> str:
351 return f"{self.label} ({self.key})"