@uwmd/core 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +79 -0
  2. package/dist/agents/bancroft.d.ts +32 -0
  3. package/dist/agents/bancroft.d.ts.map +1 -0
  4. package/dist/agents/bancroft.js +292 -0
  5. package/dist/agents/bancroft.js.map +1 -0
  6. package/dist/agents/schemas.d.ts +13 -0
  7. package/dist/agents/schemas.d.ts.map +1 -0
  8. package/dist/agents/schemas.js +74 -0
  9. package/dist/agents/schemas.js.map +1 -0
  10. package/dist/bindings.d.ts +85 -0
  11. package/dist/bindings.d.ts.map +1 -0
  12. package/dist/bindings.js +293 -0
  13. package/dist/bindings.js.map +1 -0
  14. package/dist/browser.d.ts +66 -0
  15. package/dist/browser.d.ts.map +1 -0
  16. package/dist/browser.js +53 -0
  17. package/dist/browser.js.map +1 -0
  18. package/dist/calc/builtins.d.ts +4 -0
  19. package/dist/calc/builtins.d.ts.map +1 -0
  20. package/dist/calc/builtins.js +329 -0
  21. package/dist/calc/builtins.js.map +1 -0
  22. package/dist/calc/dependencies.d.ts +34 -0
  23. package/dist/calc/dependencies.d.ts.map +1 -0
  24. package/dist/calc/dependencies.js +101 -0
  25. package/dist/calc/dependencies.js.map +1 -0
  26. package/dist/calc/errors.d.ts +8 -0
  27. package/dist/calc/errors.d.ts.map +1 -0
  28. package/dist/calc/errors.js +18 -0
  29. package/dist/calc/errors.js.map +1 -0
  30. package/dist/calc/evaluator.d.ts +5 -0
  31. package/dist/calc/evaluator.d.ts.map +1 -0
  32. package/dist/calc/evaluator.js +184 -0
  33. package/dist/calc/evaluator.js.map +1 -0
  34. package/dist/calc/index.d.ts +14 -0
  35. package/dist/calc/index.d.ts.map +1 -0
  36. package/dist/calc/index.js +56 -0
  37. package/dist/calc/index.js.map +1 -0
  38. package/dist/calc/parser.d.ts +32 -0
  39. package/dist/calc/parser.d.ts.map +1 -0
  40. package/dist/calc/parser.js +361 -0
  41. package/dist/calc/parser.js.map +1 -0
  42. package/dist/cascade.d.ts +85 -0
  43. package/dist/cascade.d.ts.map +1 -0
  44. package/dist/cascade.js +172 -0
  45. package/dist/cascade.js.map +1 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.d.ts.map +1 -0
  48. package/dist/cli.js +843 -0
  49. package/dist/cli.js.map +1 -0
  50. package/dist/codec.d.ts +25 -0
  51. package/dist/codec.d.ts.map +1 -0
  52. package/dist/codec.js +62 -0
  53. package/dist/codec.js.map +1 -0
  54. package/dist/codecs.d.ts +6 -0
  55. package/dist/codecs.d.ts.map +1 -0
  56. package/dist/codecs.js +12 -0
  57. package/dist/codecs.js.map +1 -0
  58. package/dist/compactor.d.ts +11 -0
  59. package/dist/compactor.d.ts.map +1 -0
  60. package/dist/compactor.js +84 -0
  61. package/dist/compactor.js.map +1 -0
  62. package/dist/context-profiles.d.ts +30 -0
  63. package/dist/context-profiles.d.ts.map +1 -0
  64. package/dist/context-profiles.js +225 -0
  65. package/dist/context-profiles.js.map +1 -0
  66. package/dist/context.d.ts +55 -0
  67. package/dist/context.d.ts.map +1 -0
  68. package/dist/context.js +288 -0
  69. package/dist/context.js.map +1 -0
  70. package/dist/dcf.d.ts +9 -0
  71. package/dist/dcf.d.ts.map +1 -0
  72. package/dist/dcf.js +75 -0
  73. package/dist/dcf.js.map +1 -0
  74. package/dist/debt.d.ts +11 -0
  75. package/dist/debt.d.ts.map +1 -0
  76. package/dist/debt.js +85 -0
  77. package/dist/debt.js.map +1 -0
  78. package/dist/defaults.d.ts +104 -0
  79. package/dist/defaults.d.ts.map +1 -0
  80. package/dist/defaults.js +622 -0
  81. package/dist/defaults.js.map +1 -0
  82. package/dist/derived.d.ts +17 -0
  83. package/dist/derived.d.ts.map +1 -0
  84. package/dist/derived.js +29 -0
  85. package/dist/derived.js.map +1 -0
  86. package/dist/editor.d.ts +76 -0
  87. package/dist/editor.d.ts.map +1 -0
  88. package/dist/editor.js +502 -0
  89. package/dist/editor.js.map +1 -0
  90. package/dist/envelope.d.ts +51 -0
  91. package/dist/envelope.d.ts.map +1 -0
  92. package/dist/envelope.js +213 -0
  93. package/dist/envelope.js.map +1 -0
  94. package/dist/format.d.ts +60 -0
  95. package/dist/format.d.ts.map +1 -0
  96. package/dist/format.js +115 -0
  97. package/dist/format.js.map +1 -0
  98. package/dist/gaps.d.ts +62 -0
  99. package/dist/gaps.d.ts.map +1 -0
  100. package/dist/gaps.js +272 -0
  101. package/dist/gaps.js.map +1 -0
  102. package/dist/index.d.ts +75 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +47 -0
  105. package/dist/index.js.map +1 -0
  106. package/dist/init.d.ts +15 -0
  107. package/dist/init.d.ts.map +1 -0
  108. package/dist/init.js +169 -0
  109. package/dist/init.js.map +1 -0
  110. package/dist/integrity-canonical.d.ts +17 -0
  111. package/dist/integrity-canonical.d.ts.map +1 -0
  112. package/dist/integrity-canonical.js +124 -0
  113. package/dist/integrity-canonical.js.map +1 -0
  114. package/dist/integrity.d.ts +54 -0
  115. package/dist/integrity.d.ts.map +1 -0
  116. package/dist/integrity.js +244 -0
  117. package/dist/integrity.js.map +1 -0
  118. package/dist/modules.d.ts +24 -0
  119. package/dist/modules.d.ts.map +1 -0
  120. package/dist/modules.js +322 -0
  121. package/dist/modules.js.map +1 -0
  122. package/dist/negotiation.d.ts +19 -0
  123. package/dist/negotiation.d.ts.map +1 -0
  124. package/dist/negotiation.js +70 -0
  125. package/dist/negotiation.js.map +1 -0
  126. package/dist/opstatement.d.ts +9 -0
  127. package/dist/opstatement.d.ts.map +1 -0
  128. package/dist/opstatement.js +80 -0
  129. package/dist/opstatement.js.map +1 -0
  130. package/dist/packs/excel-emit.d.ts +21 -0
  131. package/dist/packs/excel-emit.d.ts.map +1 -0
  132. package/dist/packs/excel-emit.js +135 -0
  133. package/dist/packs/excel-emit.js.map +1 -0
  134. package/dist/packs/index.d.ts +13 -0
  135. package/dist/packs/index.d.ts.map +1 -0
  136. package/dist/packs/index.js +37 -0
  137. package/dist/packs/index.js.map +1 -0
  138. package/dist/packs/industrial.d.ts +3 -0
  139. package/dist/packs/industrial.d.ts.map +1 -0
  140. package/dist/packs/industrial.js +115 -0
  141. package/dist/packs/industrial.js.map +1 -0
  142. package/dist/packs/multifamily.d.ts +3 -0
  143. package/dist/packs/multifamily.d.ts.map +1 -0
  144. package/dist/packs/multifamily.js +82 -0
  145. package/dist/packs/multifamily.js.map +1 -0
  146. package/dist/packs/office.d.ts +3 -0
  147. package/dist/packs/office.d.ts.map +1 -0
  148. package/dist/packs/office.js +108 -0
  149. package/dist/packs/office.js.map +1 -0
  150. package/dist/packs/retail.d.ts +3 -0
  151. package/dist/packs/retail.d.ts.map +1 -0
  152. package/dist/packs/retail.js +115 -0
  153. package/dist/packs/retail.js.map +1 -0
  154. package/dist/packs/self-storage.d.ts +3 -0
  155. package/dist/packs/self-storage.d.ts.map +1 -0
  156. package/dist/packs/self-storage.js +105 -0
  157. package/dist/packs/self-storage.js.map +1 -0
  158. package/dist/parser.d.ts +15 -0
  159. package/dist/parser.d.ts.map +1 -0
  160. package/dist/parser.js +430 -0
  161. package/dist/parser.js.map +1 -0
  162. package/dist/protocol.d.ts +389 -0
  163. package/dist/protocol.d.ts.map +1 -0
  164. package/dist/protocol.js +714 -0
  165. package/dist/protocol.js.map +1 -0
  166. package/dist/refinement.d.ts +63 -0
  167. package/dist/refinement.d.ts.map +1 -0
  168. package/dist/refinement.js +242 -0
  169. package/dist/refinement.js.map +1 -0
  170. package/dist/renderer.d.ts +22 -0
  171. package/dist/renderer.d.ts.map +1 -0
  172. package/dist/renderer.js +478 -0
  173. package/dist/renderer.js.map +1 -0
  174. package/dist/rentroll.d.ts +21 -0
  175. package/dist/rentroll.d.ts.map +1 -0
  176. package/dist/rentroll.js +256 -0
  177. package/dist/rentroll.js.map +1 -0
  178. package/dist/report.d.ts +29 -0
  179. package/dist/report.d.ts.map +1 -0
  180. package/dist/report.js +924 -0
  181. package/dist/report.js.map +1 -0
  182. package/dist/runner.d.ts +43 -0
  183. package/dist/runner.d.ts.map +1 -0
  184. package/dist/runner.js +288 -0
  185. package/dist/runner.js.map +1 -0
  186. package/dist/sourcesuses.d.ts +14 -0
  187. package/dist/sourcesuses.d.ts.map +1 -0
  188. package/dist/sourcesuses.js +63 -0
  189. package/dist/sourcesuses.js.map +1 -0
  190. package/dist/types.d.ts +248 -0
  191. package/dist/types.d.ts.map +1 -0
  192. package/dist/types.js +15 -0
  193. package/dist/types.js.map +1 -0
  194. package/dist/uwcsv.d.ts +23 -0
  195. package/dist/uwcsv.d.ts.map +1 -0
  196. package/dist/uwcsv.js +877 -0
  197. package/dist/uwcsv.js.map +1 -0
  198. package/dist/uwjson.d.ts +19 -0
  199. package/dist/uwjson.d.ts.map +1 -0
  200. package/dist/uwjson.js +61 -0
  201. package/dist/uwjson.js.map +1 -0
  202. package/dist/uwxml.d.ts +18 -0
  203. package/dist/uwxml.d.ts.map +1 -0
  204. package/dist/uwxml.js +297 -0
  205. package/dist/uwxml.js.map +1 -0
  206. package/dist/validator.d.ts +35 -0
  207. package/dist/validator.d.ts.map +1 -0
  208. package/dist/validator.js +553 -0
  209. package/dist/validator.js.map +1 -0
  210. package/dist/valuation.d.ts +9 -0
  211. package/dist/valuation.d.ts.map +1 -0
  212. package/dist/valuation.js +34 -0
  213. package/dist/valuation.js.map +1 -0
  214. package/package.json +58 -0
package/dist/report.js ADDED
@@ -0,0 +1,924 @@
1
+ // HTML report renderer — the "lender-ready package" rendering target.
2
+ // Implements UW_FORMAT_SPEC_v1.md §7.1 (Tier 1 Lender Package) and §7.2
3
+ // (Tier 2 Analyst Credit Memo) as a single self-contained HTML document with
4
+ // embedded print CSS, so one template serves web preview, browser print, and
5
+ // the @uwmd/report PDF pipeline.
6
+ //
7
+ // Pure string generation: no DOM, no I/O, no dependencies — browser-safe.
8
+ // Every number is read from the file (engine/pack output); nothing is computed
9
+ // here beyond simple per-unit display divisions.
10
+ import { getSection, getSectionVariant, deepGet } from './parser.js';
11
+ import { validateUWFile } from './validator.js';
12
+ import { formatCurrency, formatPercent, formatRatio, formatValue, } from './format.js';
13
+ const STANDARD_DISCLAIMER = 'This package was prepared from the deal’s canonical .uw.md underwriting record. ' +
14
+ 'All financial metrics are deterministic calculation-engine outputs; narrative sections ' +
15
+ 'reflect analyst and agent commentary recorded in the file with full provenance. ' +
16
+ 'Figures are presented for lending discussion purposes only and do not constitute an ' +
17
+ 'offer, commitment, or appraisal. Values marked unverified are stated by the borrower ' +
18
+ 'and have not been independently confirmed.';
19
+ /** Render the lender package / credit memo HTML for a parsed .uw.md file. */
20
+ export function renderReportHtml(parsed, opts = {}) {
21
+ const fm = parsed.frontmatter;
22
+ const tier = opts.tier ?? (fm.tier === 'analyst' ? 'analyst' : 'screener');
23
+ const title = String(fm.deal_name ?? fm.deal_id ?? 'Underwriting Package');
24
+ const builders = [
25
+ ['cover', coverPage],
26
+ ['executive_summary', executiveSummary],
27
+ ['property', propertyOverview],
28
+ ['noi_model', proforma],
29
+ ['rent_roll', rentRollSummary],
30
+ ['debt_structure', debtStructure],
31
+ ['sources_uses', sourcesUses],
32
+ ['borrower_sponsor', borrowerSummary],
33
+ ['exit_analysis', exitAnalysis],
34
+ ];
35
+ if (tier === 'analyst') {
36
+ builders.push(['market_analysis', marketAnalysis], ['financial_analysis', financialAnalysis], ['due_diligence', dueDiligence], ['risk_assessment', riskAssessment], ['compliance', complianceSummary], ['covenants', covenants]);
37
+ }
38
+ builders.push(['assumptions', assumptionsDisclosures]);
39
+ if (tier === 'analyst')
40
+ builders.push(['appendix', appendix]);
41
+ const ctx = { parsed, fm, tier, opts };
42
+ const sectionsRendered = [];
43
+ const sectionsSkipped = [];
44
+ const body = [];
45
+ for (const [id, build] of builders) {
46
+ const html = build(ctx);
47
+ if (html) {
48
+ sectionsRendered.push(id);
49
+ body.push(html);
50
+ }
51
+ else {
52
+ sectionsSkipped.push(id);
53
+ }
54
+ }
55
+ const article = `<article class="uwmd-report" data-tier="${esc(tier)}">\n${body.join('\n')}\n</article>`;
56
+ const html = opts.fragment
57
+ ? article
58
+ : `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>${esc(title)} — ${tier === 'analyst' ? 'Credit Memo' : 'Lender Package'}</title>
64
+ <style>${REPORT_CSS}</style>
65
+ </head>
66
+ <body>
67
+ ${article}
68
+ </body>
69
+ </html>`;
70
+ return { html, title, tier, sectionsRendered, sectionsSkipped };
71
+ }
72
+ function esc(value) {
73
+ return String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
74
+ }
75
+ const money = (v) => formatCurrency(v);
76
+ const pct = (v) => formatPercent(v);
77
+ const ratio = (v, dec = 2) => formatRatio(v, { decimals: dec });
78
+ const val = (v) => formatValue(v);
79
+ function has(v) {
80
+ return v !== null && v !== undefined && v !== '';
81
+ }
82
+ /** Pull a number that may be stored bare or as `{ value }` (noi_model style). */
83
+ function num(obj, path) {
84
+ const direct = deepGet(obj, path);
85
+ if (direct != null && typeof direct === 'object')
86
+ return deepGet(direct, 'value');
87
+ return direct;
88
+ }
89
+ function section(id, heading, inner, subtitle) {
90
+ return `<section class="rpt-section" id="rpt-${esc(id)}">
91
+ <header class="rpt-section-head"><h2>${esc(heading)}</h2>${subtitle ? `<p class="rpt-subtitle">${esc(subtitle)}</p>` : ''}</header>
92
+ ${inner}
93
+ </section>`;
94
+ }
95
+ /** Prose paragraphs from `parsed.prose[id]`. The raw prose carries Markdown
96
+ * scaffolding we must not reproduce: section headings, `---` dividers, and
97
+ * pipe tables (the structured data renders those properly). What's left is
98
+ * escaped and gets minimal inline markup (bold/italic/code). */
99
+ function proseHtml(text) {
100
+ if (!text || !text.trim())
101
+ return '';
102
+ const lines = text.split('\n').filter((line) => {
103
+ const t = line.trim();
104
+ if (/^#{1,6}\s/.test(t))
105
+ return false; // markdown headings
106
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(t))
107
+ return false; // horizontal rules
108
+ if (/^\|.*\|$/.test(t))
109
+ return false; // pipe-table rows
110
+ if (/^>\s?/.test(t))
111
+ return false; // blockquote callouts
112
+ return true;
113
+ });
114
+ return lines
115
+ .join('\n')
116
+ .trim()
117
+ .split(/\n\s*\n/)
118
+ .filter((p) => p.trim().length > 0)
119
+ .map((p) => `<p class="rpt-prose">${inlineMd(esc(p.trim().replace(/\s*\n\s*/g, ' ')))}</p>`)
120
+ .join('\n');
121
+ }
122
+ /** Minimal inline markdown on already-escaped text: **bold**, *em*, `code`. */
123
+ function inlineMd(escaped) {
124
+ return escaped
125
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
126
+ .replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
127
+ .replace(/`([^`]+)`/g, '<code>$1</code>');
128
+ }
129
+ /** Two-column definition table from [label, displayValue] pairs; rows whose
130
+ * value is the null display ('n/a') are dropped. */
131
+ function kvTable(rows, cls = '') {
132
+ const kept = rows.filter(([, v]) => v !== 'n/a' && v !== '');
133
+ if (kept.length === 0)
134
+ return '';
135
+ const tr = kept
136
+ .map(([k, v]) => `<tr><th scope="row">${esc(k)}</th><td>${esc(v)}</td></tr>`)
137
+ .join('');
138
+ return `<table class="rpt-kv ${cls}"><tbody>${tr}</tbody></table>`;
139
+ }
140
+ /** Data table; `cells` are pre-formatted display strings (escaped here). */
141
+ function dataTable(cols, rows, opts) {
142
+ const head = cols
143
+ .map((c) => `<th class="al-${c.align ?? 'left'}">${esc(c.label)}</th>`)
144
+ .join('');
145
+ const body = rows
146
+ .map((r) => `<tr>${r.map((cell, i) => `<td class="al-${cols[i]?.align ?? 'left'}">${esc(cell)}</td>`).join('')}</tr>`)
147
+ .join('');
148
+ const foot = opts?.footRow
149
+ ? `<tfoot><tr>${opts.footRow.map((cell, i) => `<td class="al-${cols[i]?.align ?? 'left'}">${esc(cell)}</td>`).join('')}</tr></tfoot>`
150
+ : '';
151
+ return `<table class="rpt-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody>${foot}</table>`;
152
+ }
153
+ function badge(text, kind = 'default') {
154
+ return `<span class="rpt-badge rpt-badge--${esc(kind)}">${esc(text)}</span>`;
155
+ }
156
+ function sectionBlock(ctx, id) {
157
+ // Sections sometimes parse as a single-variant map (e.g. variant=default);
158
+ // fall back to the 'default' variant the way renderChat does for stress_tests.
159
+ return getSection(ctx.parsed, id) ?? getSectionVariant(ctx.parsed, id, 'default');
160
+ }
161
+ // ─── §7.1 / 1 — Cover page ────────────────────────────────────────────────────
162
+ function coverPage(ctx) {
163
+ const { fm, tier, opts } = ctx;
164
+ const address = [fm.property_address, fm.city, fm.state, fm.zip].filter(has).join(', ');
165
+ const property = sectionBlock(ctx, 'property');
166
+ const units = deepGet(property?.content, 'total_units');
167
+ const sqft = deepGet(property?.content, 'total_nra_sqft');
168
+ const yearBuilt = deepGet(property?.content, 'year_built');
169
+ const qm = (fm.quick_metrics ?? {});
170
+ const date = opts.preparedDate ?? fm.last_modified;
171
+ const preparedBy = opts.preparedBy ?? fm.created_by;
172
+ const docName = tier === 'analyst' ? 'Credit Memorandum' : 'Lender Package';
173
+ const factPairs = [
174
+ ['Asset class', [fm.asset_subtype, fm.asset_class].filter(has).map(String).map(prettyToken).join(' · ')],
175
+ ['Units', has(units) ? val(units) : 'n/a'],
176
+ ['NRA', has(sqft) ? `${Number(sqft).toLocaleString('en-US')} SF` : 'n/a'],
177
+ ['Vintage', has(yearBuilt) ? val(yearBuilt) : 'n/a'],
178
+ ['Purchase price', money(qm.purchase_price)],
179
+ ['Loan amount', money(qm.loan_amount)],
180
+ ];
181
+ const facts = factPairs
182
+ .filter(([, v]) => v !== 'n/a')
183
+ .map(([k, v]) => `<div class="rpt-cover-fact"><span>${esc(k)}</span><strong>${esc(v)}</strong></div>`)
184
+ .join('');
185
+ return `<section class="rpt-cover" id="rpt-cover">
186
+ <div class="rpt-cover-rule"></div>
187
+ <p class="rpt-cover-doc">${esc(docName)}</p>
188
+ <h1>${esc(fm.deal_name ?? 'Untitled Deal')}</h1>
189
+ ${address ? `<p class="rpt-cover-address">${esc(address)}</p>` : ''}
190
+ <div class="rpt-cover-facts">${facts}</div>
191
+ <div class="rpt-cover-foot">
192
+ ${date ? `<p>Prepared ${esc(String(date).slice(0, 10))}</p>` : ''}
193
+ ${preparedBy ? `<p>Prepared by ${esc(preparedBy)}</p>` : ''}
194
+ <p>Deal ID ${esc(fm.deal_id ?? 'n/a')} · .uw.md v${esc(fm.uw_version ?? '1.1')}</p>
195
+ </div>
196
+ </section>`;
197
+ }
198
+ function prettyToken(s) {
199
+ return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
200
+ }
201
+ // ─── §7.1 / 2 — Executive Summary ────────────────────────────────────────────
202
+ function executiveSummary(ctx) {
203
+ const { fm, parsed } = ctx;
204
+ const qm = (fm.quick_metrics ?? {});
205
+ if (Object.keys(qm).length === 0)
206
+ return null;
207
+ const cards = [
208
+ ['Purchase Price', money(qm.purchase_price)],
209
+ ['Loan Amount', money(qm.loan_amount)],
210
+ ['Equity Required', money(qm.equity_required)],
211
+ ['NOI (Underwritten)', money(qm.noi_underwritten)],
212
+ ['DSCR', has(qm.dscr) ? ratio(qm.dscr, 3) : 'n/a'],
213
+ ['LTV', pct(qm.ltv)],
214
+ ['Cap Rate', pct(qm.cap_rate)],
215
+ ['Debt Yield', pct(qm.debt_yield)],
216
+ ['Levered IRR', pct(qm.irr_projected)],
217
+ ];
218
+ const grid = cards
219
+ .filter(([, v]) => v !== 'n/a')
220
+ .map(([k, v]) => `<div class="rpt-metric"><span>${esc(k)}</span><strong>${esc(v)}</strong></div>`)
221
+ .join('');
222
+ const dealCtx = sectionBlock(ctx, 'deal_context');
223
+ const thesis = deepGet(dealCtx?.content, 'investment_thesis');
224
+ const summaryTxt = deepGet(dealCtx?.content, 'deal_summary');
225
+ const flags = (fm.flags ?? []);
226
+ const blocking = (fm.blocking_flags ?? []);
227
+ const validation = validateUWFile(parsed);
228
+ const flagHtml = flags.length || blocking.length
229
+ ? `<p class="rpt-flags">${blocking.map((f) => badge(prettyToken(f), 'error')).join(' ')} ${flags.map((f) => badge(prettyToken(f), 'warn')).join(' ')}</p>`
230
+ : '';
231
+ const statusLine = `<p class="rpt-status">Stage: <strong>${esc(prettyToken(String(fm.deal_stage ?? 'draft')))}</strong> · Recommendation: <strong>${esc(prettyToken(String(fm.recommendation ?? 'pending')))}</strong> · Validation: <strong>${esc(validation.overall_status.toUpperCase())}</strong></p>`;
232
+ return section('executive_summary', 'Executive Summary', `${summaryTxt ? proseHtml(summaryTxt) : ''}
233
+ <div class="rpt-metric-grid">${grid}</div>
234
+ ${thesis ? `<h3>Investment Thesis</h3>${proseHtml(thesis)}` : ''}
235
+ ${statusLine}
236
+ ${flagHtml}`);
237
+ }
238
+ // ─── §7.1 / 3 — Property Overview ────────────────────────────────────────────
239
+ function propertyOverview(ctx) {
240
+ const block = sectionBlock(ctx, 'property');
241
+ if (!block)
242
+ return null;
243
+ const c = block.content;
244
+ const prose = ctx.parsed.prose['property'];
245
+ const left = kvTable([
246
+ ['Asset class', [deepGet(c, 'asset_subtype'), deepGet(c, 'asset_class')].filter(has).map((x) => prettyToken(String(x))).join(' · ') || 'n/a'],
247
+ ['Year built', val(deepGet(c, 'year_built'))],
248
+ ['Year renovated', val(deepGet(c, 'year_renovated'))],
249
+ ['Building class', val(deepGet(c, 'building_class'))],
250
+ ['Construction', has(deepGet(c, 'construction_type')) ? prettyToken(String(deepGet(c, 'construction_type'))) : 'n/a'],
251
+ ['Stories', val(deepGet(c, 'stories'))],
252
+ ['Condition', has(deepGet(c, 'condition')) ? prettyToken(String(deepGet(c, 'condition'))) : 'n/a'],
253
+ ]);
254
+ const right = kvTable([
255
+ ['Units', val(deepGet(c, 'total_units'))],
256
+ ['NRA (SF)', has(deepGet(c, 'total_nra_sqft')) ? Number(deepGet(c, 'total_nra_sqft')).toLocaleString('en-US') : 'n/a'],
257
+ ['Land area', has(deepGet(c, 'land_area_acres')) ? `${val(deepGet(c, 'land_area_acres'))} acres` : 'n/a'],
258
+ ['Parking', has(deepGet(c, 'parking_spaces')) ? `${val(deepGet(c, 'parking_spaces'))} spaces (${val(deepGet(c, 'parking_ratio'))}/unit)` : 'n/a'],
259
+ ['Zoning', val(deepGet(c, 'zoning'))],
260
+ ['Flood zone', val(deepGet(c, 'flood_zone'))],
261
+ ['Deferred maintenance', has(deepGet(c, 'deferred_maintenance_est')) ? money(deepGet(c, 'deferred_maintenance_est')) : 'n/a'],
262
+ ]);
263
+ const amenities = deepGet(c, 'amenities');
264
+ const amenityHtml = amenities?.length
265
+ ? `<p class="rpt-amenities">${amenities.map((a) => badge(prettyToken(a))).join(' ')}</p>`
266
+ : '';
267
+ return section('property', 'Property Overview', `${proseHtml(prose)}
268
+ <div class="rpt-cols">${left}${right}</div>
269
+ ${amenityHtml}`);
270
+ }
271
+ // ─── §7.1 / 4 — Proforma / Cash Flow (noi_model) ─────────────────────────────
272
+ function proforma(ctx) {
273
+ const block = sectionBlock(ctx, 'noi_model');
274
+ if (!block)
275
+ return null;
276
+ const c = block.content;
277
+ const prose = ctx.parsed.prose['noi_model'];
278
+ const rows = [];
279
+ const push = (label, value, indent = false, negative = false) => {
280
+ if (!has(value))
281
+ return;
282
+ rows.push([`${indent ? ' ' : ''}${label}`, negative ? `(${money(value)})` : money(value)]);
283
+ };
284
+ push('Gross Potential Rent', num(c, 'income.gross_potential_rent'));
285
+ push('Vacancy / Credit Loss', num(c, 'income.vacancy_credit_loss'), true, true);
286
+ push('Concessions', nonZero(num(c, 'income.concessions')), true, true);
287
+ push('Loss to Lease', nonZero(num(c, 'income.loss_to_lease')), true, true);
288
+ push('Other Income', num(c, 'income.other_income'), true);
289
+ const expenseLabels = [
290
+ ['real_estate_taxes', 'Real Estate Taxes'],
291
+ ['insurance', 'Insurance'],
292
+ ['management_fees', 'Management Fees'],
293
+ ['payroll_benefits', 'Payroll & Benefits'],
294
+ ['utilities', 'Utilities'],
295
+ ['repairs_maintenance', 'Repairs & Maintenance'],
296
+ ['contract_services', 'Contract Services'],
297
+ ['marketing_advertising', 'Marketing & Advertising'],
298
+ ['administrative', 'Administrative'],
299
+ ['professional_fees', 'Professional Fees'],
300
+ ['cam_expenses', 'CAM Expenses'],
301
+ ['replacement_reserves', 'Replacement Reserves'],
302
+ ['other_expenses', 'Other Expenses'],
303
+ ];
304
+ for (const [key, label] of expenseLabels) {
305
+ push(label, nonZero(num(c, `expenses.${key}`)), true, true);
306
+ }
307
+ const table = dataTable([{ label: 'Line Item' }, { label: 'Underwritten', align: 'right' }], rows);
308
+ // EGI / OpEx / NOI rendered as emphasized total rows
309
+ const totals = `<table class="rpt-table rpt-totals"><tbody>
310
+ <tr class="rpt-total-row"><td>Effective Gross Income</td><td class="al-right">${esc(money(num(c, 'income.effective_gross_income')))}</td></tr>
311
+ <tr class="rpt-total-row"><td>Total Operating Expenses</td><td class="al-right">(${esc(money(num(c, 'expenses.total_operating_expenses')))})</td></tr>
312
+ <tr class="rpt-total-row rpt-noi-row"><td>Net Operating Income</td><td class="al-right">${esc(money(deepGet(c, 'net_operating_income')))}</td></tr>
313
+ </tbody></table>`;
314
+ const stats = kvTable([
315
+ ['NOI per unit', money(deepGet(c, 'noi_per_unit'))],
316
+ ['NOI per SF', has(deepGet(c, 'noi_per_sqft')) ? formatCurrency(deepGet(c, 'noi_per_sqft'), { decimals: 2 }) : 'n/a'],
317
+ ['NOI margin', pct(deepGet(c, 'noi_margin'))],
318
+ ['Expense ratio', pct(deepGet(c, 'expenses.expense_ratio') ?? deepGet(c, 'expense_ratio'))],
319
+ ['vs. T-12 NOI', has(deepGet(c, 'vs_t12_noi')) ? `${money(deepGet(c, 'vs_t12_noi'))} (${pct(deepGet(c, 'vs_t12_variance_pct'))})` : 'n/a'],
320
+ ], 'rpt-kv--compact');
321
+ return section('noi_model', 'Proforma / Underwritten Cash Flow', `${proseHtml(prose)}
322
+ <div class="rpt-cols rpt-cols--proforma">
323
+ <div>${table}${totals}</div>
324
+ <div>${stats}</div>
325
+ </div>`);
326
+ }
327
+ function nonZero(v) {
328
+ return typeof v === 'number' && v === 0 ? null : v;
329
+ }
330
+ // ─── §7.1 / 5 — Rent Roll Summary ────────────────────────────────────────────
331
+ function rentRollSummary(ctx) {
332
+ const block = sectionBlock(ctx, 'rent_roll');
333
+ if (!block)
334
+ return null;
335
+ const c = block.content;
336
+ const prose = ctx.parsed.prose['rent_roll'];
337
+ const head = kvTable([
338
+ ['As of', val(deepGet(c, 'as_of_date'))],
339
+ ['Physical occupancy', pct(deepGet(c, 'physical_occupancy_pct'))],
340
+ ['Economic occupancy', pct(deepGet(c, 'economic_occupancy_pct'))],
341
+ ['In-place rent (annual)', money(deepGet(c, 'in_place_rent_annual') ?? deepGet(c, 'total_annual_base_rent'))],
342
+ ['Loss to lease', has(deepGet(c, 'loss_to_lease_pct')) ? pct(deepGet(c, 'loss_to_lease_pct')) : 'n/a'],
343
+ ['Month-to-month units', val(deepGet(c, 'month_to_month_units'))],
344
+ ['WALT', has(deepGet(c, 'walt_years')) ? `${val(deepGet(c, 'walt_years'))} yrs` : 'n/a'],
345
+ ], 'rpt-kv--compact');
346
+ let table = '';
347
+ const unitMix = deepGet(c, 'unit_mix_summary');
348
+ const tenants = deepGet(c, 'tenants');
349
+ if (unitMix?.length) {
350
+ table = dataTable([
351
+ { label: 'Unit Type' },
352
+ { label: 'Count', align: 'right' },
353
+ { label: 'Avg SF', align: 'right' },
354
+ { label: 'In-Place', align: 'right' },
355
+ { label: 'Market', align: 'right' },
356
+ { label: 'Occupancy', align: 'right' },
357
+ ], unitMix.map((u) => [
358
+ val(u.unit_type),
359
+ val(u.count),
360
+ val(u.avg_sqft),
361
+ money(u.avg_rent_inplace),
362
+ money(u.avg_rent_market),
363
+ pct(u.occupancy_pct),
364
+ ]));
365
+ }
366
+ else if (tenants?.length) {
367
+ table = dataTable([
368
+ { label: 'Tenant' },
369
+ { label: 'Suite' },
370
+ { label: 'SF', align: 'right' },
371
+ { label: 'Annual Rent', align: 'right' },
372
+ { label: 'Rent PSF', align: 'right' },
373
+ { label: 'Expires', align: 'right' },
374
+ ], tenants.map((t) => [
375
+ val(t.tenant_name ?? t.name),
376
+ val(t.suite ?? t.unit),
377
+ has(t.leased_sf ?? t.sqft) ? Number(t.leased_sf ?? t.sqft).toLocaleString('en-US') : 'n/a',
378
+ money(t.annual_base_rent ?? t.annual_rent),
379
+ has(t.rent_psf) ? formatCurrency(t.rent_psf, { decimals: 2 }) : 'n/a',
380
+ val(t.lease_expiration ?? t.lease_end),
381
+ ]));
382
+ }
383
+ if (!table && !head)
384
+ return null;
385
+ return section('rent_roll', 'Rent Roll Summary', `${proseHtml(prose)}${head}${table}`);
386
+ }
387
+ // ─── §7.1 / 6 — Debt Structure ───────────────────────────────────────────────
388
+ function debtStructure(ctx) {
389
+ const block = sectionBlock(ctx, 'debt_structure');
390
+ if (!block)
391
+ return null;
392
+ const c = block.content;
393
+ const prose = ctx.parsed.prose['debt_structure'];
394
+ const terms = kvTable([
395
+ ['Loan amount', money(deepGet(c, 'loan_amount'))],
396
+ ['Lender', val(deepGet(c, 'lender_name'))],
397
+ ['Loan type', has(deepGet(c, 'loan_type')) ? prettyToken(String(deepGet(c, 'loan_type'))) : 'n/a'],
398
+ ['Rate', `${pct(deepGet(c, 'interest_rate') ?? deepGet(c, 'rate'))} ${has(deepGet(c, 'rate_type')) ? `(${val(deepGet(c, 'rate_type'))})` : ''}`.trim()],
399
+ ['Term / Amortization', `${val(deepGet(c, 'loan_term_years') ?? deepGet(c, 'term_years'))} yr / ${val(deepGet(c, 'amortization_years'))} yr`],
400
+ ['Interest-only period', has(deepGet(c, 'io_period_months')) ? `${val(deepGet(c, 'io_period_months'))} months` : 'n/a'],
401
+ ['Recourse', has(deepGet(c, 'recourse')) ? prettyToken(String(deepGet(c, 'recourse'))) : 'n/a'],
402
+ ['Prepayment', has(deepGet(c, 'prepayment_schedule')) ? `${prettyToken(String(deepGet(c, 'prepayment_type') ?? ''))} ${val(deepGet(c, 'prepayment_schedule'))}`.trim() : has(deepGet(c, 'prepayment_type')) ? prettyToken(String(deepGet(c, 'prepayment_type'))) : 'n/a'],
403
+ ['Origination fee', pct(deepGet(c, 'origination_fee_pct'))],
404
+ ['Annual debt service', money(deepGet(c, 'annual_debt_service'))],
405
+ ]);
406
+ const sizing = kvTable([
407
+ ['LTV', pct(deepGet(c, 'sizing_metrics.ltv') ?? deepGet(c, 'ltv'))],
408
+ ['DSCR (underwritten)', has(deepGet(c, 'sizing_metrics.dscr_underwritten')) ? ratio(deepGet(c, 'sizing_metrics.dscr_underwritten'), 3) : has(deepGet(c, 'dscr')) ? ratio(deepGet(c, 'dscr'), 3) : 'n/a'],
409
+ ['DSCR (in place)', has(deepGet(c, 'sizing_metrics.dscr_inplace')) ? ratio(deepGet(c, 'sizing_metrics.dscr_inplace'), 3) : 'n/a'],
410
+ ['Debt yield', pct(deepGet(c, 'sizing_metrics.debt_yield') ?? deepGet(c, 'debt_yield'))],
411
+ ['Binding constraint', has(deepGet(c, 'sizing_metrics.binding_constraint')) ? prettyToken(String(deepGet(c, 'sizing_metrics.binding_constraint'))).toUpperCase() : 'n/a'],
412
+ ['Max loan @ DSCR', money(deepGet(c, 'sizing_metrics.max_loan_at_dscr'))],
413
+ ['Max loan @ debt yield', money(deepGet(c, 'sizing_metrics.max_loan_at_debt_yield'))],
414
+ ['Balloon (est.)', money(deepGet(c, 'balloon_payment_est'))],
415
+ ], 'rpt-kv--compact');
416
+ return section('debt_structure', 'Debt Structure', `${proseHtml(prose)}<div class="rpt-cols">${terms}${sizing}</div>`);
417
+ }
418
+ // ─── §7.1 / 7 — Sources & Uses ───────────────────────────────────────────────
419
+ const SOURCE_LABELS = [
420
+ ['senior_loan', 'Senior Loan'],
421
+ ['mezzanine_debt', 'Mezzanine Debt'],
422
+ ['preferred_equity', 'Preferred Equity'],
423
+ ['equity_sponsor', 'Sponsor Equity'],
424
+ ['equity_lp', 'LP Equity'],
425
+ ['seller_financing', 'Seller Financing'],
426
+ ['government_grant', 'Government Grant'],
427
+ ['tax_credit_equity', 'Tax Credit Equity'],
428
+ ['other', 'Other'],
429
+ ];
430
+ const USE_LABELS = [
431
+ ['purchase_price', 'Purchase Price'],
432
+ ['renovation_budget', 'Renovation Budget'],
433
+ ['renovation_contingency', 'Renovation Contingency'],
434
+ ['operating_reserves', 'Operating Reserves'],
435
+ ['interest_reserve', 'Interest Reserve'],
436
+ ['rate_cap_cost', 'Rate Cap Cost'],
437
+ ['other_reserves', 'Other Reserves'],
438
+ ];
439
+ function sourcesUses(ctx) {
440
+ const block = sectionBlock(ctx, 'sources_uses');
441
+ if (!block)
442
+ return null;
443
+ const c = block.content;
444
+ const srcRows = [];
445
+ for (const [key, label] of SOURCE_LABELS) {
446
+ const v = deepGet(c, `sources.${key}`);
447
+ if (has(v))
448
+ srcRows.push([label, money(v)]);
449
+ }
450
+ const sources = dataTable([{ label: 'Sources' }, { label: 'Amount', align: 'right' }], srcRows, { footRow: ['Total Sources', money(deepGet(c, 'sources.total'))] });
451
+ const useRows = [];
452
+ for (const [key, label] of USE_LABELS) {
453
+ const v = deepGet(c, `uses.${key}`);
454
+ if (has(v))
455
+ useRows.push([label, money(v)]);
456
+ }
457
+ const closing = deepGet(c, 'uses.closing_costs.total') ?? deepGet(c, 'uses.closing_costs');
458
+ if (has(closing) && typeof closing !== 'object') {
459
+ useRows.splice(1, 0, ['Closing Costs', money(closing)]);
460
+ }
461
+ const uses = dataTable([{ label: 'Uses' }, { label: 'Amount', align: 'right' }], useRows, { footRow: ['Total Uses', money(deepGet(c, 'uses.total'))] });
462
+ const equity = kvTable([
463
+ ['Total equity', money(deepGet(c, 'equity_metrics.equity_total'))],
464
+ ['Equity % of cost', pct(deepGet(c, 'equity_metrics.equity_pct_of_cost'))],
465
+ ['Loan-to-cost', pct(deepGet(c, 'equity_metrics.loan_to_cost'))],
466
+ ['Equity per unit', money(deepGet(c, 'equity_metrics.equity_per_unit'))],
467
+ ], 'rpt-kv--compact');
468
+ const balanced = deepGet(c, 'sources_uses_balanced');
469
+ const balanceNote = balanced === true
470
+ ? `<p class="rpt-note">${badge('Sources and uses balance', 'ok')}</p>`
471
+ : balanced === false
472
+ ? `<p class="rpt-note">${badge('Sources and uses do not balance', 'error')}</p>`
473
+ : '';
474
+ return section('sources_uses', 'Sources & Uses', `<div class="rpt-cols">${sources}${uses}</div>${equity}${balanceNote}`);
475
+ }
476
+ // ─── §7.1 / 8 — Borrower Summary ─────────────────────────────────────────────
477
+ function borrowerSummary(ctx) {
478
+ const block = sectionBlock(ctx, 'borrower_sponsor');
479
+ if (!block)
480
+ return null;
481
+ const c = block.content;
482
+ const narrative = deepGet(c, 'sponsor_narrative');
483
+ const fs = deepGet(c, 'financial_summary');
484
+ const verified = deepGet(c, 'financial_summary.all_figures_verified');
485
+ const finTable = kvTable([
486
+ ['Global net worth', money(deepGet(fs, 'global_net_worth'))],
487
+ ['Global liquidity', money(deepGet(fs, 'global_liquidity'))],
488
+ ['Net worth / loan', has(deepGet(fs, 'nw_to_loan_ratio')) ? ratio(deepGet(fs, 'nw_to_loan_ratio'), 2) : 'n/a'],
489
+ ['Liquidity / loan', pct(deepGet(fs, 'liquidity_to_loan_ratio'))],
490
+ ['Global DSCR', has(deepGet(fs, 'global_dscr')) ? ratio(deepGet(fs, 'global_dscr'), 2) : 'n/a'],
491
+ ], 'rpt-kv--compact');
492
+ const track = deepGet(c, 'track_record');
493
+ const trackTable = kvTable([
494
+ ['Deals completed', val(deepGet(track, 'deals_completed'))],
495
+ ['Total volume', money(deepGet(track, 'total_deal_volume'))],
496
+ ['Avg hold period', has(deepGet(track, 'avg_hold_period_years')) ? `${val(deepGet(track, 'avg_hold_period_years'))} yrs` : 'n/a'],
497
+ ['Defaults / losses', val(deepGet(track, 'deals_in_default_or_loss'))],
498
+ ], 'rpt-kv--compact');
499
+ const principals = deepGet(c, 'principals');
500
+ const principalTable = principals?.length
501
+ ? dataTable([
502
+ { label: 'Principal' },
503
+ { label: 'Role' },
504
+ { label: 'Ownership', align: 'right' },
505
+ { label: 'Net Worth', align: 'right' },
506
+ { label: 'Liquidity', align: 'right' },
507
+ { label: 'Guarantor', align: 'center' },
508
+ ], principals.map((p) => [
509
+ val(p.name),
510
+ has(p.role) ? prettyToken(String(p.role)) : 'n/a',
511
+ pct(p.ownership_pct),
512
+ money(p.net_worth_stated ?? p.net_worth),
513
+ money(p.liquid_assets_stated ?? p.liquidity),
514
+ p.is_guarantor === true ? 'Yes' : p.is_guarantor === false ? 'No' : 'n/a',
515
+ ]))
516
+ : '';
517
+ const verifiedNote = verified === false
518
+ ? `<p class="rpt-note">${badge('Figures stated, not yet verified', 'warn')}</p>`
519
+ : '';
520
+ return section('borrower_sponsor', 'Borrower Summary', `${proseHtml(narrative)}
521
+ ${principalTable}
522
+ <div class="rpt-cols">${finTable}${trackTable}</div>
523
+ ${verifiedNote}`);
524
+ }
525
+ // ─── §7.1 / 9 — Exit Analysis ────────────────────────────────────────────────
526
+ function exitAnalysis(ctx) {
527
+ const block = sectionBlock(ctx, 'dcf');
528
+ if (!block)
529
+ return null;
530
+ const c = block.content;
531
+ const exit = deepGet(c, 'exit_analysis');
532
+ const returns = deepGet(c, 'returns');
533
+ if (!has(exit) && !has(returns))
534
+ return null;
535
+ const exitTable = kvTable([
536
+ ['Exit year', val(deepGet(exit, 'exit_year'))],
537
+ ['Exit NOI', money(deepGet(exit, 'exit_noi'))],
538
+ ['Exit cap rate', pct(deepGet(exit, 'cap_rate'))],
539
+ ['Gross exit value', money(deepGet(exit, 'exit_value_gross'))],
540
+ ['Disposition costs', money(deepGet(exit, 'disposition_costs'))],
541
+ ['Net exit value', money(deepGet(exit, 'exit_value_net'))],
542
+ ['Loan balance at exit', money(deepGet(exit, 'loan_balance_at_exit'))],
543
+ ['Net proceeds to equity', money(deepGet(exit, 'net_proceeds_to_equity'))],
544
+ ]);
545
+ const returnTable = kvTable([
546
+ ['Levered IRR', pct(deepGet(returns, 'levered_irr'))],
547
+ ['Unlevered IRR', pct(deepGet(returns, 'unlevered_irr'))],
548
+ ['Equity multiple', has(deepGet(returns, 'equity_multiple')) ? ratio(deepGet(returns, 'equity_multiple'), 2) : 'n/a'],
549
+ ['Avg cash-on-cash', pct(deepGet(returns, 'avg_cash_on_cash'))],
550
+ ['NPV', money(deepGet(returns, 'npv'))],
551
+ ['Discount rate', pct(deepGet(returns, 'discount_rate_used'))],
552
+ ], 'rpt-kv--compact');
553
+ return section('exit_analysis', 'Exit Analysis & Returns', `<div class="rpt-cols">${exitTable}${returnTable}</div>`);
554
+ }
555
+ // ─── §7.1 / 10 — Assumptions & Disclosures ───────────────────────────────────
556
+ const SOURCE_BADGE_KIND = {
557
+ user_override: 'warn',
558
+ ai_extracted: 'info',
559
+ agent_computed: 'info',
560
+ market_data: 'info',
561
+ scenario_default: 'default',
562
+ wizard_input: 'default',
563
+ underwritten: 'ok',
564
+ investor_profile: 'default',
565
+ };
566
+ function assumptionsDisclosures(ctx) {
567
+ const block = sectionBlock(ctx, 'assumptions');
568
+ const disclaimer = ctx.opts.disclaimer ?? STANDARD_DISCLAIMER;
569
+ const list = deepGet(block?.content, 'assumptions');
570
+ let table = '';
571
+ if (list?.length) {
572
+ const rows = list.map((a) => {
573
+ const unit = String(a.unit ?? '');
574
+ const v = unit === 'pct' ? pct(a.value) : unit.startsWith('dollar') ? money(a.value) : val(a.value);
575
+ return [
576
+ String(a.label ?? a.key ?? ''),
577
+ v,
578
+ String(a.source ?? ''),
579
+ String(a.source_detail ?? ''),
580
+ ];
581
+ });
582
+ const head = [
583
+ { label: 'Assumption' },
584
+ { label: 'Value', align: 'right' },
585
+ { label: 'Source' },
586
+ { label: 'Basis' },
587
+ ];
588
+ const body = rows
589
+ .map((r) => `<tr><td>${esc(r[0])}</td><td class="al-right">${esc(r[1])}</td><td>${badge(prettyToken(r[2]), SOURCE_BADGE_KIND[r[2]] ?? 'default')}</td><td class="rpt-basis">${esc(r[3])}</td></tr>`)
590
+ .join('');
591
+ table = `<table class="rpt-table"><thead><tr>${head.map((c) => `<th class="al-${c.align ?? 'left'}">${esc(c.label)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table>`;
592
+ }
593
+ return section('assumptions', 'Assumptions & Disclosures', `${table}
594
+ <div class="rpt-disclaimer"><h3>Disclosures</h3><p>${esc(disclaimer)}</p></div>`);
595
+ }
596
+ // ─── §7.2 — Credit Memo additions ────────────────────────────────────────────
597
+ function marketAnalysis(ctx) {
598
+ const block = sectionBlock(ctx, 'market_analysis');
599
+ if (!block)
600
+ return null;
601
+ const c = block.content;
602
+ const prose = ctx.parsed.prose['market_analysis'];
603
+ const stats = kvTable([
604
+ ['Market / submarket', [deepGet(c, 'market'), deepGet(c, 'submarket')].filter(has).join(' — ') || 'n/a'],
605
+ ['Vacancy', pct(deepGet(c, 'vacancy.current_rate') ?? deepGet(c, 'market_vacancy_rate'))],
606
+ ['YoY rent growth', pct(deepGet(c, 'rents.yoy_rent_growth_pct'))],
607
+ ['Avg asking rent', money(deepGet(c, 'rents.avg_asking_per_unit'))],
608
+ ['Under construction', has(deepGet(c, 'supply.units_under_construction')) ? `${val(deepGet(c, 'supply.units_under_construction'))} units` : 'n/a'],
609
+ ['Net absorption (T-12)', val(deepGet(c, 'demand.net_absorption_trailing_12mo'))],
610
+ ['Cap rate range', has(deepGet(c, 'cap_rates.range_low')) ? `${pct(deepGet(c, 'cap_rates.range_low'))} – ${pct(deepGet(c, 'cap_rates.range_high'))}` : 'n/a'],
611
+ ], 'rpt-kv--compact');
612
+ const comps = deepGet(c, 'comparable_sales');
613
+ const compTable = comps?.length
614
+ ? `<h3>Comparable Sales</h3>${dataTable([
615
+ { label: 'Property' },
616
+ { label: 'Sale Date' },
617
+ { label: 'Size', align: 'right' },
618
+ { label: 'Price', align: 'right' },
619
+ { label: 'Per Unit/SF', align: 'right' },
620
+ { label: 'Cap Rate', align: 'right' },
621
+ ], comps.map((s) => [
622
+ val(s.property_name ?? s.address),
623
+ val(s.sale_date),
624
+ val(s.units_or_sqft),
625
+ money(s.sale_price),
626
+ money(s.price_per_unit ?? s.price_per_sqft),
627
+ pct(s.cap_rate),
628
+ ]))}`
629
+ : '';
630
+ return section('market_analysis', 'Market Analysis', `${proseHtml(prose)}${stats}${compTable}`);
631
+ }
632
+ function financialAnalysis(ctx) {
633
+ const dcf = sectionBlock(ctx, 'dcf');
634
+ const stress = sectionBlock(ctx, 'stress_tests') ?? getSectionVariant(ctx.parsed, 'stress_tests', 'default');
635
+ if (!dcf && !stress)
636
+ return null;
637
+ let flowsTable = '';
638
+ const flows = deepGet(dcf?.content, 'annual_cash_flows');
639
+ if (flows?.length) {
640
+ flowsTable = `<h3>Annual Cash Flows</h3>${dataTable([
641
+ { label: 'Year', align: 'right' },
642
+ { label: 'EGI', align: 'right' },
643
+ { label: 'OpEx', align: 'right' },
644
+ { label: 'NOI', align: 'right' },
645
+ { label: 'Debt Service', align: 'right' },
646
+ { label: 'Levered CF', align: 'right' },
647
+ { label: 'CoC', align: 'right' },
648
+ ], flows.map((f) => [
649
+ val(f.year),
650
+ money(f.effective_gross_income),
651
+ money(f.total_expenses),
652
+ money(f.net_operating_income),
653
+ money(f.annual_debt_service),
654
+ money(f.net_cash_flow_levered),
655
+ pct(f.cash_on_cash_return),
656
+ ]))}`;
657
+ }
658
+ let stressTable = '';
659
+ const scenarios = deepGet(stress?.content, 'scenarios');
660
+ if (scenarios?.length) {
661
+ stressTable = `<h3>Stress Scenarios</h3>${dataTable([
662
+ { label: 'Scenario' },
663
+ { label: 'NOI', align: 'right' },
664
+ { label: 'DSCR', align: 'right' },
665
+ { label: 'Debt Yield', align: 'right' },
666
+ { label: 'Passes DSCR Floor', align: 'center' },
667
+ ], scenarios.map((s) => [
668
+ val(s.label ?? s.name),
669
+ money(s.resulting_noi),
670
+ has(s.resulting_dscr) ? ratio(s.resulting_dscr, 3) : 'n/a',
671
+ pct(s.resulting_debt_yield),
672
+ s.passes_dscr_minimum === true ? 'Pass' : s.passes_dscr_minimum === false ? 'FAIL' : 'n/a',
673
+ ]))}`;
674
+ }
675
+ const breakEven = deepGet(stress?.content, 'break_even');
676
+ const beTable = has(breakEven)
677
+ ? kvTable([
678
+ ['Break-even occupancy', pct(deepGet(breakEven, 'break_even_occupancy_pct'))],
679
+ ['Break-even rent / unit / mo', money(deepGet(breakEven, 'break_even_rent_per_unit_monthly'))],
680
+ ['Occupancy cushion', pct(deepGet(breakEven, 'current_vs_breakeven_occupancy_cushion'))],
681
+ ], 'rpt-kv--compact')
682
+ : '';
683
+ if (!flowsTable && !stressTable)
684
+ return null;
685
+ return section('financial_analysis', 'Financial Analysis', `${flowsTable}${stressTable}${beTable}`);
686
+ }
687
+ function dueDiligence(ctx) {
688
+ const block = sectionBlock(ctx, 'due_diligence');
689
+ if (!block)
690
+ return null;
691
+ const c = block.content;
692
+ const prose = ctx.parsed.prose['due_diligence'];
693
+ const reports = [
694
+ ['appraisal', 'Appraisal'],
695
+ ['environmental', 'Environmental (Phase I)'],
696
+ ['title', 'Title'],
697
+ ['survey', 'Survey'],
698
+ ['inspection', 'Property Inspection'],
699
+ ['seismic', 'Seismic'],
700
+ ];
701
+ const rows = reports
702
+ .filter(([key]) => has(deepGet(c, key)))
703
+ .map(([key, label]) => {
704
+ const status = String(deepGet(c, `${key}.clearance_status`) ?? 'n/a');
705
+ const received = deepGet(c, `${key}.received`) ?? deepGet(c, `${key}.phase1_received`) ?? deepGet(c, `${key}.commitment_received`);
706
+ return `<tr><td>${esc(label)}</td><td class="al-center">${received === true ? 'Received' : received === false ? 'Pending' : 'n/a'}</td><td>${badge(prettyToken(status), status === 'clear' ? 'ok' : status === 'not_reviewed' ? 'default' : 'warn')}</td></tr>`;
707
+ })
708
+ .join('');
709
+ const reportTable = rows
710
+ ? `<table class="rpt-table"><thead><tr><th>Report</th><th class="al-center">Status</th><th>Clearance</th></tr></thead><tbody>${rows}</tbody></table>`
711
+ : '';
712
+ const checklist = deepGet(c, 'checklist');
713
+ const outstanding = deepGet(checklist, 'items_outstanding');
714
+ const checklistHtml = has(checklist)
715
+ ? `${kvTable([
716
+ ['Items received', `${val(deepGet(checklist, 'items_received'))} of ${val(deepGet(checklist, 'total_items_required'))}`],
717
+ ['Ready to close', deepGet(checklist, 'ready_to_close') === true ? 'Yes' : 'No'],
718
+ ], 'rpt-kv--compact')}${outstanding?.length
719
+ ? `<p class="rpt-note">Outstanding: ${outstanding.map((i) => badge(prettyToken(i), 'warn')).join(' ')}</p>`
720
+ : ''}`
721
+ : '';
722
+ return section('due_diligence', 'Due Diligence Summary', `${proseHtml(prose)}${reportTable}${checklistHtml}`);
723
+ }
724
+ function riskAssessment(ctx) {
725
+ const block = sectionBlock(ctx, 'risk_assessment');
726
+ if (!block)
727
+ return null;
728
+ const c = block.content;
729
+ const prose = ctx.parsed.prose['risk_assessment'];
730
+ const risks = deepGet(c, 'top_risks');
731
+ const mitigants = deepGet(c, 'top_mitigants');
732
+ const listHtml = (items, title) => items?.length
733
+ ? `<div><h3>${esc(title)}</h3><ul class="rpt-list">${items.map((r) => `<li>${esc(r)}</li>`).join('')}</ul></div>`
734
+ : '';
735
+ const composite = kvTable([
736
+ ['Composite score', val(deepGet(c, 'composite_score'))],
737
+ ['Rating (1–10)', val(deepGet(c, 'composite_rating_1_to_10'))],
738
+ ['Regulatory rating', val(deepGet(c, 'regulatory_rating'))],
739
+ ['Recommendation', has(deepGet(c, 'recommendation')) ? prettyToken(String(deepGet(c, 'recommendation'))) : 'n/a'],
740
+ ], 'rpt-kv--compact');
741
+ return section('risk_assessment', 'Risk Assessment', `${proseHtml(prose)}${composite}<div class="rpt-cols">${listHtml(risks, 'Top Risks')}${listHtml(mitigants, 'Mitigants')}</div>`);
742
+ }
743
+ function complianceSummary(ctx) {
744
+ const block = sectionBlock(ctx, 'compliance');
745
+ if (!block)
746
+ return null;
747
+ const c = block.content;
748
+ const table = kvTable([
749
+ ['HVCRE classification', has(deepGet(c, 'hvcre.classification')) ? prettyToken(String(deepGet(c, 'hvcre.classification'))).toUpperCase() : 'n/a'],
750
+ ['CRA eligible', deepGet(c, 'cra.eligible') === true ? `Yes — ${val(deepGet(c, 'cra.cra_category'))}` : deepGet(c, 'cra.eligible') === false ? 'No' : 'n/a'],
751
+ ['BSA/AML risk', has(deepGet(c, 'bsa_aml.overall_bsa_risk')) ? prettyToken(String(deepGet(c, 'bsa_aml.overall_bsa_risk'))) : 'n/a'],
752
+ ['OFAC clear', deepGet(c, 'bsa_aml.ofac_sdn_clear') === true ? 'Yes' : deepGet(c, 'bsa_aml.ofac_sdn_clear') === false ? 'NO' : 'n/a'],
753
+ ['Fair lending review', deepGet(c, 'fair_lending.review_triggered') === true ? 'Triggered' : 'Not triggered'],
754
+ ]);
755
+ const exceptions = deepGet(c, 'policy_exceptions');
756
+ const exceptionsHtml = exceptions?.length
757
+ ? `<p class="rpt-note">${badge(`${exceptions.length} policy exception(s)`, 'warn')}</p>`
758
+ : `<p class="rpt-note">${badge('No policy exceptions', 'ok')}</p>`;
759
+ return section('compliance', 'Compliance Summary', `${table}${exceptionsHtml}`);
760
+ }
761
+ function covenants(ctx) {
762
+ const block = sectionBlock(ctx, 'debt_structure');
763
+ const list = deepGet(block?.content, 'covenants');
764
+ if (!list?.length)
765
+ return null;
766
+ const table = dataTable([
767
+ { label: 'Covenant' },
768
+ { label: 'Threshold' },
769
+ { label: 'Frequency' },
770
+ { label: 'Cure Period', align: 'right' },
771
+ ], list.map((cov) => [
772
+ has(cov.type) ? prettyToken(String(cov.type)) : 'n/a',
773
+ val(cov.threshold),
774
+ has(cov.test_frequency) ? prettyToken(String(cov.test_frequency)) : 'n/a',
775
+ has(cov.cure_period_days) ? `${val(cov.cure_period_days)} days` : 'n/a',
776
+ ]));
777
+ return section('covenants', 'Covenants', table);
778
+ }
779
+ function appendix(ctx) {
780
+ const { parsed } = ctx;
781
+ if (parsed.pipeline_log.length === 0)
782
+ return null;
783
+ const rows = [];
784
+ for (const block of parsed.pipeline_log) {
785
+ const entries = deepGet(block.content, 'entries') ?? [];
786
+ for (const e of entries) {
787
+ rows.push([
788
+ String(e.timestamp ?? '').slice(0, 19).replace('T', ' '),
789
+ val(e.agent_or_actor),
790
+ has(e.event_type) ? prettyToken(String(e.event_type)) : 'n/a',
791
+ val(e.status),
792
+ ]);
793
+ }
794
+ }
795
+ if (rows.length === 0)
796
+ return null;
797
+ return section('appendix', 'Appendix — Pipeline Log', dataTable([{ label: 'Timestamp' }, { label: 'Actor' }, { label: 'Event' }, { label: 'Status' }], rows), 'Full provenance retained in the source .uw.md file.');
798
+ }
799
+ // ─── Stylesheet ───────────────────────────────────────────────────────────────
800
+ // Exported so fragment-mode hosts (web editor preview) can include it once.
801
+ export const REPORT_CSS = `
802
+ :root {
803
+ --rpt-ink: #1a2332;
804
+ --rpt-muted: #5b6b7f;
805
+ --rpt-accent: #1e3a5f;
806
+ --rpt-accent-soft: #eef2f7;
807
+ --rpt-rule: #d7dee8;
808
+ --rpt-ok: #1d6f42;
809
+ --rpt-warn: #8a6d1a;
810
+ --rpt-error: #9b2226;
811
+ --rpt-paper: #ffffff;
812
+ }
813
+ .uwmd-report {
814
+ font-family: "Source Sans 3", "Segoe UI", system-ui, -apple-system, sans-serif;
815
+ color: var(--rpt-ink);
816
+ background: var(--rpt-paper);
817
+ max-width: 52rem;
818
+ margin: 0 auto;
819
+ padding: 2rem 2.5rem;
820
+ font-size: 0.875rem;
821
+ line-height: 1.55;
822
+ }
823
+ .uwmd-report h1, .uwmd-report h2, .uwmd-report h3 {
824
+ font-family: Georgia, "Times New Roman", serif;
825
+ color: var(--rpt-accent);
826
+ font-weight: 600;
827
+ margin: 0;
828
+ }
829
+ .uwmd-report h3 { font-size: 0.95rem; margin: 1.1rem 0 0.4rem; }
830
+
831
+ /* Cover */
832
+ .rpt-cover { padding: 5rem 0 3rem; }
833
+ .rpt-cover-rule { height: 4px; width: 4.5rem; background: var(--rpt-accent); margin-bottom: 2.25rem; }
834
+ .rpt-cover-doc {
835
+ text-transform: uppercase; letter-spacing: 0.22em; font-size: 0.72rem;
836
+ color: var(--rpt-muted); margin: 0 0 0.6rem;
837
+ }
838
+ .rpt-cover h1 { font-size: 2.1rem; line-height: 1.15; }
839
+ .rpt-cover-address { color: var(--rpt-muted); font-size: 1rem; margin: 0.5rem 0 2.5rem; }
840
+ .rpt-cover-facts {
841
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
842
+ background: var(--rpt-rule); border: 1px solid var(--rpt-rule); margin-bottom: 3rem;
843
+ }
844
+ .rpt-cover-fact { background: var(--rpt-paper); padding: 0.7rem 0.9rem; }
845
+ .rpt-cover-fact span { display: block; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--rpt-muted); }
846
+ .rpt-cover-fact strong { font-size: 1rem; font-variant-numeric: tabular-nums; }
847
+ .rpt-cover-foot { color: var(--rpt-muted); font-size: 0.78rem; }
848
+ .rpt-cover-foot p { margin: 0.15rem 0; }
849
+
850
+ /* Sections */
851
+ .rpt-section { margin: 2.25rem 0; }
852
+ .rpt-section-head { border-bottom: 2px solid var(--rpt-accent); padding-bottom: 0.35rem; margin-bottom: 0.9rem; }
853
+ .rpt-section-head h2 { font-size: 1.25rem; }
854
+ .rpt-subtitle { color: var(--rpt-muted); font-size: 0.78rem; margin: 0.2rem 0 0; }
855
+ .rpt-prose { margin: 0.5rem 0; }
856
+ .rpt-status, .rpt-note { font-size: 0.82rem; color: var(--rpt-muted); margin: 0.6rem 0 0; }
857
+ .rpt-flags { margin: 0.5rem 0 0; }
858
+ .rpt-list { margin: 0.3rem 0 0 1.1rem; padding: 0; }
859
+ .rpt-list li { margin: 0.25rem 0; }
860
+
861
+ /* Metric grid */
862
+ .rpt-metric-grid {
863
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
864
+ background: var(--rpt-rule); border: 1px solid var(--rpt-rule); margin: 0.9rem 0;
865
+ }
866
+ .rpt-metric { background: var(--rpt-accent-soft); padding: 0.65rem 0.85rem; }
867
+ .rpt-metric span { display: block; font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--rpt-muted); }
868
+ .rpt-metric strong { font-size: 1.05rem; font-variant-numeric: tabular-nums; color: var(--rpt-accent); }
869
+
870
+ /* Tables */
871
+ .uwmd-report table { border-collapse: collapse; width: 100%; margin: 0.6rem 0; font-variant-numeric: tabular-nums; }
872
+ .rpt-table th, .rpt-table td { padding: 0.32rem 0.55rem; border-bottom: 1px solid var(--rpt-rule); }
873
+ .rpt-table thead th {
874
+ background: var(--rpt-accent); color: #fff; font-size: 0.7rem; text-transform: uppercase;
875
+ letter-spacing: 0.06em; text-align: left; font-weight: 600;
876
+ }
877
+ .rpt-table tbody tr:nth-child(even) { background: #f7f9fc; }
878
+ .rpt-table tfoot td { font-weight: 700; border-top: 2px solid var(--rpt-accent); border-bottom: none; }
879
+ .al-right { text-align: right; }
880
+ .al-center { text-align: center; }
881
+ .al-left { text-align: left; }
882
+ .rpt-kv th { text-align: left; font-weight: 600; color: var(--rpt-muted); width: 46%; padding: 0.28rem 0.55rem 0.28rem 0; border-bottom: 1px solid var(--rpt-rule); font-size: 0.8rem; }
883
+ .rpt-kv td { padding: 0.28rem 0; border-bottom: 1px solid var(--rpt-rule); }
884
+ .rpt-kv--compact th, .rpt-kv--compact td { font-size: 0.78rem; padding-top: 0.22rem; padding-bottom: 0.22rem; }
885
+ .rpt-totals { margin-top: -0.4rem; }
886
+ .rpt-total-row td { font-weight: 700; padding: 0.32rem 0.55rem; border-bottom: 1px solid var(--rpt-rule); }
887
+ .rpt-noi-row td { border-top: 2px solid var(--rpt-accent); font-size: 0.95rem; color: var(--rpt-accent); }
888
+ .rpt-basis { font-size: 0.74rem; color: var(--rpt-muted); }
889
+
890
+ /* Layout */
891
+ .rpt-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.6rem; align-items: start; }
892
+ .rpt-cols--proforma { grid-template-columns: 3fr 2fr; }
893
+ @media (max-width: 640px) { .rpt-cols, .rpt-cols--proforma { grid-template-columns: 1fr; } }
894
+
895
+ /* Badges */
896
+ .rpt-badge {
897
+ display: inline-block; font-size: 0.66rem; font-weight: 600; letter-spacing: 0.04em;
898
+ padding: 0.12rem 0.5rem; border-radius: 999px; border: 1px solid var(--rpt-rule);
899
+ background: var(--rpt-accent-soft); color: var(--rpt-muted); margin: 0.1rem 0;
900
+ }
901
+ .rpt-badge--ok { color: var(--rpt-ok); border-color: #bcd9c8; background: #ecf6f0; }
902
+ .rpt-badge--warn { color: var(--rpt-warn); border-color: #e4d5a3; background: #faf5e3; }
903
+ .rpt-badge--error { color: var(--rpt-error); border-color: #e5b4b6; background: #fbeaeb; }
904
+ .rpt-badge--info { color: var(--rpt-accent); border-color: #c3d2e4; background: var(--rpt-accent-soft); }
905
+
906
+ /* Disclaimer */
907
+ .rpt-disclaimer { margin-top: 1.2rem; padding: 0.9rem 1.1rem; background: var(--rpt-accent-soft); border-left: 3px solid var(--rpt-accent); }
908
+ .rpt-disclaimer h3 { margin-top: 0; }
909
+ .rpt-disclaimer p { font-size: 0.76rem; color: var(--rpt-muted); margin: 0.3rem 0 0; }
910
+ .rpt-amenities { margin: 0.6rem 0 0; }
911
+
912
+ /* Print */
913
+ @page { size: letter; margin: 0.75in; }
914
+ @media print {
915
+ .uwmd-report { max-width: none; padding: 0; font-size: 10pt; }
916
+ .rpt-cover { page-break-after: always; padding-top: 2.5in; }
917
+ .rpt-section { break-inside: avoid-page; margin: 1.4rem 0; }
918
+ .rpt-section-head { break-after: avoid-page; }
919
+ .rpt-table thead th { background: var(--rpt-accent) !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
920
+ .rpt-metric, .rpt-disclaimer, .rpt-badge { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
921
+ a { color: inherit; text-decoration: none; }
922
+ }
923
+ `;
924
+ //# sourceMappingURL=report.js.map