Coverage for apps/mismo/generator.py: 93%
310 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""MISMO 3.4 XML generator — builds a DEAL document from a loan's existing data.
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.
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"""
15from __future__ import annotations
17from lxml import etree
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
33MISMO_NS = "http://www.mismo.org/residential/2009/schemas"
34NSMAP = {None: MISMO_NS}
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}
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
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))
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
78# --- PARTY / BORROWER ---------------------------------------------------------------
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
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
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
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)
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)
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)
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)
224 role.append(role_borrower)
225 roles = _el("ROLES")
226 roles.append(role)
227 party.append(roles)
229 residences = _residences_el(list(borrower.residences.all()))
230 if residences is not None:
231 party.append(residences)
233 return party
236# --- COLLATERAL / PROPERTY ----------------------------------------------------------
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")
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)
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)
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)
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)
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)
288 collateral.append(subject)
289 return collateral
292# --- ASSET / LIABILITY / DOWN_PAYMENT ------------------------------------------------
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
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
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
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
356# --- LOAN / TERMS_OF_LOAN / LOAN_IDENTIFIERS -----------------------------------------
359def build_loan(
360 loan: Loan, txn: LoanTransaction | None, identifiers: list[LoanIdentifier]
361) -> etree._Element:
362 loan_el = _el("LOAN")
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)
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
396# --- DEAL assembly --------------------------------------------------------------------
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``.
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}")
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()
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)
448 deal_sets = _el("DEAL_SETS")
449 deal_set = _el("DEAL_SET")
450 deals = _el("DEALS")
451 deal = _el("DEAL")
453 parties = _el("PARTIES")
454 for b in borrowers:
455 parties.append(build_party(b))
456 deal.append(parties)
458 collateral = build_collateral(subject_property)
459 if collateral is not None:
460 collaterals = _el("COLLATERALS")
461 collaterals.append(collateral)
462 deal.append(collaterals)
464 assets_el = build_assets(assets)
465 if assets_el is not None:
466 deal.append(assets_el)
468 liabilities_el = build_liabilities(liabilities)
469 if liabilities_el is not None:
470 deal.append(liabilities_el)
472 down_payments_el = build_down_payments(down_payment_sources)
473 if down_payments_el is not None:
474 deal.append(down_payments_el)
476 gifts_el = build_gifts_or_grants(gifts_or_grants)
477 if gifts_el is not None:
478 deal.append(gifts_el)
480 loans = _el("LOANS")
481 loans.append(build_loan(loan, txn, loan_identifiers))
482 deal.append(loans)
484 deals.append(deal)
485 deal_set.append(deals)
486 deal_sets.append(deal_set)
487 message.append(deal_sets)
488 return message
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 )