@uwmd/core 2.10.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/browser.d.ts +4 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +4 -0
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.js +1 -1
- package/dist/validator.d.ts +34 -0
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +888 -0
- package/dist/validator.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/waterfall.d.ts +37 -0
- package/dist/waterfall.d.ts.map +1 -1
- package/dist/waterfall.js +67 -1
- package/dist/waterfall.js.map +1 -1
- package/package.json +2 -2
package/dist/validator.js
CHANGED
|
@@ -106,6 +106,9 @@ export function validateUWFile(parsed, thresholdOverrides) {
|
|
|
106
106
|
checkSectionReadiness(parsed, issues, ledger);
|
|
107
107
|
checkReturnsTaxBasis(parsed, issues);
|
|
108
108
|
checkTaxBasis(parsed, issues);
|
|
109
|
+
checkLeaseClauses(parsed, issues);
|
|
110
|
+
checkHedgesAndEscrows(parsed, issues);
|
|
111
|
+
checkRenovationDraw(parsed, issues);
|
|
109
112
|
checkLocale(parsed, issues);
|
|
110
113
|
checkCurrencyIdentity(parsed, issues);
|
|
111
114
|
checkAssetClassIdentifier(parsed, issues);
|
|
@@ -877,6 +880,828 @@ function checkTaxBasis(parsed, issues) {
|
|
|
877
880
|
taxIssue(issues, 'TAX-08', 'dcf', `${base}.value_basis`, `TAX-08: terminal_tax.value_basis ${basis} must equal exit_value_gross ${gross} for a sale trigger, or state value_basis_differs_because`, basis);
|
|
878
881
|
}
|
|
879
882
|
}
|
|
883
|
+
// ─── §4.3 Commercial lease clauses (RFC 0055) ────────────────────────────────
|
|
884
|
+
//
|
|
885
|
+
// RFC 0054 placed lease clauses on the lease record because they are attributes
|
|
886
|
+
// of a lease, not of a period. These rules check stated terms: nothing escalates
|
|
887
|
+
// a rent, exercises a break, applies a remedy or amortizes a balance.
|
|
888
|
+
/** What a stated termination penalty is composed of. Closed. */
|
|
889
|
+
export const TERMINATION_PENALTY_COMPONENTS = Object.freeze([
|
|
890
|
+
'unamortized_ti', 'unamortized_lc', 'free_rent', 'fee',
|
|
891
|
+
]);
|
|
892
|
+
export const CO_TENANCY_TRIGGERS = Object.freeze([
|
|
893
|
+
'named_tenant_departure', 'occupancy_threshold', 'both',
|
|
894
|
+
]);
|
|
895
|
+
export const CO_TENANCY_REMEDIES = Object.freeze([
|
|
896
|
+
'rent_reduction', 'alternate_rent', 'termination_right',
|
|
897
|
+
]);
|
|
898
|
+
const leaseNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
899
|
+
const isDate = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && parseISODate(v) !== null;
|
|
900
|
+
function leaseIssue(issues, code, field, message, value) {
|
|
901
|
+
issues.push({
|
|
902
|
+
code, severity: 'error', section: 'rent_roll', field, message,
|
|
903
|
+
...(value !== undefined ? { value } : {}),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
function checkEscalationSchedule(t, issues, at) {
|
|
907
|
+
const steps = t['escalation_schedule'];
|
|
908
|
+
if (steps === undefined || steps === null)
|
|
909
|
+
return;
|
|
910
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
911
|
+
leaseIssue(issues, 'LSE-01', `${at}.escalation_schedule`, 'LSE-01: escalation_schedule must be a nonempty array when stated');
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
// LSE-03: a flat lease does not carry a step schedule.
|
|
915
|
+
const type = t['escalation_type'];
|
|
916
|
+
if (type === undefined || type === null || type === 'none') {
|
|
917
|
+
leaseIssue(issues, 'LSE-03', `${at}.escalation_type`, 'LSE-03: a stated escalation_schedule requires an escalation_type other than "none"', type);
|
|
918
|
+
}
|
|
919
|
+
const dates = [];
|
|
920
|
+
for (const [i, raw] of steps.entries()) {
|
|
921
|
+
const p = `${at}.escalation_schedule[${i}]`;
|
|
922
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
923
|
+
leaseIssue(issues, 'LSE-01', p, 'LSE-01: each escalation step must be an object');
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const step = raw;
|
|
927
|
+
if (!isDate(step['effective_date'])) {
|
|
928
|
+
leaseIssue(issues, 'LSE-01', `${p}.effective_date`, 'LSE-01: effective_date must be a real YYYY-MM-DD date', step['effective_date']);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const rent = step['base_rent_annual'];
|
|
932
|
+
if (!leaseNum(rent) || rent < 0) {
|
|
933
|
+
leaseIssue(issues, 'LSE-01', `${p}.base_rent_annual`, 'LSE-01: base_rent_annual must be a finite nonnegative number', rent);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
dates.push(step['effective_date']);
|
|
937
|
+
}
|
|
938
|
+
for (let i = 1; i < dates.length; i++) {
|
|
939
|
+
if (dates[i - 1] >= dates[i]) {
|
|
940
|
+
leaseIssue(issues, 'LSE-01', `${at}.escalation_schedule[${i}].effective_date`, `LSE-01: escalation steps must strictly increase by date (${dates[i - 1]} then ${dates[i]})`, dates[i]);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// LSE-02: steps live inside the lease term when the term is stated.
|
|
945
|
+
const start = t['lease_commencement'];
|
|
946
|
+
const end = t['lease_expiration'];
|
|
947
|
+
if (!isDate(start) || !isDate(end))
|
|
948
|
+
return;
|
|
949
|
+
for (const [i, d] of dates.entries()) {
|
|
950
|
+
if (d < start || d > end) {
|
|
951
|
+
leaseIssue(issues, 'LSE-02', `${at}.escalation_schedule[${i}].effective_date`, `LSE-02: escalation step ${d} lies outside the lease term ${start}..${end}`, d);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function checkTerminationOption(t, issues, at) {
|
|
957
|
+
const raw = t['termination_option'];
|
|
958
|
+
if (raw === undefined || raw === null)
|
|
959
|
+
return;
|
|
960
|
+
const p = `${at}.termination_option`;
|
|
961
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
962
|
+
leaseIssue(issues, 'LSE-04', p, 'LSE-04: termination_option must be an object when stated', raw);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const opt = raw;
|
|
966
|
+
if (!isDate(opt['earliest_date'])) {
|
|
967
|
+
leaseIssue(issues, 'LSE-04', `${p}.earliest_date`, 'LSE-04: earliest_date must be a real YYYY-MM-DD date', opt['earliest_date']);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const notice = opt['notice_months'];
|
|
971
|
+
if (!Number.isSafeInteger(notice) || notice < 0) {
|
|
972
|
+
leaseIssue(issues, 'LSE-04', `${p}.notice_months`, 'LSE-04: notice_months must be a nonnegative whole number of months', notice);
|
|
973
|
+
}
|
|
974
|
+
const penalty = opt['penalty'];
|
|
975
|
+
if (penalty !== undefined && penalty !== null && (!leaseNum(penalty) || penalty < 0)) {
|
|
976
|
+
leaseIssue(issues, 'LSE-04', `${p}.penalty`, 'LSE-04: a stated penalty must be a finite nonnegative amount; null means genuinely none', penalty);
|
|
977
|
+
}
|
|
978
|
+
const start = t['lease_commencement'];
|
|
979
|
+
const end = t['lease_expiration'];
|
|
980
|
+
if (isDate(start) && isDate(end)) {
|
|
981
|
+
const d = opt['earliest_date'];
|
|
982
|
+
if (d < start || d > end) {
|
|
983
|
+
leaseIssue(issues, 'LSE-04', `${p}.earliest_date`, `LSE-04: the break date ${d} lies outside the lease term ${start}..${end}`, d);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
// LSE-05: what the penalty is composed of, from a closed list, without repeats.
|
|
987
|
+
const parts = opt['penalty_includes'];
|
|
988
|
+
if (parts === undefined || parts === null)
|
|
989
|
+
return;
|
|
990
|
+
if (!Array.isArray(parts) || parts.length === 0) {
|
|
991
|
+
leaseIssue(issues, 'LSE-05', `${p}.penalty_includes`, 'LSE-05: penalty_includes must be a nonempty array when stated');
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const seen = new Set();
|
|
995
|
+
for (const [i, part] of parts.entries()) {
|
|
996
|
+
if (typeof part !== 'string' || !TERMINATION_PENALTY_COMPONENTS.includes(part)) {
|
|
997
|
+
leaseIssue(issues, 'LSE-05', `${p}.penalty_includes[${i}]`, `LSE-05: penalty component must be one of ${TERMINATION_PENALTY_COMPONENTS.join(', ')}`, part);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (seen.has(part)) {
|
|
1001
|
+
leaseIssue(issues, 'LSE-05', `${p}.penalty_includes[${i}]`, `LSE-05: penalty component ${part} is listed more than once`, part);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
seen.add(part);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function checkCoTenancy(t, issues, at) {
|
|
1008
|
+
const raw = t['co_tenancy_details'];
|
|
1009
|
+
const flag = t['co_tenancy_clause'];
|
|
1010
|
+
const stated = raw !== undefined && raw !== null;
|
|
1011
|
+
// LSE-06: the boolean and the body agree, or neither is stated. Same move
|
|
1012
|
+
// TAX-04 made for sale_triggers_reassessment: a flag that asserts nothing
|
|
1013
|
+
// until something has to agree with it.
|
|
1014
|
+
if (stated && flag !== true) {
|
|
1015
|
+
leaseIssue(issues, 'LSE-06', `${at}.co_tenancy_clause`, 'LSE-06: co_tenancy_details requires co_tenancy_clause: true', flag);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (!stated) {
|
|
1019
|
+
if (flag === true) {
|
|
1020
|
+
leaseIssue(issues, 'LSE-06', `${at}.co_tenancy_details`, 'LSE-06: co_tenancy_clause: true requires co_tenancy_details saying what triggers it and what the remedy is');
|
|
1021
|
+
}
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const p = `${at}.co_tenancy_details`;
|
|
1025
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1026
|
+
leaseIssue(issues, 'LSE-06', p, 'LSE-06: co_tenancy_details must be an object when stated', raw);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const d = raw;
|
|
1030
|
+
const trigger = d['trigger'];
|
|
1031
|
+
if (typeof trigger !== 'string' || !CO_TENANCY_TRIGGERS.includes(trigger)) {
|
|
1032
|
+
leaseIssue(issues, 'LSE-07', `${p}.trigger`, `LSE-07: trigger must be one of ${CO_TENANCY_TRIGGERS.join(', ')}`, trigger);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
// LSE-07: the trigger carries what it needs.
|
|
1036
|
+
if (trigger === 'named_tenant_departure' || trigger === 'both') {
|
|
1037
|
+
const named = d['named_cotenants'];
|
|
1038
|
+
if (!Array.isArray(named) || named.length === 0 || named.some(n => typeof n !== 'string' || n.length === 0)) {
|
|
1039
|
+
leaseIssue(issues, 'LSE-07', `${p}.named_cotenants`, `LSE-07: trigger "${trigger}" requires a nonempty list of named co-tenants`, named);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (trigger === 'occupancy_threshold' || trigger === 'both') {
|
|
1043
|
+
const th = d['occupancy_threshold'];
|
|
1044
|
+
if (!leaseNum(th) || th <= 0 || th >= 1) {
|
|
1045
|
+
leaseIssue(issues, 'LSE-07', `${p}.occupancy_threshold`, `LSE-07: trigger "${trigger}" requires an occupancy_threshold fraction between 0 and 1, exclusive`, th);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const remedy = d['remedy'];
|
|
1049
|
+
if (typeof remedy !== 'string' || !CO_TENANCY_REMEDIES.includes(remedy)) {
|
|
1050
|
+
leaseIssue(issues, 'LSE-08', `${p}.remedy`, `LSE-08: remedy must be one of ${CO_TENANCY_REMEDIES.join(', ')}`, remedy);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
// LSE-08: a remedy that changes rent states by how much; a termination right does not.
|
|
1054
|
+
const value = d['remedy_value'];
|
|
1055
|
+
const hasValue = value !== undefined && value !== null;
|
|
1056
|
+
if (remedy === 'termination_right') {
|
|
1057
|
+
if (hasValue) {
|
|
1058
|
+
leaseIssue(issues, 'LSE-08', `${p}.remedy_value`, 'LSE-08: a termination_right has no remedy_value; the remedy is the right itself', value);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
else if (!hasValue) {
|
|
1062
|
+
leaseIssue(issues, 'LSE-08', `${p}.remedy_value`, `LSE-08: remedy "${remedy}" requires a remedy_value`);
|
|
1063
|
+
}
|
|
1064
|
+
else if (remedy === 'rent_reduction' && (!leaseNum(value) || value <= 0 || value > 1)) {
|
|
1065
|
+
leaseIssue(issues, 'LSE-08', `${p}.remedy_value`, 'LSE-08: a rent_reduction remedy_value is a fraction in (0, 1]', value);
|
|
1066
|
+
}
|
|
1067
|
+
else if (remedy === 'alternate_rent' && (!leaseNum(value) || value < 0)) {
|
|
1068
|
+
leaseIssue(issues, 'LSE-08', `${p}.remedy_value`, 'LSE-08: an alternate_rent remedy_value is a nonnegative amount', value);
|
|
1069
|
+
}
|
|
1070
|
+
const cure = d['cure_period_months'];
|
|
1071
|
+
if (cure !== undefined && cure !== null && (!Number.isSafeInteger(cure) || cure < 0)) {
|
|
1072
|
+
leaseIssue(issues, 'LSE-07', `${p}.cure_period_months`, 'LSE-07: cure_period_months must be a nonnegative whole number of months', cure);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function checkLeasingCapital(t, issues, at) {
|
|
1076
|
+
// LSE-09: state the balances. Amortization is periodic and RFC 0054 deferred it.
|
|
1077
|
+
for (const [original, outstanding, label] of [
|
|
1078
|
+
['ti_allowance_original', 'ti_outstanding_balance', 'tenant improvement'],
|
|
1079
|
+
['lc_original', 'lc_outstanding_balance', 'leasing commission'],
|
|
1080
|
+
]) {
|
|
1081
|
+
for (const key of [original, outstanding]) {
|
|
1082
|
+
const v = t[key];
|
|
1083
|
+
if (v === undefined || v === null)
|
|
1084
|
+
continue;
|
|
1085
|
+
if (!leaseNum(v) || v < 0) {
|
|
1086
|
+
leaseIssue(issues, 'LSE-09', `${at}.${key}`, `LSE-09: ${key} must be a finite nonnegative amount`, v);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const o = t[original];
|
|
1090
|
+
const b = t[outstanding];
|
|
1091
|
+
if (leaseNum(o) && leaseNum(b) && o >= 0 && b >= 0 && b > o) {
|
|
1092
|
+
leaseIssue(issues, 'LSE-09', `${at}.${outstanding}`, `LSE-09: the outstanding ${label} balance ${b} cannot exceed the original ${o}`, b);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
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
|
+
}
|
|
1317
|
+
function checkLeaseClauses(parsed, issues) {
|
|
1318
|
+
const block = resolveCrossCheckSection(parsed, 'rent_roll').block;
|
|
1319
|
+
if (!block)
|
|
1320
|
+
return;
|
|
1321
|
+
const tenants = deepGet(block.content, 'tenants');
|
|
1322
|
+
if (!Array.isArray(tenants))
|
|
1323
|
+
return;
|
|
1324
|
+
const rawAsOf = deepGet(block.content, 'as_of_date');
|
|
1325
|
+
const asOf = isDate(rawAsOf) ? rawAsOf : null;
|
|
1326
|
+
for (const [i, raw] of tenants.entries()) {
|
|
1327
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1328
|
+
continue;
|
|
1329
|
+
const t = raw;
|
|
1330
|
+
const at = `tenants[${i}]`;
|
|
1331
|
+
checkEscalationSchedule(t, issues, at);
|
|
1332
|
+
checkTerminationOption(t, issues, at);
|
|
1333
|
+
checkCoTenancy(t, issues, at);
|
|
1334
|
+
checkLeasingCapital(t, issues, at);
|
|
1335
|
+
const share = checkRecoveryTerms(t, issues, at);
|
|
1336
|
+
checkRecoveryTrueUp(t, issues, at, share, asOf, parsed);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// ─── §4.7 / §4.8 Rate hedges and escrows (RFC 0056) ──────────────────────────
|
|
1340
|
+
//
|
|
1341
|
+
// A cap's strike, notional and term are stated, never priced. Nothing values
|
|
1342
|
+
// the instrument, projects a strike crossing or rolls an escrow balance
|
|
1343
|
+
// forward — that needs a forward curve, which this contract does not fetch.
|
|
1344
|
+
// The one thing these rules insist on is that the author answer what happens
|
|
1345
|
+
// when the cap expires, and that a stated replacement is a funded line.
|
|
1346
|
+
/** The only instrument this contract types. Closed. */
|
|
1347
|
+
export const HEDGE_INSTRUMENTS = Object.freeze(['rate_cap']);
|
|
1348
|
+
/**
|
|
1349
|
+
* Named, refused, and left for a mark-to-market contract. Both can be worth
|
|
1350
|
+
* less than zero; a cap cannot. Typing them as caps would make the capital
|
|
1351
|
+
* stack wrong in the one case that matters.
|
|
1352
|
+
*/
|
|
1353
|
+
export const RESERVED_HEDGE_INSTRUMENTS = Object.freeze([
|
|
1354
|
+
'rate_swap', 'rate_collar',
|
|
1355
|
+
]);
|
|
1356
|
+
/** The `rate_index` vocabulary minus `fixed`, which a cap is not struck against. */
|
|
1357
|
+
export const HEDGE_INDEXES = Object.freeze([
|
|
1358
|
+
'sofr', 'prime', 'treasury_5yr', 'treasury_10yr',
|
|
1359
|
+
]);
|
|
1360
|
+
/** What the author says happens in the month after the cap expires. No default. */
|
|
1361
|
+
export const HEDGE_EXPIRY_ASSUMPTIONS = Object.freeze([
|
|
1362
|
+
'replace', 'unhedged', 'loan_matures_first',
|
|
1363
|
+
]);
|
|
1364
|
+
/** Closed, with a label-bearing `other` — the RFC 0052 shape. */
|
|
1365
|
+
export const ESCROW_NAMES = Object.freeze([
|
|
1366
|
+
'tax', 'insurance', 'replacement_reserve', 'ti_lc',
|
|
1367
|
+
'interest', 'operating', 'rate_cap_replacement', 'other',
|
|
1368
|
+
]);
|
|
1369
|
+
function hedgeIssue(issues, code, section, field, message, value) {
|
|
1370
|
+
issues.push({ code, severity: 'error', section, field, message, ...(value !== undefined ? { value } : {}) });
|
|
1371
|
+
}
|
|
1372
|
+
/** The §4.7 hedge object: shape, the rate_type gate, and the legacy agreement. */
|
|
1373
|
+
function checkRateHedge(h, rateType, legacyCapPct, issues) {
|
|
1374
|
+
const at = 'rate_hedge';
|
|
1375
|
+
const inst = h['instrument'];
|
|
1376
|
+
// HDG-02 before HDG-01: a reserved name earns its own message, not "unknown".
|
|
1377
|
+
if (typeof inst === 'string' && RESERVED_HEDGE_INSTRUMENTS.includes(inst)) {
|
|
1378
|
+
hedgeIssue(issues, 'HDG-02', 'debt_structure', `${at}.instrument`, `HDG-02: ${inst} is reserved for a later mark-to-market contract and is refused here — its value moves with the curve and can be negative, which a cap's cannot`, inst);
|
|
1379
|
+
}
|
|
1380
|
+
else if (typeof inst !== 'string' || !HEDGE_INSTRUMENTS.includes(inst)) {
|
|
1381
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.instrument`, `HDG-01: instrument must be one of ${HEDGE_INSTRUMENTS.join(', ')}`, inst);
|
|
1382
|
+
}
|
|
1383
|
+
const notional = h['notional'];
|
|
1384
|
+
if (!finiteNum(notional) || notional < 0) {
|
|
1385
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.notional`, 'HDG-01: notional must be a finite nonnegative amount', notional);
|
|
1386
|
+
}
|
|
1387
|
+
// A fraction, not a percent — a strike of 3.5 is 350%, which is the mistake
|
|
1388
|
+
// this bound exists to catch.
|
|
1389
|
+
const strike = h['strike_rate'];
|
|
1390
|
+
if (!finiteNum(strike) || strike <= 0 || strike >= 1) {
|
|
1391
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.strike_rate`, 'HDG-01: strike_rate must be a fraction in (0, 1) — 0.035 is 3.5%', strike);
|
|
1392
|
+
}
|
|
1393
|
+
const index = h['index'];
|
|
1394
|
+
if (typeof index !== 'string' || !HEDGE_INDEXES.includes(index)) {
|
|
1395
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.index`, `HDG-01: index must be one of ${HEDGE_INDEXES.join(', ')}`, index);
|
|
1396
|
+
}
|
|
1397
|
+
const eff = h['effective_date'];
|
|
1398
|
+
const exp = h['expiration_date'];
|
|
1399
|
+
if (!isDate(eff)) {
|
|
1400
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.effective_date`, 'HDG-01: effective_date must be a real YYYY-MM-DD date', eff);
|
|
1401
|
+
}
|
|
1402
|
+
if (!isDate(exp)) {
|
|
1403
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.expiration_date`, 'HDG-01: expiration_date must be a real YYYY-MM-DD date', exp);
|
|
1404
|
+
}
|
|
1405
|
+
else if (isDate(eff) && exp <= eff) {
|
|
1406
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.expiration_date`, `HDG-01: expiration_date ${exp} must be strictly after effective_date ${eff}`, exp);
|
|
1407
|
+
}
|
|
1408
|
+
const premium = h['premium'];
|
|
1409
|
+
if (premium !== undefined && premium !== null && (!finiteNum(premium) || premium < 0)) {
|
|
1410
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.premium`, 'HDG-01: premium must be a finite nonnegative amount, or null for genuinely none', premium);
|
|
1411
|
+
}
|
|
1412
|
+
// HDG-03: a fixed-rate loan does not carry a rate cap.
|
|
1413
|
+
if (rateType !== 'floating' && rateType !== 'hybrid') {
|
|
1414
|
+
hedgeIssue(issues, 'HDG-03', 'debt_structure', 'rate_type', `HDG-03: a stated rate_hedge requires rate_type "floating" or "hybrid" (found ${JSON.stringify(rateType)})`, rateType);
|
|
1415
|
+
}
|
|
1416
|
+
// HDG-04: the legacy scalar has to agree with the typed body.
|
|
1417
|
+
if (finiteNum(legacyCapPct) && finiteNum(strike) && legacyCapPct !== strike) {
|
|
1418
|
+
hedgeIssue(issues, 'HDG-04', 'debt_structure', 'rate_cap_pct', `HDG-04: rate_cap_pct ${legacyCapPct} disagrees with rate_hedge.strike_rate ${strike}`, legacyCapPct);
|
|
1419
|
+
}
|
|
1420
|
+
// HDG-06: no default. A cap's expiry is the fact the reader came for, and
|
|
1421
|
+
// "unstated" is the answer that hides the cliff.
|
|
1422
|
+
const after = h['post_expiration_assumption'];
|
|
1423
|
+
if (typeof after !== 'string' || !HEDGE_EXPIRY_ASSUMPTIONS.includes(after)) {
|
|
1424
|
+
hedgeIssue(issues, 'HDG-06', 'debt_structure', `${at}.post_expiration_assumption`, `HDG-06: post_expiration_assumption must be stated as one of ${HEDGE_EXPIRY_ASSUMPTIONS.join(', ')}`, after);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
/** The §4.8 escrow array. Returns the set of names it managed to read. */
|
|
1428
|
+
function checkEscrows(escrows, uses, issues) {
|
|
1429
|
+
const seen = new Set();
|
|
1430
|
+
const at = 'uses.escrows';
|
|
1431
|
+
if (!Array.isArray(escrows) || escrows.length === 0) {
|
|
1432
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', at, 'ESC-01: escrows must be a nonempty array when stated');
|
|
1433
|
+
return seen;
|
|
1434
|
+
}
|
|
1435
|
+
const labels = new Set();
|
|
1436
|
+
const upfrontByName = new Map();
|
|
1437
|
+
for (const [i, raw] of escrows.entries()) {
|
|
1438
|
+
const p = `${at}[${i}]`;
|
|
1439
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1440
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', p, 'ESC-01: each escrow must be an object');
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const e = raw;
|
|
1444
|
+
const name = e['name'];
|
|
1445
|
+
if (typeof name !== 'string' || !ESCROW_NAMES.includes(name)) {
|
|
1446
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', `${p}.name`, `ESC-01: escrow name must be one of ${ESCROW_NAMES.join(', ')}`, name);
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
const label = e['label'];
|
|
1450
|
+
if (name === 'other') {
|
|
1451
|
+
// ESC-02: `other` says nothing until the label says what it is.
|
|
1452
|
+
if (typeof label !== 'string' || label.trim().length === 0) {
|
|
1453
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.label`, 'ESC-02: an "other" escrow requires a nonempty label', label);
|
|
1454
|
+
}
|
|
1455
|
+
else if (labels.has(label.trim())) {
|
|
1456
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.label`, `ESC-02: duplicate "other" escrow label ${JSON.stringify(label.trim())}`, label);
|
|
1457
|
+
}
|
|
1458
|
+
else {
|
|
1459
|
+
labels.add(label.trim());
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
if (label !== undefined && label !== null) {
|
|
1464
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.label`, `ESC-02: only an "other" escrow carries a label; ${name} names itself`, label);
|
|
1465
|
+
}
|
|
1466
|
+
if (seen.has(name)) {
|
|
1467
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.name`, `ESC-02: duplicate escrow name ${JSON.stringify(name)}`, name);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
seen.add(name);
|
|
1471
|
+
// ESC-01: an escrow that funds neither at close nor monthly is not one.
|
|
1472
|
+
let stated = 0;
|
|
1473
|
+
for (const key of ['upfront', 'monthly']) {
|
|
1474
|
+
const v = e[key];
|
|
1475
|
+
if (v === undefined || v === null)
|
|
1476
|
+
continue;
|
|
1477
|
+
if (!finiteNum(v) || v < 0) {
|
|
1478
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', `${p}.${key}`, `ESC-01: ${key} must be a finite nonnegative amount`, v);
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
stated++;
|
|
1482
|
+
if (key === 'upfront' && !upfrontByName.has(name))
|
|
1483
|
+
upfrontByName.set(name, v);
|
|
1484
|
+
}
|
|
1485
|
+
if (stated === 0) {
|
|
1486
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', p, 'ESC-01: an escrow must state at least one of upfront or monthly');
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// ESC-03: the legacy scalars have to agree with the typed lines.
|
|
1490
|
+
for (const [legacyKey, name] of [
|
|
1491
|
+
['interest_reserve', 'interest'],
|
|
1492
|
+
['operating_reserves', 'operating'],
|
|
1493
|
+
]) {
|
|
1494
|
+
const legacy = uses[legacyKey];
|
|
1495
|
+
const typed = upfrontByName.get(name);
|
|
1496
|
+
if (finiteNum(legacy) && typed !== undefined && !sameMoney(legacy, typed)) {
|
|
1497
|
+
hedgeIssue(issues, 'ESC-03', 'sources_uses', `uses.${legacyKey}`, `ESC-03: uses.${legacyKey} ${legacy} disagrees with the ${name} escrow's upfront ${typed}`, legacy);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return seen;
|
|
1501
|
+
}
|
|
1502
|
+
function checkHedgesAndEscrows(parsed, issues) {
|
|
1503
|
+
const debt = resolveCrossCheckSection(parsed, 'debt_structure').block;
|
|
1504
|
+
const su = resolveCrossCheckSection(parsed, 'sources_uses').block;
|
|
1505
|
+
let assumption;
|
|
1506
|
+
let hedgeStated = false;
|
|
1507
|
+
if (debt) {
|
|
1508
|
+
const h = deepGet(debt.content, 'rate_hedge');
|
|
1509
|
+
if (h !== undefined && h !== null) {
|
|
1510
|
+
if (typeof h !== 'object' || Array.isArray(h)) {
|
|
1511
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', 'rate_hedge', 'HDG-01: rate_hedge must be an object when stated', h);
|
|
1512
|
+
}
|
|
1513
|
+
else {
|
|
1514
|
+
hedgeStated = true;
|
|
1515
|
+
const rec = h;
|
|
1516
|
+
assumption = rec['post_expiration_assumption'];
|
|
1517
|
+
checkRateHedge(rec, deepGet(debt.content, 'rate_type'), deepGet(debt.content, 'rate_cap_pct'), issues);
|
|
1518
|
+
// HDG-05: the premium is the same cash as the use that funds it.
|
|
1519
|
+
const premium = rec['premium'];
|
|
1520
|
+
const cost = su ? deepGet(su.content, 'uses.rate_cap_cost') : undefined;
|
|
1521
|
+
if (finiteNum(premium) && finiteNum(cost) && !sameMoney(premium, cost)) {
|
|
1522
|
+
hedgeIssue(issues, 'HDG-05', 'debt_structure', 'rate_hedge.premium', `HDG-05: rate_hedge.premium ${premium} disagrees with sources_uses.uses.rate_cap_cost ${cost}`, premium);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (!su)
|
|
1528
|
+
return;
|
|
1529
|
+
const uses = deepGet(su.content, 'uses');
|
|
1530
|
+
if (uses === null || typeof uses !== 'object' || Array.isArray(uses))
|
|
1531
|
+
return;
|
|
1532
|
+
const u = uses;
|
|
1533
|
+
const escrows = u['escrows'];
|
|
1534
|
+
const names = escrows === undefined || escrows === null
|
|
1535
|
+
? new Set()
|
|
1536
|
+
: checkEscrows(escrows, u, issues);
|
|
1537
|
+
// ESC-04: the rule that turns "this cap expires in year three" from a note
|
|
1538
|
+
// into a funded line. Both directions, so neither side can drift alone.
|
|
1539
|
+
const wantsReplacement = hedgeStated && assumption === 'replace';
|
|
1540
|
+
const hasReplacement = names.has('rate_cap_replacement');
|
|
1541
|
+
if (wantsReplacement && !hasReplacement) {
|
|
1542
|
+
hedgeIssue(issues, 'ESC-04', 'sources_uses', 'uses.escrows', 'ESC-04: rate_hedge.post_expiration_assumption "replace" requires a rate_cap_replacement escrow');
|
|
1543
|
+
}
|
|
1544
|
+
else if (hasReplacement && !wantsReplacement) {
|
|
1545
|
+
hedgeIssue(issues, 'ESC-04', 'sources_uses', 'uses.escrows', `ESC-04: a rate_cap_replacement escrow requires rate_hedge.post_expiration_assumption "replace" (found ${JSON.stringify(assumption)})`, assumption);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
// ─── §4.8 Renovation draw and expense-targeted capex (RFC 0057) ──────────────
|
|
1549
|
+
//
|
|
1550
|
+
// A contingency is the number a construction lender watches, and two deals
|
|
1551
|
+
// stating the same one are indistinguishable when one has drawn none of it and
|
|
1552
|
+
// the other has drawn all of it. These rules read the draw the author stated.
|
|
1553
|
+
//
|
|
1554
|
+
// Nothing here applies a saving. `annual_savings` is never subtracted from an
|
|
1555
|
+
// expense line, from EGI or from NOI — `in_noi_model` records whether the author
|
|
1556
|
+
// already did, and CAPX-07 makes them say so, because a stated saving with no
|
|
1557
|
+
// such flag is how a document gets double-counted.
|
|
1558
|
+
/** Where the payback arithmetic is compared. Years, not money. */
|
|
1559
|
+
const PAYBACK_DP = 4;
|
|
1560
|
+
function capxIssue(issues, code, field, message, value) {
|
|
1561
|
+
issues.push({
|
|
1562
|
+
code, severity: 'error', section: 'sources_uses', field, message,
|
|
1563
|
+
...(value !== undefined ? { value } : {}),
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* One `expense_targeted` entry. `expenseKeys` is null when `noi_model` is absent
|
|
1568
|
+
* or unreadable, in which case CAPX-06 checks the shape but not the target.
|
|
1569
|
+
*/
|
|
1570
|
+
function checkExpenseTargeted(e, expenseKeys, issues, at) {
|
|
1571
|
+
const label = e['label'];
|
|
1572
|
+
if (typeof label !== 'string' || label.trim().length === 0) {
|
|
1573
|
+
capxIssue(issues, 'CAPX-06', `${at}.label`, 'CAPX-06: an expense-targeted capex entry requires a nonempty label', label);
|
|
1574
|
+
}
|
|
1575
|
+
for (const key of ['amount', 'annual_savings']) {
|
|
1576
|
+
const v = e[key];
|
|
1577
|
+
if (!finiteNum(v) || v < 0) {
|
|
1578
|
+
capxIssue(issues, 'CAPX-06', `${at}.${key}`, `CAPX-06: ${key} must be a finite nonnegative amount`, v);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
const begin = e['savings_begin'];
|
|
1582
|
+
if (typeof begin !== 'string' || taxPeriodKind(begin) === null) {
|
|
1583
|
+
capxIssue(issues, 'CAPX-06', `${at}.savings_begin`, 'CAPX-06: savings_begin must be an RFC 0041 period selector (Y1+, YYYY-Qn, YYYY-MM or YYYY-MM-DD)', begin);
|
|
1584
|
+
}
|
|
1585
|
+
// The target is checked against the keys actually present, not a hardcoded
|
|
1586
|
+
// list, so a module adding a class-specific expense line keeps working.
|
|
1587
|
+
const targets = e['targets'];
|
|
1588
|
+
if (typeof targets !== 'string' || targets.trim().length === 0) {
|
|
1589
|
+
capxIssue(issues, 'CAPX-06', `${at}.targets`, 'CAPX-06: targets must name the noi_model.expenses key this project reduces', targets);
|
|
1590
|
+
}
|
|
1591
|
+
else if (expenseKeys !== null && !expenseKeys.has(targets)) {
|
|
1592
|
+
capxIssue(issues, 'CAPX-06', `${at}.targets`, `CAPX-06: targets ${JSON.stringify(targets)} names no key under noi_model.expenses`, targets);
|
|
1593
|
+
}
|
|
1594
|
+
// CAPX-07: the disclosure that keeps a reader from applying the saving twice.
|
|
1595
|
+
if (typeof e['in_noi_model'] !== 'boolean') {
|
|
1596
|
+
capxIssue(issues, 'CAPX-07', `${at}.in_noi_model`, 'CAPX-07: in_noi_model must state whether this saving is already inside noi_model', e['in_noi_model']);
|
|
1597
|
+
}
|
|
1598
|
+
const payback = e['simple_payback_years'];
|
|
1599
|
+
if (payback === undefined || payback === null)
|
|
1600
|
+
return;
|
|
1601
|
+
const amount = e['amount'];
|
|
1602
|
+
const savings = e['annual_savings'];
|
|
1603
|
+
if (!finiteNum(payback) || payback < 0) {
|
|
1604
|
+
capxIssue(issues, 'CAPX-08', `${at}.simple_payback_years`, 'CAPX-08: simple_payback_years must be a finite nonnegative number', payback);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
if (!finiteNum(amount) || !finiteNum(savings))
|
|
1608
|
+
return;
|
|
1609
|
+
// A project with no stated saving has no payback period; a number there
|
|
1610
|
+
// would be a fiction, so state nothing rather than Infinity.
|
|
1611
|
+
if (savings === 0) {
|
|
1612
|
+
capxIssue(issues, 'CAPX-08', `${at}.simple_payback_years`, 'CAPX-08: simple_payback_years cannot be stated against zero annual_savings', payback);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const expected = quantizeAtDecimals(amount / savings, PAYBACK_DP);
|
|
1616
|
+
if (quantizeAtDecimals(payback, PAYBACK_DP) !== expected) {
|
|
1617
|
+
capxIssue(issues, 'CAPX-08', `${at}.simple_payback_years`, `CAPX-08: simple_payback_years ${payback} must equal amount / annual_savings (${expected})`, payback);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
function checkRenovationDraw(parsed, issues) {
|
|
1621
|
+
const su = resolveCrossCheckSection(parsed, 'sources_uses').block;
|
|
1622
|
+
if (!su)
|
|
1623
|
+
return;
|
|
1624
|
+
const r = deepGet(su.content, 'uses.renovation');
|
|
1625
|
+
if (r === undefined || r === null)
|
|
1626
|
+
return;
|
|
1627
|
+
const at = 'uses.renovation';
|
|
1628
|
+
if (typeof r !== 'object' || Array.isArray(r)) {
|
|
1629
|
+
capxIssue(issues, 'CAPX-01', at, 'CAPX-01: renovation must be an object when stated', r);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const ren = r;
|
|
1633
|
+
for (const key of ['budget', 'contingency', 'contingency_used', 'drawn_to_date']) {
|
|
1634
|
+
const v = ren[key];
|
|
1635
|
+
if (!finiteNum(v) || v < 0) {
|
|
1636
|
+
capxIssue(issues, 'CAPX-01', `${at}.${key}`, `CAPX-01: ${key} must be a finite nonnegative amount`, v);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (!isDate(ren['as_of_date'])) {
|
|
1640
|
+
capxIssue(issues, 'CAPX-01', `${at}.as_of_date`, 'CAPX-01: as_of_date must be a real YYYY-MM-DD date', ren['as_of_date']);
|
|
1641
|
+
}
|
|
1642
|
+
// The relational rules below only compare figures CAPX-01 accepted: a bound
|
|
1643
|
+
// computed from a budget we just refused as negative is noise, not a finding.
|
|
1644
|
+
const amt = (v) => finiteNum(v) && v >= 0;
|
|
1645
|
+
const budget = ren['budget'];
|
|
1646
|
+
const contingency = ren['contingency'];
|
|
1647
|
+
const used = ren['contingency_used'];
|
|
1648
|
+
const drawn = ren['drawn_to_date'];
|
|
1649
|
+
// CAPX-02: a contingency drawn past its size is an overrun, and calling it a
|
|
1650
|
+
// contingency is what hides that.
|
|
1651
|
+
if (amt(contingency) && amt(used) && used > contingency) {
|
|
1652
|
+
capxIssue(issues, 'CAPX-02', `${at}.contingency_used`, `CAPX-02: contingency_used ${used} exceeds the contingency ${contingency} — that is an overrun, not a contingency`, used);
|
|
1653
|
+
}
|
|
1654
|
+
if (amt(drawn)) {
|
|
1655
|
+
if (amt(budget) && amt(contingency) && drawn > budget + contingency) {
|
|
1656
|
+
capxIssue(issues, 'CAPX-03', `${at}.drawn_to_date`, `CAPX-03: drawn_to_date ${drawn} exceeds budget plus contingency (${budget + contingency})`, drawn);
|
|
1657
|
+
}
|
|
1658
|
+
if (amt(used) && drawn < used) {
|
|
1659
|
+
capxIssue(issues, 'CAPX-03', `${at}.drawn_to_date`, `CAPX-03: drawn_to_date ${drawn} is below contingency_used ${used}; the contingency draw is part of the total`, drawn);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// CAPX-04: stated and verified, the RFC 0052 net_sale_proceeds posture — the
|
|
1663
|
+
// figure a lender quotes should be checkable, not recomputed by every reader.
|
|
1664
|
+
const remaining = ren['contingency_remaining'];
|
|
1665
|
+
if (remaining !== undefined && remaining !== null) {
|
|
1666
|
+
if (!finiteNum(remaining)) {
|
|
1667
|
+
capxIssue(issues, 'CAPX-04', `${at}.contingency_remaining`, 'CAPX-04: contingency_remaining must be a finite number when stated', remaining);
|
|
1668
|
+
}
|
|
1669
|
+
else if (amt(contingency) && amt(used) && !sameMoney(remaining, contingency - used)) {
|
|
1670
|
+
capxIssue(issues, 'CAPX-04', `${at}.contingency_remaining`, `CAPX-04: contingency_remaining ${remaining} must equal contingency less contingency_used (${contingency - used})`, remaining);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// CAPX-05: the legacy scalars have to agree with the typed body.
|
|
1674
|
+
for (const [legacyKey, typedKey] of [
|
|
1675
|
+
['renovation_budget', 'budget'],
|
|
1676
|
+
['renovation_contingency', 'contingency'],
|
|
1677
|
+
]) {
|
|
1678
|
+
const legacy = deepGet(su.content, `uses.${legacyKey}`);
|
|
1679
|
+
const typed = ren[typedKey];
|
|
1680
|
+
if (finiteNum(legacy) && amt(typed) && !sameMoney(legacy, typed)) {
|
|
1681
|
+
capxIssue(issues, 'CAPX-05', `uses.${legacyKey}`, `CAPX-05: uses.${legacyKey} ${legacy} disagrees with renovation.${typedKey} ${typed}`, legacy);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
const targeted = ren['expense_targeted'];
|
|
1685
|
+
if (targeted === undefined || targeted === null)
|
|
1686
|
+
return;
|
|
1687
|
+
if (!Array.isArray(targeted) || targeted.length === 0) {
|
|
1688
|
+
capxIssue(issues, 'CAPX-06', `${at}.expense_targeted`, 'CAPX-06: expense_targeted must be a nonempty array when stated');
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
const noi = resolveCrossCheckSection(parsed, 'noi_model').block;
|
|
1692
|
+
const expenses = noi ? deepGet(noi.content, 'expenses') : undefined;
|
|
1693
|
+
const expenseKeys = expenses !== null && typeof expenses === 'object' && !Array.isArray(expenses)
|
|
1694
|
+
? new Set(Object.keys(expenses))
|
|
1695
|
+
: null;
|
|
1696
|
+
for (const [i, raw] of targeted.entries()) {
|
|
1697
|
+
const p = `${at}.expense_targeted[${i}]`;
|
|
1698
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1699
|
+
capxIssue(issues, 'CAPX-06', p, 'CAPX-06: each expense_targeted entry must be an object');
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
checkExpenseTargeted(raw, expenseKeys, issues, p);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
880
1705
|
// ─── §4.23 Mixed-use components (RFC 0019) ───────────────────────────────────
|
|
881
1706
|
// The eight income classes admissible as a mixed-use component. `land` is
|
|
882
1707
|
// excluded (its NOI model nets negative) and `mixed_use` cannot nest in itself.
|
|
@@ -1466,6 +2291,54 @@ function sumsToOne(a, b) {
|
|
|
1466
2291
|
a >= 0 && a <= 1 && b >= 0 && b <= 1 &&
|
|
1467
2292
|
Math.abs(a + b - 1) < RATIO_QUANTUM;
|
|
1468
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
|
+
}
|
|
1469
2342
|
function checkWaterfallContent(content, variant, parsed, issues) {
|
|
1470
2343
|
const section = 'distribution_waterfall';
|
|
1471
2344
|
const label = variant === 'default' ? '' : ` (variant "${variant}")`;
|
|
@@ -1593,6 +2466,8 @@ function checkWaterfallContent(content, variant, parsed, issues) {
|
|
|
1593
2466
|
wf01('tiers', 'the ladder must end in at least one split tier');
|
|
1594
2467
|
}
|
|
1595
2468
|
}
|
|
2469
|
+
// WF-10 .. WF-15: the RFC 0059 clawback provision.
|
|
2470
|
+
checkWaterfallClawback(content, rows, section, label, issues);
|
|
1596
2471
|
// WF-02 / WF-03: the cash reference.
|
|
1597
2472
|
const ref = content['cash_flow_ref'];
|
|
1598
2473
|
const refVariant = ref && typeof ref['variant'] === 'string' ? ref['variant'] : null;
|
|
@@ -1624,6 +2499,19 @@ function checkWaterfallContent(content, variant, parsed, issues) {
|
|
|
1624
2499
|
message: `WF-03: the referenced series${label} has no contribution (no negative amount); a waterfall over pure inflows has no capital to return`,
|
|
1625
2500
|
});
|
|
1626
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
|
+
}
|
|
1627
2515
|
}
|
|
1628
2516
|
function checkWaterfall(parsed, issues) {
|
|
1629
2517
|
const entry = parsed.sections['distribution_waterfall'];
|