Coverage for apps/mismo/generator.py: 93%

310 statements  

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

1"""MISMO 3.4 XML generator — builds a DEAL document from a loan's existing data. 

2 

3Container names and nesting follow the MISMO 3.4 (ReferenceModel v3.4.0 B324) field names 

4documented in ``details/08-data-model-gaps.md`` and ``arive-spec/16`` §16.5. **Unverified** 

5against a real Tireless-accepted sample or the official XSD (see proposal.md "Out of 

6scope" / design.md Risks — tracked, not a blocker). Each container is built by its own 

7function so a later correction against a real sample touches one function, not a rewrite. 

8 

9Every builder omits a container entirely when its source data does not exist, and omits an 

10optional field when its value is blank/None — booleans on a record that *does* exist are 

11always emitted, since ``False`` is a meaningful answer, not a gap (design.md D4 / spec 

12"Export never blocks on missing data"). 

13""" 

14 

15from __future__ import annotations 

16 

17from lxml import etree 

18 

19from apps.application_1003.models import ( 

20 Asset, 

21 Borrower, 

22 Declaration, 

23 Demographics, 

24 DownPaymentSource, 

25 GiftGrant, 

26 Liability, 

27 LoanIdentifier, 

28 Residence, 

29 SubjectProperty, 

30) 

31from apps.core_los.models import Loan, LoanTransaction 

32 

33MISMO_NS = "http://www.mismo.org/residential/2009/schemas" 

34NSMAP = {None: MISMO_NS} 

35 

36TARGETS = ("GENERIC", "DU", "LPA") 

37_DATA_VERSION_NAMES = { 

38 "GENERIC": "Lumen LOS Interchange", 

39 "DU": "Fannie Mae Desktop Underwriter Automated Underwriting Credit Data", 

40 "LPA": "Freddie Mac Loan Product Advisor Automated Underwriting Credit Data", 

41} 

42 

43 

44def _el(tag: str, *, text: str | None = None) -> etree._Element: 

45 """Create an element in the MISMO namespace (Clark notation) — ``nsmap`` is only 

46 applied once, at the root, in ``generate_mismo_xml`` via ``cleanup_namespaces``, so 

47 the serialized document declares the default namespace once rather than on every 

48 element.""" 

49 e = etree.Element(f"{{{MISMO_NS}}}{tag}") 

50 if text is not None and text != "": 

51 e.text = str(text) 

52 return e 

53 

54 

55def _sub(parent: etree._Element, tag: str, value, *, always: bool = False) -> None: 

56 """Append ``tag`` to ``parent`` with ``value`` as text, unless ``value`` is blank/None 

57 and ``always`` is False.""" 

58 if not always and (value is None or value == ""): 

59 return 

60 text = "true" if value is True else "false" if value is False else value 

61 parent.append(_el(tag, text=text)) 

62 

63 

64def _address(tag: str, *, line="", unit="", city="", county="", state="", postal="", country="US"): 

65 if not any([line, unit, city, county, state, postal]): 

66 return None 

67 addr = _el(tag) 

68 _sub(addr, "AddressLineText", line) 

69 _sub(addr, "AddressUnitIdentifier", unit) 

70 _sub(addr, "CityName", city) 

71 _sub(addr, "CountyName", county) 

72 _sub(addr, "StateCode", state) 

73 _sub(addr, "PostalCode", postal) 

74 _sub(addr, "CountryCode", country) 

75 return addr 

76 

77 

78# --- PARTY / BORROWER --------------------------------------------------------------- 

79 

80 

81def _declaration_el(decl: Declaration | None) -> etree._Element | None: 

82 if decl is None: 

83 return None 

84 d = _el("DECLARATION") 

85 dd = _el("DECLARATION_DETAIL") 

86 _sub(dd, "BankruptcyIndicator", decl.bankruptcy_ind, always=True) 

87 _sub(dd, "BankruptcyChapterType", decl.bankruptcy_chapter) 

88 _sub(dd, "PriorPropertyForeclosureCompletedIndicator", decl.prior_foreclosure_ind, always=True) 

89 _sub(dd, "PriorPropertyDeedInLieuConveyedIndicator", decl.prior_deed_in_lieu_ind, always=True) 

90 _sub(dd, "PriorPropertyShortSaleCompletedIndicator", decl.prior_short_sale_ind, always=True) 

91 _sub(dd, "OutstandingJudgmentsIndicator", decl.outstanding_judgments_ind, always=True) 

92 _sub(dd, "PresentlyDelinquentIndicator", decl.delinquent_federal_debt_ind, always=True) 

93 _sub(dd, "PartyToLawsuitIndicator", decl.party_to_lawsuit_ind, always=True) 

94 _sub(dd, "AlimonyChildSupportObligationIndicator", decl.alimony_child_support_ind, always=True) 

95 _sub(dd, "UndisclosedBorrowedFundsIndicator", decl.undisclosed_borrowed_funds_ind, always=True) 

96 _sub(dd, "UndisclosedBorrowedFundsAmount", decl.undisclosed_borrowed_funds_amt) 

97 _sub( 

98 dd, "UndisclosedMortgageApplicationIndicator", decl.undisclosed_mortgage_app_ind, 

99 always=True, 

100 ) 

101 _sub(dd, "UndisclosedCreditApplicationIndicator", decl.undisclosed_credit_app_ind, always=True) 

102 _sub(dd, "CoMakerEndorserOfNoteIndicator", decl.comaker_endorser_ind, always=True) 

103 _sub(dd, "IntentToOccupyType", decl.intent_to_occupy_type) 

104 _sub( 

105 dd, "HomeownerPastThreeYearsIndicator", decl.owned_property_past_three_years_ind, 

106 always=True, 

107 ) 

108 _sub(dd, "PriorPropertyUsageType", decl.prior_property_usage_type) 

109 _sub(dd, "CitizenshipResidencyType", decl.citizen_residency_type) 

110 _sub(dd, "FHASecondaryResidenceIndicator", decl.fha_secondary_residence_ind, always=True) 

111 _sub(dd, "PropertyProposedCleanEnergyLienIndicator", decl.clean_energy_lien_ind, always=True) 

112 _sub(dd, "BorrowerFirstTimeHomebuyerIndicator", decl.first_time_homebuyer_ind, always=True) 

113 d.append(dd) 

114 if decl.explanations: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 explanations = _el("DECLARATION_EXPLANATIONS") 

116 for text in decl.explanations: 

117 explanations.append(_el("DECLARATION_EXPLANATION", text=text)) 

118 d.append(explanations) 

119 return d 

120 

121 

122def _government_monitoring_el(demo: Demographics | None) -> etree._Element | None: 

123 if demo is None: 

124 return None 

125 gm = _el("GOVERNMENT_MONITORING") 

126 gmd = _el("GOVERNMENT_MONITORING_DETAIL") 

127 for e in demo.ethnicity: 127 ↛ 128line 127 didn't jump to line 128 because the loop on line 127 never started

128 gmd.append(_el("HMDAEthnicityType", text=e)) 

129 for h in demo.hispanic_origins: 129 ↛ 130line 129 didn't jump to line 130 because the loop on line 129 never started

130 gmd.append(_el("HMDAEthnicityOriginType", text=h)) 

131 for r in demo.race: 

132 gmd.append(_el("HMDARaceType", text=r)) 

133 _sub(gmd, "GenderType", demo.sex) 

134 _sub(gmd, "HMDAEthnicityRefusalIndicator", demo.ethnicity_refusal_ind, always=True) 

135 _sub(gmd, "HMDARaceRefusalIndicator", demo.race_refusal_ind, always=True) 

136 _sub(gmd, "HMDAGenderRefusalIndicator", demo.sex_refusal_ind, always=True) 

137 _sub( 

138 gmd, 

139 "HMDAEthnicityCollectedBasedOnVisualObservationOrSurnameIndicator", 

140 demo.ethnicity_visual_ind, 

141 always=True, 

142 ) 

143 _sub( 

144 gmd, 

145 "HMDARaceCollectedBasedOnVisualObservationOrSurnameIndicator", 

146 demo.race_visual_ind, 

147 always=True, 

148 ) 

149 _sub( 

150 gmd, 

151 "HMDASexCollectedBasedOnVisualObservationOrSurnameIndicator", 

152 demo.sex_visual_ind, 

153 always=True, 

154 ) 

155 _sub(gmd, "ApplicationTakenMethodType", demo.application_taken_method) 

156 gm.append(gmd) 

157 return gm 

158 

159 

160def _residences_el(residences: list[Residence]) -> etree._Element | None: 

161 if not residences: 

162 return None 

163 container = _el("RESIDENCES") 

164 for r in residences: 

165 res = _el("RESIDENCE") 

166 rd = _el("RESIDENCE_DETAIL") 

167 _sub(rd, "BorrowerResidencyType", r.residence_type) 

168 _sub(rd, "BorrowerResidencyBasisType", r.basis_type) 

169 _sub(rd, "BorrowerResidencyDurationMonthsCount", r.duration_months) 

170 _sub(rd, "MonthlyRentAmount", r.monthly_rent_amt) 

171 res.append(rd) 

172 addr = _address( 

173 "ADDRESS", 

174 line=r.address_line, 

175 unit=r.unit_id, 

176 city=r.city, 

177 county=r.county, 

178 state=r.state_code, 

179 postal=r.postal_code, 

180 country=r.country_code or "US", 

181 ) 

182 if addr is not None: 

183 res.append(addr) 

184 container.append(res) 

185 return container 

186 

187 

188def build_party(borrower: Borrower) -> etree._Element: 

189 party = _el("PARTY") 

190 individual = _el("INDIVIDUAL") 

191 name = _el("NAME") 

192 _sub(name, "FirstName", borrower.first_name, always=True) 

193 _sub(name, "MiddleName", borrower.middle_name) 

194 _sub(name, "LastName", borrower.last_name, always=True) 

195 individual.append(name) 

196 party.append(individual) 

197 

198 taxpayer_identifiers = _el("TAXPAYER_IDENTIFIERS") 

199 taxpayer_identifier = _el("TAXPAYER_IDENTIFIER") 

200 _sub( 

201 taxpayer_identifier, 

202 "TaxpayerIdentifierType", 

203 "ITIN" if borrower.itin_ssn_ind else "SocialSecurityNumber", 

204 always=True, 

205 ) 

206 taxpayer_identifiers.append(taxpayer_identifier) 

207 party.append(taxpayer_identifiers) 

208 

209 role = _el("ROLE") 

210 role_borrower = _el("BORROWER") 

211 borrower_detail = _el("BORROWER_DETAIL") 

212 _sub(borrower_detail, "BorrowerClassificationType", borrower.classification, always=True) 

213 _sub(borrower_detail, "BorrowerCitizenshipResidencyType", borrower.citizen_residency_type) 

214 _sub(borrower_detail, "PartyRoleType", borrower.role, always=True) 

215 role_borrower.append(borrower_detail) 

216 

217 decl = _declaration_el(getattr(borrower, "declaration", None)) 

218 if decl is not None: 

219 role_borrower.append(decl) 

220 gm = _government_monitoring_el(getattr(borrower, "demographics", None)) 

221 if gm is not None: 

222 role_borrower.append(gm) 

223 

224 role.append(role_borrower) 

225 roles = _el("ROLES") 

226 roles.append(role) 

227 party.append(roles) 

228 

229 residences = _residences_el(list(borrower.residences.all())) 

230 if residences is not None: 

231 party.append(residences) 

232 

233 return party 

234 

235 

236# --- COLLATERAL / PROPERTY ---------------------------------------------------------- 

237 

238 

239def build_collateral(subject_property: SubjectProperty | None) -> etree._Element | None: 

240 if subject_property is None: 

241 return None 

242 sp = subject_property 

243 collateral = _el("COLLATERAL") 

244 subject = _el("SUBJECT_PROPERTY") 

245 

246 addr = _address( 

247 "ADDRESS", 

248 line=sp.address_line, 

249 unit=sp.unit_id, 

250 city=sp.city, 

251 county=sp.county, 

252 state=sp.state_code, 

253 postal=sp.postal_code, 

254 ) 

255 if addr is not None: 255 ↛ 258line 255 didn't jump to line 258 because the condition on line 255 was always true

256 subject.append(addr) 

257 

258 detail = _el("PROPERTY_DETAIL") 

259 _sub(detail, "PropertyType", sp.property_type) 

260 _sub(detail, "PropertyEstateType", sp.estate_type) 

261 _sub(detail, "AttachmentType", sp.attachment_type) 

262 _sub(detail, "FinancedUnitCount", sp.financed_unit_count, always=True) 

263 _sub(detail, "ConstructionMethodType", sp.construction_method) 

264 _sub(detail, "ConstructionStatusType", sp.construction_status) 

265 _sub(detail, "PropertyEstimatedValueAmount", sp.estimated_value_amt) 

266 subject.append(detail) 

267 

268 if sp.project_legal_structure_type or sp.project_name: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 project = _el("PROJECT") 

270 _sub(project, "ProjectLegalStructureType", sp.project_legal_structure_type) 

271 _sub(project, "ProjectName", sp.project_name) 

272 _sub(project, "ProjectStoriesCount", sp.stories_count) 

273 _sub(project, "HOADuesAmount", sp.hoa_dues_amt) 

274 _sub(project, "ProjectNonWarrantableIndicator", sp.non_warrantable_ind, always=True) 

275 subject.append(project) 

276 

277 if sp.apn_identifier or sp.legal_description: 

278 legal = _el("PROPERTY_LEGAL_DESCRIPTION") 

279 _sub(legal, "ParcelIdentifier", sp.apn_identifier) 

280 _sub(legal, "LegalDescriptionText", sp.legal_description) 

281 subject.append(legal) 

282 

283 if sp.flood_zone: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 flood = _el("FLOOD_DETERMINATION") 

285 _sub(flood, "FloodZoneDesignationType", sp.flood_zone) 

286 subject.append(flood) 

287 

288 collateral.append(subject) 

289 return collateral 

290 

291 

292# --- ASSET / LIABILITY / DOWN_PAYMENT ------------------------------------------------ 

293 

294 

295def build_assets(assets: list[Asset]) -> etree._Element | None: 

296 if not assets: 

297 return None 

298 container = _el("ASSETS") 

299 for a in assets: 

300 asset = _el("ASSET") 

301 detail = _el("ASSET_DETAIL") 

302 _sub(detail, "AssetType", a.asset_type, always=True) 

303 _sub(detail, "AssetCashOrMarketValueAmount", a.cash_or_market_value_amt, always=True) 

304 _sub(detail, "FinancialInstitutionName", a.institution_name) 

305 _sub(detail, "AccountIdentifier", a.account_number) 

306 asset.append(detail) 

307 container.append(asset) 

308 return container 

309 

310 

311def build_liabilities(liabilities: list[Liability]) -> etree._Element | None: 

312 if not liabilities: 

313 return None 

314 container = _el("LIABILITIES") 

315 for liab in liabilities: 

316 liability = _el("LIABILITY") 

317 detail = _el("LIABILITY_DETAIL") 

318 _sub(detail, "LiabilityType", liab.liability_type, always=True) 

319 _sub(detail, "CreditorName", liab.creditor_name) 

320 _sub(detail, "MonthlyPaymentAmount", liab.monthly_payment_amt, always=True) 

321 _sub(detail, "UnpaidBalanceAmount", liab.unpaid_balance_amt, always=True) 

322 _sub(detail, "LiabilityExclusionIndicator", liab.exclude_from_dti_ind, always=True) 

323 liability.append(detail) 

324 container.append(liability) 

325 return container 

326 

327 

328def build_down_payments(sources: list[DownPaymentSource]) -> etree._Element | None: 

329 if not sources: 

330 return None 

331 container = _el("DOWN_PAYMENTS") 

332 for s in sources: 

333 dp = _el("DOWN_PAYMENT") 

334 _sub(dp, "DownPaymentSourceType", s.funds_source_type, always=True) 

335 _sub(dp, "DownPaymentAmount", s.amount, always=True) 

336 _sub(dp, "DownPaymentDescription", s.description) 

337 container.append(dp) 

338 return container 

339 

340 

341def build_gifts_or_grants(gifts: list[GiftGrant]) -> etree._Element | None: 

342 if not gifts: 

343 return None 

344 container = _el("GIFTS_OR_GRANTS") 

345 for g in gifts: 

346 gog = _el("GIFT_OR_GRANT") 

347 _sub(gog, "DonorName", g.donor_name) 

348 _sub(gog, "GiftOrGrantSourceType", g.source_type) 

349 _sub(gog, "GiftOrGrantAmount", g.amount, always=True) 

350 _sub(gog, "GiftReceivedIndicator", g.received_ind, always=True) 

351 _sub(gog, "GiftDepositedIndicator", g.deposited_ind, always=True) 

352 container.append(gog) 

353 return container 

354 

355 

356# --- LOAN / TERMS_OF_LOAN / LOAN_IDENTIFIERS ----------------------------------------- 

357 

358 

359def build_loan( 

360 loan: Loan, txn: LoanTransaction | None, identifiers: list[LoanIdentifier] 

361) -> etree._Element: 

362 loan_el = _el("LOAN") 

363 

364 if identifiers: 

365 container = _el("LOAN_IDENTIFIERS") 

366 for ident in identifiers: 

367 li = _el("LOAN_IDENTIFIER") 

368 _sub(li, "LoanIdentifierType", ident.identifier_type, always=True) 

369 _sub(li, "LoanIdentifier", ident.value, always=True) 

370 container.append(li) 

371 loan_el.append(container) 

372 

373 terms = _el("TERMS_OF_LOAN") 

374 _sub(terms, "LoanPurposeType", loan.purpose, always=True) 

375 _sub(terms, "MortgageType", txn.mortgage_type if txn else None) 

376 _sub(terms, "AmortizationType", txn.amortization_type if txn else None) 

377 _sub(terms, "BaseLoanAmount", txn.base_loan_amt if txn else None) 

378 _sub(terms, "NoteAmount", txn.note_amt if txn else None) 

379 _sub(terms, "NoteRatePercent", txn.note_rate_percent if txn else None) 

380 _sub(terms, "LoanMaturityPeriodCount", txn.term_months if txn else None) 

381 _sub(terms, "LTVRatioPercent", txn.ltv_ratio_percent if txn else None) 

382 _sub(terms, "CLTVRatioPercent", txn.cltv_ratio_percent if txn else None) 

383 _sub(terms, "HCLTVRatioPercent", txn.hcltv_ratio_percent if txn else None) 

384 _sub(terms, "LienPriorityType", txn.lien_priority_type if txn else None, always=bool(txn)) 

385 _sub( 

386 terms, 

387 "TotalSubordinateFinancingAmount", 

388 txn.total_subordinate_financing_amt if txn else None, 

389 ) 

390 _sub(terms, "RefinanceCashOutDeterminationType", txn.refinance_cash_out_type if txn else None) 

391 _sub(terms, "RefinanceCashOutAmount", txn.refinance_cash_out_amt if txn else None) 

392 loan_el.append(terms) 

393 return loan_el 

394 

395 

396# --- DEAL assembly -------------------------------------------------------------------- 

397 

398 

399def build_mismo_deal( 

400 loan: Loan, 

401 *, 

402 target: str = "GENERIC", 

403 borrowers: list[Borrower] | None = None, 

404 txn: LoanTransaction | None = None, 

405 subject_property: SubjectProperty | None = None, 

406 assets: list[Asset] | None = None, 

407 liabilities: list[Liability] | None = None, 

408 down_payment_sources: list[DownPaymentSource] | None = None, 

409 gifts_or_grants: list[GiftGrant] | None = None, 

410 loan_identifiers: list[LoanIdentifier] | None = None, 

411) -> etree._Element: 

412 """Assemble the full MISMO ``MESSAGE`` document for ``loan``. 

413 

414 Every collection argument defaults to querying the loan's related data; callers (tests, 

415 the API layer) may pass them explicitly to avoid extra queries or to exercise a specific 

416 scenario. 

417 """ 

418 if target not in TARGETS: 

419 raise ValueError(f"Unknown MISMO export target: {target!r}") 

420 

421 borrowers = loan.borrowers.all() if borrowers is None else borrowers 

422 assets = list(loan.assets.all()) if assets is None else assets 

423 liabilities = list(loan.liabilities.all()) if liabilities is None else liabilities 

424 down_payment_sources = ( 

425 list(loan.down_payment_sources.all()) 

426 if down_payment_sources is None 

427 else down_payment_sources 

428 ) 

429 gifts_or_grants = ( 

430 list(loan.gift_grants.all()) if gifts_or_grants is None else gifts_or_grants 

431 ) 

432 loan_identifiers = ( 

433 list(loan.loan_identifiers.all()) if loan_identifiers is None else loan_identifiers 

434 ) 

435 if subject_property is None: 435 ↛ 437line 435 didn't jump to line 437 because the condition on line 435 was always true

436 subject_property = SubjectProperty.objects.filter(loan=loan).first() 

437 if txn is None: 437 ↛ 440line 437 didn't jump to line 440 because the condition on line 437 was always true

438 txn = LoanTransaction.objects.filter(loan=loan).first() 

439 

440 message = _el("MESSAGE") 

441 about_versions = _el("ABOUT_VERSIONS") 

442 about_version = _el("ABOUT_VERSION") 

443 _sub(about_version, "DataVersionIdentifier", "3.4.0", always=True) 

444 _sub(about_version, "DataVersionName", _DATA_VERSION_NAMES[target], always=True) 

445 about_versions.append(about_version) 

446 message.append(about_versions) 

447 

448 deal_sets = _el("DEAL_SETS") 

449 deal_set = _el("DEAL_SET") 

450 deals = _el("DEALS") 

451 deal = _el("DEAL") 

452 

453 parties = _el("PARTIES") 

454 for b in borrowers: 

455 parties.append(build_party(b)) 

456 deal.append(parties) 

457 

458 collateral = build_collateral(subject_property) 

459 if collateral is not None: 

460 collaterals = _el("COLLATERALS") 

461 collaterals.append(collateral) 

462 deal.append(collaterals) 

463 

464 assets_el = build_assets(assets) 

465 if assets_el is not None: 

466 deal.append(assets_el) 

467 

468 liabilities_el = build_liabilities(liabilities) 

469 if liabilities_el is not None: 

470 deal.append(liabilities_el) 

471 

472 down_payments_el = build_down_payments(down_payment_sources) 

473 if down_payments_el is not None: 

474 deal.append(down_payments_el) 

475 

476 gifts_el = build_gifts_or_grants(gifts_or_grants) 

477 if gifts_el is not None: 

478 deal.append(gifts_el) 

479 

480 loans = _el("LOANS") 

481 loans.append(build_loan(loan, txn, loan_identifiers)) 

482 deal.append(loans) 

483 

484 deals.append(deal) 

485 deal_set.append(deals) 

486 deal_sets.append(deal_set) 

487 message.append(deal_sets) 

488 return message 

489 

490 

491def generate_mismo_xml(loan: Loan, *, target: str = "GENERIC") -> bytes: 

492 """Serialize ``build_mismo_deal`` to UTF-8 MISMO XML bytes.""" 

493 message = build_mismo_deal(loan, target=target) 

494 etree.cleanup_namespaces(message, top_nsmap=NSMAP) 

495 return etree.tostring( 

496 message, xml_declaration=True, encoding="UTF-8", pretty_print=True 

497 )