Coverage for apps/application_1003/models.py: 95%
290 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"""URLA / 1003 borrower records (1:M with Loan).
3Only Borrower lands this sprint; income, employment, assets, liabilities, and REO
4follow in the 1003 sprint.
5"""
7from decimal import Decimal
9from django.db import models
10from django.db.models.signals import post_delete, post_save
11from django.dispatch import receiver
13from domain.enums import (
14 ApplicationTakenMethodType,
15 AttachmentType,
16 BankruptcyChapterType,
17 BorrowerClassificationType,
18 BorrowerRoleType,
19 CitizenResidencyType,
20 ConstructionMethodType,
21 ConstructionStatusType,
22 EstateType,
23 FundsSourceType,
24 GiftSourceType,
25 IntentToOccupyType,
26 LoanIdentifierType,
27 MaritalStatusType,
28 PriorPropertyUsageType,
29 ProjectLegalStructureType,
30 PropertyType,
31 ResidenceBasisType,
32 ResidenceType,
33 SexType,
34 enum_choices,
35)
38class Borrower(models.Model):
39 """A borrower on a loan. SSN is stored encrypted, never in plaintext."""
41 loan = models.ForeignKey("core_los.Loan", on_delete=models.CASCADE, related_name="borrowers")
42 is_primary = models.BooleanField(default=False)
43 first_name = models.CharField(max_length=100)
44 middle_name = models.CharField(max_length=100, blank=True)
45 last_name = models.CharField(max_length=100)
46 suffix = models.CharField(max_length=10, blank=True)
47 email = models.EmailField()
48 cell_phone = models.CharField(max_length=30, blank=True)
49 home_phone = models.CharField(max_length=30, blank=True)
50 work_phone = models.CharField(max_length=30, blank=True)
51 # Column-level encryption placeholder (AWS KMS / Fernet envelope in the security
52 # sprint). Do NOT store real SSNs against this field until that lands.
53 ssn_encrypted = models.BinaryField(null=True, blank=True)
54 birth_date = models.DateField(null=True, blank=True)
55 marital_status = models.CharField(
56 max_length=30, choices=enum_choices(MaritalStatusType), blank=True
57 )
58 dependent_count = models.PositiveSmallIntegerField(null=True, blank=True)
59 dependent_ages = models.CharField(max_length=60, blank=True, null=True)
60 credit_auth_ind = models.BooleanField(default=False)
61 # Borrower portal's verbatim answers, per borrower. The 1003 Declaration booleans
62 # default to False so "not answered" and "No" are indistinguishable there; the portal
63 # keeps its own copy (mirrors PosApplication's loan-level cache, but per borrower).
64 declaration_answers = models.JSONField(default=dict, blank=True) # letter -> bool
65 demographics_answers = models.JSONField(default=dict, blank=True) # {ethnicity, sex, race[]}
66 # AUS/MISMO blocker fields (details/08)
67 itin_ssn_ind = models.BooleanField(
68 default=False, help_text="True when the identifier is an ITIN rather than an SSN"
69 )
70 citizen_residency_type = models.CharField(
71 max_length=30, choices=enum_choices(CitizenResidencyType), blank=True
72 )
73 role = models.CharField(
74 max_length=20,
75 choices=enum_choices(BorrowerRoleType),
76 default=BorrowerRoleType.BORROWER.value,
77 )
78 classification = models.CharField(
79 max_length=20,
80 choices=enum_choices(BorrowerClassificationType),
81 default=BorrowerClassificationType.PRIMARY.value,
82 )
83 created_at = models.DateTimeField(auto_now_add=True)
85 class Meta:
86 db_table = "borrowers"
88 def __str__(self) -> str:
89 return f"{self.first_name} {self.last_name}"
92class Employer(models.Model):
93 """Employment record (URLA §1b). monthly_income_amt is an informational roll-up."""
95 loan = models.ForeignKey("core_los.Loan", on_delete=models.CASCADE, related_name="employers")
96 borrower = models.ForeignKey(Borrower, on_delete=models.CASCADE, related_name="employers")
97 employer_name = models.CharField(max_length=255)
98 position_title = models.CharField(max_length=255, blank=True)
99 employment_type = models.CharField(max_length=30, default="Primary")
100 self_employed_ind = models.BooleanField(default=False)
101 ownership_pct = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
102 start_date = models.DateField(null=True, blank=True)
103 end_date = models.DateField(null=True, blank=True)
104 monthly_income_amt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
105 years_in_line_of_work = models.DecimalField(
106 max_digits=5, decimal_places=2, null=True, blank=True
107 )
108 phone = models.CharField(max_length=30, blank=True)
109 address_line = models.CharField(max_length=255, blank=True)
110 city = models.CharField(max_length=100, blank=True)
111 state_code = models.CharField(max_length=2, blank=True)
112 postal_code = models.CharField(max_length=10, blank=True)
113 special_relationship_ind = models.BooleanField(default=False)
114 created_at = models.DateTimeField(auto_now_add=True)
116 class Meta:
117 db_table = "employers"
119 def __str__(self) -> str:
120 return self.employer_name
123class Income(models.Model):
124 """Income record (URLA §15.2). monthly_amt of current records is canonical for DTI."""
126 loan = models.ForeignKey("core_los.Loan", on_delete=models.CASCADE, related_name="incomes")
127 borrower = models.ForeignKey(Borrower, on_delete=models.CASCADE, related_name="incomes")
128 employer = models.ForeignKey(
129 Employer, on_delete=models.SET_NULL, null=True, blank=True, related_name="incomes"
130 )
131 income_type = models.CharField(max_length=30)
132 monthly_amt = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
133 annual_amt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
134 pay_frequency = models.CharField(max_length=20, blank=True)
135 calc_type = models.CharField(max_length=20, blank=True)
136 current_ind = models.BooleanField(default=True)
137 description = models.CharField(max_length=255, blank=True)
138 created_at = models.DateTimeField(auto_now_add=True)
140 class Meta:
141 db_table = "incomes"
143 def __str__(self) -> str:
144 return f"{self.income_type}: {self.monthly_amt}"
147class Asset(models.Model):
148 """Asset (jointly owned by one or more borrowers)."""
150 loan = models.ForeignKey("core_los.Loan", on_delete=models.CASCADE, related_name="assets")
151 borrowers = models.ManyToManyField(Borrower, related_name="assets")
152 asset_type = models.CharField(max_length=40)
153 cash_or_market_value_amt = models.DecimalField(
154 max_digits=12, decimal_places=2, default=Decimal("0.00")
155 )
156 institution_name = models.CharField(max_length=255, blank=True)
157 account_number = models.CharField(max_length=50, blank=True)
158 gift_total_value_amt = models.DecimalField(
159 max_digits=12, decimal_places=2, null=True, blank=True
160 )
161 liquidity_type = models.CharField(max_length=20, blank=True)
162 is_verified_ind = models.BooleanField(default=False)
163 ownership_type = models.CharField(max_length=20, blank=True)
164 created_at = models.DateTimeField(auto_now_add=True)
166 class Meta:
167 db_table = "assets"
169 def __str__(self) -> str:
170 return f"{self.asset_type}: {self.cash_or_market_value_amt}"
173class RealEstate(models.Model):
174 """Real estate owned (REO), jointly owned by one or more borrowers."""
176 loan = models.ForeignKey("core_los.Loan", on_delete=models.CASCADE, related_name="real_estate")
177 borrowers = models.ManyToManyField(Borrower, related_name="real_estate")
178 property_usage_type = models.CharField(max_length=30, blank=True)
179 disposition_status_type = models.CharField(max_length=20, blank=True)
180 property_value_amt = models.DecimalField(
181 max_digits=12, decimal_places=2, default=Decimal("0.00")
182 )
183 subject_property_ind = models.BooleanField(default=False)
184 ownership_type = models.CharField(max_length=20, blank=True)
185 address_line = models.CharField(max_length=255, blank=True)
186 city = models.CharField(max_length=100, blank=True)
187 state_code = models.CharField(max_length=2, blank=True)
188 postal_code = models.CharField(max_length=10, blank=True)
189 mortgage_unpaid_balance_amt = models.DecimalField(
190 max_digits=12, decimal_places=2, null=True, blank=True
191 )
192 mortgage_monthly_payment_amt = models.DecimalField(
193 max_digits=12, decimal_places=2, null=True, blank=True
194 )
195 gross_rental_income_amt = models.DecimalField(
196 max_digits=12, decimal_places=2, null=True, blank=True
197 )
198 rental_expense_amt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
199 monthly_taxes_ins_hoa_amt = models.DecimalField(
200 max_digits=12, decimal_places=2, null=True, blank=True
201 )
202 created_at = models.DateTimeField(auto_now_add=True)
204 class Meta:
205 db_table = "real_estate"
207 def __str__(self) -> str:
208 return f"REO {self.property_value_amt}"
211class Liability(models.Model):
212 """Liability (jointly owned); may link to the REO it secures."""
214 loan = models.ForeignKey("core_los.Loan", on_delete=models.CASCADE, related_name="liabilities")
215 borrowers = models.ManyToManyField(Borrower, related_name="liabilities")
216 real_estate = models.ForeignKey(
217 RealEstate, on_delete=models.SET_NULL, null=True, blank=True, related_name="liabilities"
218 )
219 liability_type = models.CharField(max_length=30)
220 creditor_name = models.CharField(max_length=255, blank=True)
221 monthly_payment_amt = models.DecimalField(
222 max_digits=12, decimal_places=2, default=Decimal("0.00")
223 )
224 unpaid_balance_amt = models.DecimalField(
225 max_digits=12, decimal_places=2, default=Decimal("0.00")
226 )
227 payoff_status_ind = models.BooleanField(default=False)
228 exclude_from_dti_ind = models.BooleanField(default=False)
229 omit_ind = models.BooleanField(default=False)
230 paid_by_close_ind = models.BooleanField(default=False)
231 account_number = models.CharField(max_length=50, blank=True)
232 months_remaining_count = models.IntegerField(null=True, blank=True)
233 ownership_type = models.CharField(max_length=20, blank=True)
234 created_at = models.DateTimeField(auto_now_add=True)
236 class Meta:
237 db_table = "liabilities"
239 def __str__(self) -> str:
240 return f"{self.liability_type}: {self.monthly_payment_amt}"
243class HousingExpense(models.Model):
244 """Housing expense (per loan), proposed or present."""
246 loan = models.ForeignKey(
247 "core_los.Loan", on_delete=models.CASCADE, related_name="housing_expenses"
248 )
249 expense_type = models.CharField(max_length=40)
250 payment_amt = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
251 timing_type = models.CharField(max_length=20, default="Proposed")
252 created_at = models.DateTimeField(auto_now_add=True)
254 class Meta:
255 db_table = "housing_expenses"
257 def __str__(self) -> str:
258 return f"{self.expense_type}: {self.payment_amt}"
261# --- 1003 extension entities (AUS pre-flight + MISMO 3.4 export; see details/08) ---
262class Declaration(models.Model):
263 """URLA §5 declarations — one set per borrower (DU pre-flight `declaration` bucket)."""
265 borrower = models.OneToOneField(Borrower, on_delete=models.CASCADE, related_name="declaration")
266 bankruptcy_ind = models.BooleanField(default=False)
267 bankruptcy_chapter = models.CharField(
268 max_length=12, choices=enum_choices(BankruptcyChapterType), blank=True
269 )
270 prior_foreclosure_ind = models.BooleanField(default=False)
271 prior_deed_in_lieu_ind = models.BooleanField(default=False)
272 prior_short_sale_ind = models.BooleanField(default=False)
273 outstanding_judgments_ind = models.BooleanField(default=False)
274 delinquent_federal_debt_ind = models.BooleanField(default=False)
275 party_to_lawsuit_ind = models.BooleanField(default=False)
276 alimony_child_support_ind = models.BooleanField(default=False)
277 undisclosed_borrowed_funds_ind = models.BooleanField(default=False)
278 undisclosed_borrowed_funds_amt = models.DecimalField(
279 max_digits=12, decimal_places=2, null=True, blank=True
280 )
281 undisclosed_mortgage_app_ind = models.BooleanField(default=False)
282 undisclosed_credit_app_ind = models.BooleanField(default=False)
283 comaker_endorser_ind = models.BooleanField(default=False)
284 intent_to_occupy_type = models.CharField(
285 max_length=10, choices=enum_choices(IntentToOccupyType), blank=True
286 )
287 owned_property_past_three_years_ind = models.BooleanField(default=False)
288 prior_property_usage_type = models.CharField(
289 max_length=30, choices=enum_choices(PriorPropertyUsageType), blank=True
290 )
291 citizen_residency_type = models.CharField(
292 max_length=30, choices=enum_choices(CitizenResidencyType), blank=True
293 )
294 fha_secondary_residence_ind = models.BooleanField(default=False)
295 clean_energy_lien_ind = models.BooleanField(default=False)
296 first_time_homebuyer_ind = models.BooleanField(default=False)
297 explanations = models.JSONField(default=list, blank=True)
298 created_at = models.DateTimeField(auto_now_add=True)
299 updated_at = models.DateTimeField(auto_now=True)
301 class Meta:
302 db_table = "borrower_declarations"
304 def __str__(self) -> str:
305 return f"Declarations for borrower {self.borrower_id}"
308class Demographics(models.Model):
309 """URLA §7 HMDA demographics — one set per borrower (DU `demographic` bucket)."""
311 borrower = models.OneToOneField(Borrower, on_delete=models.CASCADE, related_name="demographics")
312 ethnicity = models.JSONField(default=list, blank=True) # HmdaEthnicityType[]
313 hispanic_origins = models.JSONField(default=list, blank=True)
314 race = models.JSONField(default=list, blank=True) # HmdaRaceType[]
315 sex = models.CharField(max_length=20, choices=enum_choices(SexType), blank=True)
316 ethnicity_refusal_ind = models.BooleanField(default=False)
317 race_refusal_ind = models.BooleanField(default=False)
318 sex_refusal_ind = models.BooleanField(default=False)
319 ethnicity_visual_ind = models.BooleanField(default=False)
320 race_visual_ind = models.BooleanField(default=False)
321 sex_visual_ind = models.BooleanField(default=False)
322 application_taken_method = models.CharField(
323 max_length=20, choices=enum_choices(ApplicationTakenMethodType), blank=True
324 )
325 created_at = models.DateTimeField(auto_now_add=True)
326 updated_at = models.DateTimeField(auto_now=True)
328 class Meta:
329 db_table = "borrower_demographics"
331 def __str__(self) -> str:
332 return f"Demographics for borrower {self.borrower_id}"
335class Residence(models.Model):
336 """Borrower residence (current / prior / mailing) — AUS `borrowerResidence` + 2-yr history."""
338 borrower = models.ForeignKey(Borrower, on_delete=models.CASCADE, related_name="residences")
339 residence_type = models.CharField(max_length=10, choices=enum_choices(ResidenceType))
340 basis_type = models.CharField(
341 max_length=20, choices=enum_choices(ResidenceBasisType), blank=True
342 )
343 duration_months = models.PositiveIntegerField(null=True, blank=True)
344 monthly_rent_amt = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
345 address_line = models.CharField(max_length=255, blank=True)
346 unit_id = models.CharField(max_length=30, blank=True)
347 city = models.CharField(max_length=100, blank=True)
348 county = models.CharField(max_length=100, blank=True)
349 state_code = models.CharField(max_length=2, blank=True)
350 postal_code = models.CharField(max_length=10, blank=True)
351 country_code = models.CharField(max_length=2, default="US", blank=True)
352 created_at = models.DateTimeField(auto_now_add=True)
354 class Meta:
355 db_table = "borrower_residences"
356 indexes = [models.Index(fields=["borrower", "residence_type"])]
358 def __str__(self) -> str:
359 return f"{self.residence_type} residence for borrower {self.borrower_id}"
362class DownPaymentSource(models.Model):
363 """A funds source for the down payment (AUS funds-to-close, MISMO DOWN_PAYMENT)."""
365 loan = models.ForeignKey(
366 "core_los.Loan", on_delete=models.CASCADE, related_name="down_payment_sources"
367 )
368 amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
369 funds_source_type = models.CharField(max_length=30, choices=enum_choices(FundsSourceType))
370 description = models.CharField(max_length=255, blank=True)
371 created_at = models.DateTimeField(auto_now_add=True)
373 class Meta:
374 db_table = "down_payment_sources"
376 def __str__(self) -> str:
377 return f"{self.funds_source_type}: {self.amount}"
380class GiftGrant(models.Model):
381 """Gift/grant donor detail on a gifted asset (AUS gift funds, MISMO GIFT/GRANT donor)."""
383 loan = models.ForeignKey("core_los.Loan", on_delete=models.CASCADE, related_name="gift_grants")
384 borrower = models.ForeignKey(
385 Borrower, on_delete=models.CASCADE, related_name="gift_grants", null=True, blank=True
386 )
387 donor_name = models.CharField(max_length=255, blank=True)
388 source_type = models.CharField(max_length=20, choices=enum_choices(GiftSourceType), blank=True)
389 amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
390 received_ind = models.BooleanField(default=False)
391 deposited_ind = models.BooleanField(default=False)
392 created_at = models.DateTimeField(auto_now_add=True)
394 class Meta:
395 db_table = "gift_grants"
397 def __str__(self) -> str:
398 return f"Gift {self.amount} from {self.donor_name or self.source_type}"
401class LoanIdentifier(models.Model):
402 """Loan-level identifier (ULI, MERS MIN, FHA/VA/Agency case #, lender loan #)."""
404 loan = models.ForeignKey(
405 "core_los.Loan", on_delete=models.CASCADE, related_name="loan_identifiers"
406 )
407 identifier_type = models.CharField(max_length=30, choices=enum_choices(LoanIdentifierType))
408 value = models.CharField(max_length=100)
409 created_at = models.DateTimeField(auto_now_add=True)
411 class Meta:
412 db_table = "loan_identifiers"
413 unique_together = ("loan", "identifier_type")
415 def __str__(self) -> str:
416 return f"{self.identifier_type}: {self.value}"
419class SubjectProperty(models.Model):
420 """The financed property's detail (distinct from REO) — AUS/MISMO/disclosures."""
422 loan = models.OneToOneField(
423 "core_los.Loan", on_delete=models.CASCADE, related_name="subject_property"
424 )
425 address_line = models.CharField(max_length=255, blank=True)
426 unit_id = models.CharField(max_length=30, blank=True)
427 city = models.CharField(max_length=100, blank=True)
428 county = models.CharField(max_length=100, blank=True)
429 state_code = models.CharField(max_length=2, blank=True)
430 postal_code = models.CharField(max_length=10, blank=True)
431 property_type = models.CharField(max_length=30, choices=enum_choices(PropertyType), blank=True)
432 attachment_type = models.CharField(
433 max_length=20, choices=enum_choices(AttachmentType), blank=True
434 )
435 financed_unit_count = models.PositiveIntegerField(default=1)
436 construction_method = models.CharField(
437 max_length=20, choices=enum_choices(ConstructionMethodType), blank=True
438 )
439 construction_status = models.CharField(
440 max_length=20, choices=enum_choices(ConstructionStatusType), blank=True
441 )
442 project_legal_structure_type = models.CharField(
443 max_length=30, choices=enum_choices(ProjectLegalStructureType), blank=True
444 )
445 project_name = models.CharField(max_length=255, blank=True)
446 stories_count = models.PositiveIntegerField(null=True, blank=True)
447 hoa_dues_amt = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
448 non_warrantable_ind = models.BooleanField(default=False)
449 estate_type = models.CharField(max_length=20, choices=enum_choices(EstateType), blank=True)
450 apn_identifier = models.CharField(max_length=50, blank=True)
451 legal_description = models.TextField(blank=True)
452 flood_zone = models.CharField(max_length=20, blank=True)
453 estimated_value_amt = models.DecimalField(
454 max_digits=12, decimal_places=2, null=True, blank=True
455 )
456 created_at = models.DateTimeField(auto_now_add=True)
457 updated_at = models.DateTimeField(auto_now=True)
459 class Meta:
460 db_table = "subject_properties"
462 def __str__(self) -> str:
463 return f"Subject property for loan {self.loan_id}"
466@receiver(post_save, sender=Employer)
467def sync_employer_income(sender, instance: Employer, **kwargs):
468 """Keep canonical Income record synchronized with Employer.monthly_income_amt."""
469 if instance.monthly_income_amt and instance.monthly_income_amt > 0:
470 income = Income.objects.filter(employer=instance).first()
471 if income:
472 updated = False
473 if income.monthly_amt != instance.monthly_income_amt:
474 income.monthly_amt = instance.monthly_income_amt
475 updated = True
476 if income.borrower_id != instance.borrower_id:
477 income.borrower = instance.borrower
478 updated = True
479 if updated: 479 ↛ exitline 479 didn't return from function 'sync_employer_income' because the condition on line 479 was always true
480 income.save(update_fields=["monthly_amt", "borrower"])
481 else:
482 Income.objects.create(
483 loan=instance.loan,
484 borrower=instance.borrower,
485 employer=instance,
486 income_type="Base",
487 monthly_amt=instance.monthly_income_amt,
488 current_ind=True,
489 )
490 elif instance.monthly_income_amt is None or instance.monthly_income_amt <= 0: 490 ↛ exitline 490 didn't return from function 'sync_employer_income' because the condition on line 490 was always true
491 Income.objects.filter(employer=instance, income_type="Base").delete()
494@receiver(post_delete, sender=Employer)
495def clean_employer_income(sender, instance: Employer, **kwargs):
496 Income.objects.filter(employer=instance, income_type="Base").delete()