@uwmd/core 2.11.0 → 2.12.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.
package/dist/validator.js CHANGED
@@ -1093,6 +1093,227 @@ function checkLeasingCapital(t, issues, at) {
1093
1093
  }
1094
1094
  }
1095
1095
  }
1096
+ // ─── §4.3 Expense recoveries and the CAM true-up (RFC 0058) ──────────────────
1097
+ //
1098
+ // Recovery income is the second-largest line in most commercial deals, and the
1099
+ // tenant record carried a lease type and one orphan `cam_cap_pct`. These rules
1100
+ // type the terms and check the one piece of arithmetic genuinely knowable from
1101
+ // a single document: a closed period's reconciliation.
1102
+ //
1103
+ // Two things are deliberately NOT recomputed. The cap amount, because a
1104
+ // cumulative or compounding cap depends on a base-year history no single
1105
+ // document carries — REC-07 checks the direction instead, which is the honest
1106
+ // half. And the pool allocation across tenants, because that is a modeling
1107
+ // decision with a policy for vacant space, not a recorded fact; each tenant
1108
+ // states its own share.
1109
+ /** Recovery methods. Closed — a producer meaning something else states `none`. */
1110
+ export const RECOVERY_METHODS = Object.freeze([
1111
+ 'net', 'base_year_stop', 'fixed_stop', 'fixed_amount', 'none',
1112
+ ]);
1113
+ /**
1114
+ * The §4.4 `operating_statement.expenses` keys a pool may draw from.
1115
+ *
1116
+ * Deliberately excludes `management_fee_pct_egi` (a ratio, not an expense),
1117
+ * `capital_expenditures_actual` and `replacement_reserves` (capital, not
1118
+ * operating), and `total_operating_expenses` (a total — naming it would double
1119
+ * every member beside it).
1120
+ */
1121
+ export const RECOVERABLE_EXPENSE_KEYS = Object.freeze([
1122
+ 'real_estate_taxes', 'insurance', 'management_fees', 'payroll_benefits',
1123
+ 'utilities', 'repairs_maintenance', 'contract_services',
1124
+ 'marketing_advertising', 'administrative', 'professional_fees',
1125
+ 'other_expenses',
1126
+ ]);
1127
+ export const RECOVERY_CAP_ACCUMULATIONS = Object.freeze([
1128
+ 'cumulative', 'non_cumulative', 'compounding',
1129
+ ]);
1130
+ export const RECOVERY_SETTLEMENTS = Object.freeze([
1131
+ 'billed', 'credited', 'disputed', 'unsettled',
1132
+ ]);
1133
+ /** Currency quantum, matching every sibling verifier's reporting boundary. */
1134
+ const RECOVERY_DP = 2;
1135
+ function recIssue(issues, code, field, message, value, severity = 'error') {
1136
+ issues.push({
1137
+ code, severity, section: 'rent_roll', field, message,
1138
+ ...(value !== undefined ? { value } : {}),
1139
+ });
1140
+ }
1141
+ function roundRec(value) {
1142
+ const f = 10 ** RECOVERY_DP;
1143
+ const scaled = value * f;
1144
+ return (scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)) / f;
1145
+ }
1146
+ function checkRecoveryTerms(t, issues, at) {
1147
+ const raw = t['recovery_terms'];
1148
+ if (raw == null)
1149
+ return null;
1150
+ const p = `${at}.recovery_terms`;
1151
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
1152
+ recIssue(issues, 'REC-02', p, 'REC-02: recovery_terms must be an object stating a method', raw);
1153
+ return null;
1154
+ }
1155
+ const terms = raw;
1156
+ // REC-01: the share is a fraction, not a percent. 4.12 instead of 0.0412 is
1157
+ // the single most consequential typo available here — it multiplies every
1158
+ // recovery by a hundred.
1159
+ const share = terms['pro_rata_share'];
1160
+ let shareValue = null;
1161
+ if (share != null) {
1162
+ if (!(leaseNum(share) && share > 0 && share <= 1)) {
1163
+ recIssue(issues, 'REC-01', `${p}.pro_rata_share`, 'REC-01: pro_rata_share must be a fraction in (0,1] — 0.0412 is 4.12%, not 4.12', share);
1164
+ }
1165
+ else {
1166
+ shareValue = share;
1167
+ }
1168
+ }
1169
+ // REC-02: a method without the input it needs.
1170
+ const method = terms['method'];
1171
+ if (typeof method !== 'string' || !RECOVERY_METHODS.includes(method)) {
1172
+ recIssue(issues, 'REC-02', `${p}.method`, `REC-02: method must be one of ${RECOVERY_METHODS.join(', ')} — the vocabulary is closed`, method);
1173
+ }
1174
+ else {
1175
+ const REQUIRED = {
1176
+ base_year_stop: 'base_year',
1177
+ fixed_stop: 'expense_stop_per_sqft',
1178
+ fixed_amount: 'fixed_recovery_annual',
1179
+ };
1180
+ const needs = REQUIRED[method];
1181
+ if (needs && terms[needs] == null) {
1182
+ recIssue(issues, 'REC-02', `${p}.${needs}`, `REC-02: method "${method}" requires ${needs}; without it the method states nothing`);
1183
+ }
1184
+ }
1185
+ // REC-03: a pool entry that names nothing recovers nothing, silently.
1186
+ const pool = terms['recoverable_pool'];
1187
+ if (pool != null) {
1188
+ if (!Array.isArray(pool) || pool.length === 0) {
1189
+ recIssue(issues, 'REC-03', `${p}.recoverable_pool`, 'REC-03: recoverable_pool must be a nonempty array of operating-statement expense keys', pool);
1190
+ }
1191
+ else {
1192
+ for (const [j, entry] of pool.entries()) {
1193
+ if (typeof entry !== 'string' || !RECOVERABLE_EXPENSE_KEYS.includes(entry)) {
1194
+ recIssue(issues, 'REC-03', `${p}.recoverable_pool[${j}]`, `REC-03: ${JSON.stringify(entry)} is not a recoverable operating_statement.expenses key — a pool naming a nonexistent expense recovers zero without saying so`, entry);
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1199
+ // The cap. Accumulation has no default: the three treatments diverge
1200
+ // materially within three years, so a silent choice is a wrong number.
1201
+ const cap = terms['cap'];
1202
+ if (cap != null) {
1203
+ const cp = `${p}.cap`;
1204
+ if (typeof cap !== 'object' || Array.isArray(cap)) {
1205
+ recIssue(issues, 'REC-02', cp, 'REC-02: cap must be an object', cap);
1206
+ }
1207
+ else {
1208
+ const c = cap;
1209
+ const pct = c['pct'];
1210
+ if (pct != null && !(leaseNum(pct) && pct > 0 && pct < 1)) {
1211
+ recIssue(issues, 'REC-01', `${cp}.pct`, 'REC-01: cap.pct must be a fraction in (0,1) — 0.05 is 5%, not 5', pct);
1212
+ }
1213
+ if (pct != null) {
1214
+ const acc = c['accumulation'];
1215
+ if (typeof acc !== 'string' || !RECOVERY_CAP_ACCUMULATIONS.includes(acc)) {
1216
+ recIssue(issues, 'REC-02', `${cp}.accumulation`, `REC-02: a stated cap.pct requires cap.accumulation (${RECOVERY_CAP_ACCUMULATIONS.join(' | ')}) — there is no default, because the three disagree`, acc);
1217
+ }
1218
+ }
1219
+ }
1220
+ }
1221
+ // REC-10: the legacy field and the typed cap can contradict each other.
1222
+ if (t['cam_cap_pct'] != null && cap != null) {
1223
+ recIssue(issues, 'REC-10', `${at}.cam_cap_pct`, 'REC-10: cam_cap_pct is superseded by recovery_terms.cap; stating both lets the two drift apart', t['cam_cap_pct'], 'warning');
1224
+ }
1225
+ return shareValue;
1226
+ }
1227
+ /**
1228
+ * REC-09. A settled true-up becomes a dated cash line in §4.26 — the
1229
+ * cross-cutting requirement that new dated cash lands in the addressable sink,
1230
+ * where assembly and receipt coverage already verify it. A reference that
1231
+ * resolves to nothing is the one failure mode that looks like success: the row
1232
+ * claims the amount reached the cash flows, and nothing checks that it did.
1233
+ */
1234
+ function recoveryRefResolves(parsed, variant) {
1235
+ const entry = parsed.sections['cash_flow_series'];
1236
+ if (!entry)
1237
+ return false;
1238
+ return isVariantMap(entry)
1239
+ ? entry[variant] !== undefined
1240
+ : variant === 'default';
1241
+ }
1242
+ function checkRecoveryTrueUp(t, issues, at, share, asOf, parsed) {
1243
+ const rows = t['recovery_true_up'];
1244
+ if (rows == null)
1245
+ return;
1246
+ if (!Array.isArray(rows)) {
1247
+ recIssue(issues, 'REC-04', `${at}.recovery_true_up`, 'REC-04: recovery_true_up must be an array of reconciliation rows', rows);
1248
+ return;
1249
+ }
1250
+ for (const [j, raw] of rows.entries()) {
1251
+ const p = `${at}.recovery_true_up[${j}]`;
1252
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1253
+ recIssue(issues, 'REC-04', p, 'REC-04: each true-up entry must be an object', raw);
1254
+ continue;
1255
+ }
1256
+ const row = raw;
1257
+ const start = row['period_start'];
1258
+ const end = row['period_end'];
1259
+ if (!isDate(start) || !isDate(end)) {
1260
+ recIssue(issues, 'REC-04', `${p}.period_start`, 'REC-04: a true-up states period_start and period_end as YYYY-MM-DD dates');
1261
+ continue;
1262
+ }
1263
+ if (end < start) {
1264
+ recIssue(issues, 'REC-04', `${p}.period_end`, `REC-04: period_end (${end}) precedes period_start (${start})`, end);
1265
+ continue;
1266
+ }
1267
+ // REC-05: a reconciliation of a period that has not ended is a forecast.
1268
+ // Anchored on the rent roll's own as_of_date — never on file metadata,
1269
+ // which is an edit timestamp and would refuse a legitimately re-saved
1270
+ // document. Skipped when the rent roll states no date.
1271
+ if (asOf !== null && !(end < asOf)) {
1272
+ recIssue(issues, 'REC-05', `${p}.period_end`, `REC-05: the true-up period ends ${end}, on or after the rent roll as_of_date ${asOf} — a reconciliation of an open period is a forecast, and this section carries settled facts`, end);
1273
+ }
1274
+ const poolActual = row['pool_actual'];
1275
+ const uncapped = row['tenant_share_uncapped'];
1276
+ const capped = row['tenant_share_capped'];
1277
+ const billed = row['estimated_billed'];
1278
+ const trueUp = row['true_up_amount'];
1279
+ // REC-06: the one product a single document can check.
1280
+ if (leaseNum(poolActual) && share !== null && leaseNum(uncapped)) {
1281
+ const want = roundRec(poolActual * share);
1282
+ if (roundRec(uncapped) !== want) {
1283
+ recIssue(issues, 'REC-06', `${p}.tenant_share_uncapped`, `REC-06: tenant_share_uncapped states ${uncapped} but pool_actual x pro_rata_share is ${want}`, uncapped);
1284
+ }
1285
+ }
1286
+ // REC-07: the cap can only reduce. The capped amount itself is stated,
1287
+ // not recomputed — see the section note.
1288
+ if (leaseNum(uncapped) && leaseNum(capped) && roundRec(capped) > roundRec(uncapped)) {
1289
+ recIssue(issues, 'REC-07', `${p}.tenant_share_capped`, `REC-07: tenant_share_capped (${capped}) exceeds tenant_share_uncapped (${uncapped}) — a cap cannot increase a recovery`, capped);
1290
+ }
1291
+ // REC-08: the subtraction the whole row exists to record.
1292
+ if (leaseNum(capped) && leaseNum(billed) && leaseNum(trueUp)) {
1293
+ const want = roundRec(capped - billed);
1294
+ if (roundRec(trueUp) !== want) {
1295
+ recIssue(issues, 'REC-08', `${p}.true_up_amount`, `REC-08: true_up_amount states ${trueUp} but tenant_share_capped less estimated_billed is ${want}`, trueUp);
1296
+ }
1297
+ }
1298
+ const settlement = row['settlement'];
1299
+ if (settlement != null && !(typeof settlement === 'string' && RECOVERY_SETTLEMENTS.includes(settlement))) {
1300
+ recIssue(issues, 'REC-04', `${p}.settlement`, `REC-04: settlement must be one of ${RECOVERY_SETTLEMENTS.join(', ')}`, settlement);
1301
+ }
1302
+ // REC-09: the §4.26 handoff.
1303
+ const ref = row['cash_flow_ref'];
1304
+ if (ref != null) {
1305
+ const variant = typeof ref === 'object' && !Array.isArray(ref)
1306
+ ? ref['variant']
1307
+ : undefined;
1308
+ if (typeof variant !== 'string' || variant.length === 0) {
1309
+ recIssue(issues, 'REC-09', `${p}.cash_flow_ref`, 'REC-09: cash_flow_ref must name a cash_flow_series variant', ref);
1310
+ }
1311
+ else if (!recoveryRefResolves(parsed, variant)) {
1312
+ recIssue(issues, 'REC-09', `${p}.cash_flow_ref.variant`, `REC-09: cash_flow_ref.variant ${JSON.stringify(variant)} does not resolve to a cash_flow_series variant in this document — the settled amount claims a cash line that is not there`, variant);
1313
+ }
1314
+ }
1315
+ }
1316
+ }
1096
1317
  function checkLeaseClauses(parsed, issues) {
1097
1318
  const block = resolveCrossCheckSection(parsed, 'rent_roll').block;
1098
1319
  if (!block)
@@ -1100,6 +1321,8 @@ function checkLeaseClauses(parsed, issues) {
1100
1321
  const tenants = deepGet(block.content, 'tenants');
1101
1322
  if (!Array.isArray(tenants))
1102
1323
  return;
1324
+ const rawAsOf = deepGet(block.content, 'as_of_date');
1325
+ const asOf = isDate(rawAsOf) ? rawAsOf : null;
1103
1326
  for (const [i, raw] of tenants.entries()) {
1104
1327
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1105
1328
  continue;
@@ -1109,6 +1332,8 @@ function checkLeaseClauses(parsed, issues) {
1109
1332
  checkTerminationOption(t, issues, at);
1110
1333
  checkCoTenancy(t, issues, at);
1111
1334
  checkLeasingCapital(t, issues, at);
1335
+ const share = checkRecoveryTerms(t, issues, at);
1336
+ checkRecoveryTrueUp(t, issues, at, share, asOf, parsed);
1112
1337
  }
1113
1338
  }
1114
1339
  // ─── §4.7 / §4.8 Rate hedges and escrows (RFC 0056) ──────────────────────────
@@ -2066,6 +2291,54 @@ function sumsToOne(a, b) {
2066
2291
  a >= 0 && a <= 1 && b >= 0 && b <= 1 &&
2067
2292
  Math.abs(a + b - 1) < RATIO_QUANTUM;
2068
2293
  }
2294
+ /**
2295
+ * RFC 0059. The clawback is a terminal true-up, so the only structural
2296
+ * questions are whether its basis carries the input it needs, whether the cap
2297
+ * is the one cap that exists, and whether the rates are fractions.
2298
+ */
2299
+ function checkWaterfallClawback(content, tiers, section, label, issues) {
2300
+ const raw = content['clawback'];
2301
+ if (raw == null)
2302
+ return;
2303
+ const push = (code, field, message) => {
2304
+ issues.push({ code, severity: 'error', section, field: `clawback.${field}`, message: `${code}: ${message}${label}` });
2305
+ };
2306
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
2307
+ push('WF-10', 'basis', 'clawback must be an object stating a basis and a cap');
2308
+ return;
2309
+ }
2310
+ const cb = raw;
2311
+ const basis = cb['basis'];
2312
+ const BASES = ['lp_preferred_shortfall', 'lp_irr_floor', 'lp_em_floor'];
2313
+ if (typeof basis !== 'string' || !BASES.includes(basis)) {
2314
+ push('WF-10', 'basis', `basis must be one of ${BASES.join(', ')} — the vocabulary is closed`);
2315
+ }
2316
+ else if (basis === 'lp_irr_floor') {
2317
+ const rate = cb['floor_rate'];
2318
+ if (!(typeof rate === 'number' && Number.isFinite(rate) && rate > 0 && rate < 1)) {
2319
+ push('WF-10', 'floor_rate', 'lp_irr_floor requires floor_rate as a fraction in (0,1) — 0.12 is 12%, not 12');
2320
+ }
2321
+ }
2322
+ else if (basis === 'lp_em_floor') {
2323
+ const mult = cb['floor_multiple'];
2324
+ if (!(typeof mult === 'number' && Number.isFinite(mult) && mult > 1)) {
2325
+ push('WF-10', 'floor_multiple', 'lp_em_floor requires floor_multiple greater than 1');
2326
+ }
2327
+ }
2328
+ // WF-11: a preferred-shortfall floor with no preferred tier to measure it.
2329
+ if (basis === 'lp_preferred_shortfall' && !tiers.some((t) => t?.type === 'preferred_return')) {
2330
+ push('WF-11', 'basis', 'lp_preferred_shortfall needs a preferred_return tier — without one there is no accrual to fall short of');
2331
+ }
2332
+ // WF-12: the cap is not a choice.
2333
+ if (cb['cap'] !== 'promote_received') {
2334
+ push('WF-12', 'cap', 'cap must be "promote_received" — a GP cannot owe back more promote than it received, and any other cap is a different instrument');
2335
+ }
2336
+ // WF-13: a stated tax rate is a fraction.
2337
+ const tax = cb['net_of_tax_rate'];
2338
+ if (tax != null && !(typeof tax === 'number' && Number.isFinite(tax) && tax >= 0 && tax < 1)) {
2339
+ push('WF-13', 'net_of_tax_rate', 'net_of_tax_rate must be a fraction in [0,1) — 0.37 is 37%, not 37');
2340
+ }
2341
+ }
2069
2342
  function checkWaterfallContent(content, variant, parsed, issues) {
2070
2343
  const section = 'distribution_waterfall';
2071
2344
  const label = variant === 'default' ? '' : ` (variant "${variant}")`;
@@ -2193,6 +2466,8 @@ function checkWaterfallContent(content, variant, parsed, issues) {
2193
2466
  wf01('tiers', 'the ladder must end in at least one split tier');
2194
2467
  }
2195
2468
  }
2469
+ // WF-10 .. WF-15: the RFC 0059 clawback provision.
2470
+ checkWaterfallClawback(content, rows, section, label, issues);
2196
2471
  // WF-02 / WF-03: the cash reference.
2197
2472
  const ref = content['cash_flow_ref'];
2198
2473
  const refVariant = ref && typeof ref['variant'] === 'string' ? ref['variant'] : null;
@@ -2224,6 +2499,19 @@ function checkWaterfallContent(content, variant, parsed, issues) {
2224
2499
  message: `WF-03: the referenced series${label} has no contribution (no negative amount); a waterfall over pure inflows has no capital to return`,
2225
2500
  });
2226
2501
  }
2502
+ // WF-15 (RFC 0059): a clawback provision is unexercisable without a terminal
2503
+ // distribution to have overpaid out of. A warning, not an error — the
2504
+ // provision may legitimately be stated ahead of the exit.
2505
+ if (content['clawback'] != null && Array.isArray(seriesRows)) {
2506
+ const lastPositive = [...seriesRows].reverse().find((r) => r && typeof r === 'object' && typeof r['amount'] === 'number' &&
2507
+ r['amount'] > 0);
2508
+ if (lastPositive === undefined) {
2509
+ issues.push({
2510
+ code: 'WF-15', severity: 'warning', section, field: 'clawback',
2511
+ message: `WF-15: a clawback provision is stated but the referenced series${label} has no distribution, so no promote can have been paid and the provision is unexercisable as stated`,
2512
+ });
2513
+ }
2514
+ }
2227
2515
  }
2228
2516
  function checkWaterfall(parsed, issues) {
2229
2517
  const entry = parsed.sections['distribution_waterfall'];