Coverage for apps/core_los/models.py: 93%

172 statements  

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

1"""Core loan aggregate: the loan header and its 1:1 financial transaction.""" 

2 

3import uuid 

4from decimal import Decimal 

5 

6from django.conf import settings 

7from django.db import models 

8 

9from domain.enums import ( 

10 LienPriorityType, 

11 RefinanceCashOutType, 

12 enum_choices, 

13 occupancy_choices, 

14 purpose_choices, 

15 stage_choices, 

16) 

17 

18 

19class Loan(models.Model): 

20 """Loan header aggregate root.""" 

21 

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

27 

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) 

88 

89 class Meta: 

90 db_table = "loans" 

91 indexes = [ 

92 models.Index(fields=["org_unit", "stage"]), 

93 models.Index(fields=["loan_officer"]), 

94 ] 

95 

96 def __str__(self) -> str: 

97 return self.arive_loan_no 

98 

99 

100class LoanTransaction(models.Model): 

101 """Financial transaction information (1:1 with Loan).""" 

102 

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 ) 

136 

137 class Meta: 

138 db_table = "loan_transactions" 

139 

140 def __str__(self) -> str: 

141 return f"Transaction for {self.loan_id}" 

142 

143 

144class SavedView(models.Model): 

145 """A staff user's saved pipeline scope+filter preset (/api/saved-views).""" 

146 

147 SCOPE_CHOICES = [("me", "me"), ("team", "team"), ("all", "all")] 

148 

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) 

157 

158 class Meta: 

159 db_table = "saved_views" 

160 

161 def __str__(self) -> str: 

162 return f"{self.name} (owner {self.owner_id})" 

163 

164 

165class BusinessContact(models.Model): 

166 """A referral/service partner (realtor, title, appraiser, ...), global, not loan-scoped.""" 

167 

168 CONTACT_TYPE_CHOICES = [ 

169 (t, t) 

170 for t in ( 

171 "Realtor", "Title", "Appraiser", "Insurance", "Attorney", "Builder", "Lender", "Other", 

172 ) 

173 ] 

174 

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) 

181 

182 class Meta: 

183 db_table = "business_contacts" 

184 

185 def __str__(self) -> str: 

186 return self.name 

187 

188 

189class LoanNumberSequence(models.Model): 

190 """Single-row counter generating `LMN-<n>` loan numbers (design.md D1).""" 

191 

192 next_value = models.PositiveIntegerField(default=10000) 

193 

194 class Meta: 

195 db_table = "loan_number_sequence" 

196 

197 def __str__(self) -> str: 

198 return f"next={self.next_value}" 

199 

200 

201def next_loan_number() -> str: 

202 """Atomically reserve and return the next `LMN-<n>` loan number. 

203 

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}" 

212 

213 

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

218 

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) 

222 

223 class Meta: 

224 db_table = "loan_stage_history" 

225 indexes = [models.Index(fields=["loan", "entered_at"])] 

226 

227 def __str__(self) -> str: 

228 return f"{self.loan_id}: {self.stage} @ {self.entered_at}" 

229 

230 

231class PreApproval(models.Model): 

232 """A generated pre-approval letter record (POST /api/loans/{guid}/preapproval). 

233 

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

237 

238 loan = models.ForeignKey(Loan, on_delete=models.CASCADE, related_name="preapprovals") 

239 generated_at = models.DateTimeField(auto_now_add=True) 

240 

241 class Meta: 

242 db_table = "preapprovals" 

243 

244 def __str__(self) -> str: 

245 return f"PreApproval for {self.loan_id} @ {self.generated_at}" 

246 

247 

248class Lead(models.Model): 

249 """A prospect before (or after) becoming a loan (/api/leads).""" 

250 

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 ] 

258 

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) 

271 

272 class Meta: 

273 db_table = "leads" 

274 

275 def __str__(self) -> str: 

276 return f"{self.first_name} {self.last_name} ({self.status})" 

277 

278 

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

283 

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) 

292 

293 class Meta: 

294 db_table = "custom_reports" 

295 

296 def __str__(self) -> str: 

297 return self.name 

298 

299 

300class CustomFieldDefinition(models.Model): 

301 """Organization-defined custom loan field.""" 

302 

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) 

310 

311 class Meta: 

312 db_table = "custom_field_definitions" 

313 ordering = ["id"] 

314 

315 def __str__(self) -> str: 

316 return f"{self.label} ({self.key})" 

317 

318 

319class LoanCustomFieldValues(models.Model): 

320 """Custom field value bag for a specific loan.""" 

321 

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) 

327 

328 class Meta: 

329 db_table = "loan_custom_field_values" 

330 

331 def __str__(self) -> str: 

332 return f"Custom fields for Loan {self.loan_id}" 

333 

334 

335class CustomTrackerDefinition(models.Model): 

336 """Organization-defined custom milestone tracker.""" 

337 

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) 

345 

346 class Meta: 

347 db_table = "custom_tracker_definitions" 

348 ordering = ["id"] 

349 

350 def __str__(self) -> str: 

351 return f"{self.label} ({self.key})" 

352