Coverage for apps/application_1003/models.py: 95%

290 statements  

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

1"""URLA / 1003 borrower records (1:M with Loan). 

2 

3Only Borrower lands this sprint; income, employment, assets, liabilities, and REO 

4follow in the 1003 sprint. 

5""" 

6 

7from decimal import Decimal 

8 

9from django.db import models 

10from django.db.models.signals import post_delete, post_save 

11from django.dispatch import receiver 

12 

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) 

36 

37 

38class Borrower(models.Model): 

39 """A borrower on a loan. SSN is stored encrypted, never in plaintext.""" 

40 

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) 

84 

85 class Meta: 

86 db_table = "borrowers" 

87 

88 def __str__(self) -> str: 

89 return f"{self.first_name} {self.last_name}" 

90 

91 

92class Employer(models.Model): 

93 """Employment record (URLA §1b). monthly_income_amt is an informational roll-up.""" 

94 

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) 

115 

116 class Meta: 

117 db_table = "employers" 

118 

119 def __str__(self) -> str: 

120 return self.employer_name 

121 

122 

123class Income(models.Model): 

124 """Income record (URLA §15.2). monthly_amt of current records is canonical for DTI.""" 

125 

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) 

139 

140 class Meta: 

141 db_table = "incomes" 

142 

143 def __str__(self) -> str: 

144 return f"{self.income_type}: {self.monthly_amt}" 

145 

146 

147class Asset(models.Model): 

148 """Asset (jointly owned by one or more borrowers).""" 

149 

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) 

165 

166 class Meta: 

167 db_table = "assets" 

168 

169 def __str__(self) -> str: 

170 return f"{self.asset_type}: {self.cash_or_market_value_amt}" 

171 

172 

173class RealEstate(models.Model): 

174 """Real estate owned (REO), jointly owned by one or more borrowers.""" 

175 

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) 

203 

204 class Meta: 

205 db_table = "real_estate" 

206 

207 def __str__(self) -> str: 

208 return f"REO {self.property_value_amt}" 

209 

210 

211class Liability(models.Model): 

212 """Liability (jointly owned); may link to the REO it secures.""" 

213 

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) 

235 

236 class Meta: 

237 db_table = "liabilities" 

238 

239 def __str__(self) -> str: 

240 return f"{self.liability_type}: {self.monthly_payment_amt}" 

241 

242 

243class HousingExpense(models.Model): 

244 """Housing expense (per loan), proposed or present.""" 

245 

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) 

253 

254 class Meta: 

255 db_table = "housing_expenses" 

256 

257 def __str__(self) -> str: 

258 return f"{self.expense_type}: {self.payment_amt}" 

259 

260 

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

264 

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) 

300 

301 class Meta: 

302 db_table = "borrower_declarations" 

303 

304 def __str__(self) -> str: 

305 return f"Declarations for borrower {self.borrower_id}" 

306 

307 

308class Demographics(models.Model): 

309 """URLA §7 HMDA demographics — one set per borrower (DU `demographic` bucket).""" 

310 

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) 

327 

328 class Meta: 

329 db_table = "borrower_demographics" 

330 

331 def __str__(self) -> str: 

332 return f"Demographics for borrower {self.borrower_id}" 

333 

334 

335class Residence(models.Model): 

336 """Borrower residence (current / prior / mailing) — AUS `borrowerResidence` + 2-yr history.""" 

337 

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) 

353 

354 class Meta: 

355 db_table = "borrower_residences" 

356 indexes = [models.Index(fields=["borrower", "residence_type"])] 

357 

358 def __str__(self) -> str: 

359 return f"{self.residence_type} residence for borrower {self.borrower_id}" 

360 

361 

362class DownPaymentSource(models.Model): 

363 """A funds source for the down payment (AUS funds-to-close, MISMO DOWN_PAYMENT).""" 

364 

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) 

372 

373 class Meta: 

374 db_table = "down_payment_sources" 

375 

376 def __str__(self) -> str: 

377 return f"{self.funds_source_type}: {self.amount}" 

378 

379 

380class GiftGrant(models.Model): 

381 """Gift/grant donor detail on a gifted asset (AUS gift funds, MISMO GIFT/GRANT donor).""" 

382 

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) 

393 

394 class Meta: 

395 db_table = "gift_grants" 

396 

397 def __str__(self) -> str: 

398 return f"Gift {self.amount} from {self.donor_name or self.source_type}" 

399 

400 

401class LoanIdentifier(models.Model): 

402 """Loan-level identifier (ULI, MERS MIN, FHA/VA/Agency case #, lender loan #).""" 

403 

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) 

410 

411 class Meta: 

412 db_table = "loan_identifiers" 

413 unique_together = ("loan", "identifier_type") 

414 

415 def __str__(self) -> str: 

416 return f"{self.identifier_type}: {self.value}" 

417 

418 

419class SubjectProperty(models.Model): 

420 """The financed property's detail (distinct from REO) — AUS/MISMO/disclosures.""" 

421 

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) 

458 

459 class Meta: 

460 db_table = "subject_properties" 

461 

462 def __str__(self) -> str: 

463 return f"Subject property for loan {self.loan_id}" 

464 

465 

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

492 

493 

494@receiver(post_delete, sender=Employer) 

495def clean_employer_income(sender, instance: Employer, **kwargs): 

496 Income.objects.filter(employer=instance, income_type="Base").delete()