@uwmd/core 2.10.0 → 2.11.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 +3 -0
- 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 +3 -0
- 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 +21 -0
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +600 -0
- package/dist/validator.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +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,603 @@ 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
|
+
function checkLeaseClauses(parsed, issues) {
|
|
1097
|
+
const block = resolveCrossCheckSection(parsed, 'rent_roll').block;
|
|
1098
|
+
if (!block)
|
|
1099
|
+
return;
|
|
1100
|
+
const tenants = deepGet(block.content, 'tenants');
|
|
1101
|
+
if (!Array.isArray(tenants))
|
|
1102
|
+
return;
|
|
1103
|
+
for (const [i, raw] of tenants.entries()) {
|
|
1104
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1105
|
+
continue;
|
|
1106
|
+
const t = raw;
|
|
1107
|
+
const at = `tenants[${i}]`;
|
|
1108
|
+
checkEscalationSchedule(t, issues, at);
|
|
1109
|
+
checkTerminationOption(t, issues, at);
|
|
1110
|
+
checkCoTenancy(t, issues, at);
|
|
1111
|
+
checkLeasingCapital(t, issues, at);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
// ─── §4.7 / §4.8 Rate hedges and escrows (RFC 0056) ──────────────────────────
|
|
1115
|
+
//
|
|
1116
|
+
// A cap's strike, notional and term are stated, never priced. Nothing values
|
|
1117
|
+
// the instrument, projects a strike crossing or rolls an escrow balance
|
|
1118
|
+
// forward — that needs a forward curve, which this contract does not fetch.
|
|
1119
|
+
// The one thing these rules insist on is that the author answer what happens
|
|
1120
|
+
// when the cap expires, and that a stated replacement is a funded line.
|
|
1121
|
+
/** The only instrument this contract types. Closed. */
|
|
1122
|
+
export const HEDGE_INSTRUMENTS = Object.freeze(['rate_cap']);
|
|
1123
|
+
/**
|
|
1124
|
+
* Named, refused, and left for a mark-to-market contract. Both can be worth
|
|
1125
|
+
* less than zero; a cap cannot. Typing them as caps would make the capital
|
|
1126
|
+
* stack wrong in the one case that matters.
|
|
1127
|
+
*/
|
|
1128
|
+
export const RESERVED_HEDGE_INSTRUMENTS = Object.freeze([
|
|
1129
|
+
'rate_swap', 'rate_collar',
|
|
1130
|
+
]);
|
|
1131
|
+
/** The `rate_index` vocabulary minus `fixed`, which a cap is not struck against. */
|
|
1132
|
+
export const HEDGE_INDEXES = Object.freeze([
|
|
1133
|
+
'sofr', 'prime', 'treasury_5yr', 'treasury_10yr',
|
|
1134
|
+
]);
|
|
1135
|
+
/** What the author says happens in the month after the cap expires. No default. */
|
|
1136
|
+
export const HEDGE_EXPIRY_ASSUMPTIONS = Object.freeze([
|
|
1137
|
+
'replace', 'unhedged', 'loan_matures_first',
|
|
1138
|
+
]);
|
|
1139
|
+
/** Closed, with a label-bearing `other` — the RFC 0052 shape. */
|
|
1140
|
+
export const ESCROW_NAMES = Object.freeze([
|
|
1141
|
+
'tax', 'insurance', 'replacement_reserve', 'ti_lc',
|
|
1142
|
+
'interest', 'operating', 'rate_cap_replacement', 'other',
|
|
1143
|
+
]);
|
|
1144
|
+
function hedgeIssue(issues, code, section, field, message, value) {
|
|
1145
|
+
issues.push({ code, severity: 'error', section, field, message, ...(value !== undefined ? { value } : {}) });
|
|
1146
|
+
}
|
|
1147
|
+
/** The §4.7 hedge object: shape, the rate_type gate, and the legacy agreement. */
|
|
1148
|
+
function checkRateHedge(h, rateType, legacyCapPct, issues) {
|
|
1149
|
+
const at = 'rate_hedge';
|
|
1150
|
+
const inst = h['instrument'];
|
|
1151
|
+
// HDG-02 before HDG-01: a reserved name earns its own message, not "unknown".
|
|
1152
|
+
if (typeof inst === 'string' && RESERVED_HEDGE_INSTRUMENTS.includes(inst)) {
|
|
1153
|
+
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);
|
|
1154
|
+
}
|
|
1155
|
+
else if (typeof inst !== 'string' || !HEDGE_INSTRUMENTS.includes(inst)) {
|
|
1156
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.instrument`, `HDG-01: instrument must be one of ${HEDGE_INSTRUMENTS.join(', ')}`, inst);
|
|
1157
|
+
}
|
|
1158
|
+
const notional = h['notional'];
|
|
1159
|
+
if (!finiteNum(notional) || notional < 0) {
|
|
1160
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.notional`, 'HDG-01: notional must be a finite nonnegative amount', notional);
|
|
1161
|
+
}
|
|
1162
|
+
// A fraction, not a percent — a strike of 3.5 is 350%, which is the mistake
|
|
1163
|
+
// this bound exists to catch.
|
|
1164
|
+
const strike = h['strike_rate'];
|
|
1165
|
+
if (!finiteNum(strike) || strike <= 0 || strike >= 1) {
|
|
1166
|
+
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);
|
|
1167
|
+
}
|
|
1168
|
+
const index = h['index'];
|
|
1169
|
+
if (typeof index !== 'string' || !HEDGE_INDEXES.includes(index)) {
|
|
1170
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.index`, `HDG-01: index must be one of ${HEDGE_INDEXES.join(', ')}`, index);
|
|
1171
|
+
}
|
|
1172
|
+
const eff = h['effective_date'];
|
|
1173
|
+
const exp = h['expiration_date'];
|
|
1174
|
+
if (!isDate(eff)) {
|
|
1175
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.effective_date`, 'HDG-01: effective_date must be a real YYYY-MM-DD date', eff);
|
|
1176
|
+
}
|
|
1177
|
+
if (!isDate(exp)) {
|
|
1178
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.expiration_date`, 'HDG-01: expiration_date must be a real YYYY-MM-DD date', exp);
|
|
1179
|
+
}
|
|
1180
|
+
else if (isDate(eff) && exp <= eff) {
|
|
1181
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.expiration_date`, `HDG-01: expiration_date ${exp} must be strictly after effective_date ${eff}`, exp);
|
|
1182
|
+
}
|
|
1183
|
+
const premium = h['premium'];
|
|
1184
|
+
if (premium !== undefined && premium !== null && (!finiteNum(premium) || premium < 0)) {
|
|
1185
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', `${at}.premium`, 'HDG-01: premium must be a finite nonnegative amount, or null for genuinely none', premium);
|
|
1186
|
+
}
|
|
1187
|
+
// HDG-03: a fixed-rate loan does not carry a rate cap.
|
|
1188
|
+
if (rateType !== 'floating' && rateType !== 'hybrid') {
|
|
1189
|
+
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);
|
|
1190
|
+
}
|
|
1191
|
+
// HDG-04: the legacy scalar has to agree with the typed body.
|
|
1192
|
+
if (finiteNum(legacyCapPct) && finiteNum(strike) && legacyCapPct !== strike) {
|
|
1193
|
+
hedgeIssue(issues, 'HDG-04', 'debt_structure', 'rate_cap_pct', `HDG-04: rate_cap_pct ${legacyCapPct} disagrees with rate_hedge.strike_rate ${strike}`, legacyCapPct);
|
|
1194
|
+
}
|
|
1195
|
+
// HDG-06: no default. A cap's expiry is the fact the reader came for, and
|
|
1196
|
+
// "unstated" is the answer that hides the cliff.
|
|
1197
|
+
const after = h['post_expiration_assumption'];
|
|
1198
|
+
if (typeof after !== 'string' || !HEDGE_EXPIRY_ASSUMPTIONS.includes(after)) {
|
|
1199
|
+
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);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/** The §4.8 escrow array. Returns the set of names it managed to read. */
|
|
1203
|
+
function checkEscrows(escrows, uses, issues) {
|
|
1204
|
+
const seen = new Set();
|
|
1205
|
+
const at = 'uses.escrows';
|
|
1206
|
+
if (!Array.isArray(escrows) || escrows.length === 0) {
|
|
1207
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', at, 'ESC-01: escrows must be a nonempty array when stated');
|
|
1208
|
+
return seen;
|
|
1209
|
+
}
|
|
1210
|
+
const labels = new Set();
|
|
1211
|
+
const upfrontByName = new Map();
|
|
1212
|
+
for (const [i, raw] of escrows.entries()) {
|
|
1213
|
+
const p = `${at}[${i}]`;
|
|
1214
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1215
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', p, 'ESC-01: each escrow must be an object');
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
const e = raw;
|
|
1219
|
+
const name = e['name'];
|
|
1220
|
+
if (typeof name !== 'string' || !ESCROW_NAMES.includes(name)) {
|
|
1221
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', `${p}.name`, `ESC-01: escrow name must be one of ${ESCROW_NAMES.join(', ')}`, name);
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
const label = e['label'];
|
|
1225
|
+
if (name === 'other') {
|
|
1226
|
+
// ESC-02: `other` says nothing until the label says what it is.
|
|
1227
|
+
if (typeof label !== 'string' || label.trim().length === 0) {
|
|
1228
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.label`, 'ESC-02: an "other" escrow requires a nonempty label', label);
|
|
1229
|
+
}
|
|
1230
|
+
else if (labels.has(label.trim())) {
|
|
1231
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.label`, `ESC-02: duplicate "other" escrow label ${JSON.stringify(label.trim())}`, label);
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
labels.add(label.trim());
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
if (label !== undefined && label !== null) {
|
|
1239
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.label`, `ESC-02: only an "other" escrow carries a label; ${name} names itself`, label);
|
|
1240
|
+
}
|
|
1241
|
+
if (seen.has(name)) {
|
|
1242
|
+
hedgeIssue(issues, 'ESC-02', 'sources_uses', `${p}.name`, `ESC-02: duplicate escrow name ${JSON.stringify(name)}`, name);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
seen.add(name);
|
|
1246
|
+
// ESC-01: an escrow that funds neither at close nor monthly is not one.
|
|
1247
|
+
let stated = 0;
|
|
1248
|
+
for (const key of ['upfront', 'monthly']) {
|
|
1249
|
+
const v = e[key];
|
|
1250
|
+
if (v === undefined || v === null)
|
|
1251
|
+
continue;
|
|
1252
|
+
if (!finiteNum(v) || v < 0) {
|
|
1253
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', `${p}.${key}`, `ESC-01: ${key} must be a finite nonnegative amount`, v);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
stated++;
|
|
1257
|
+
if (key === 'upfront' && !upfrontByName.has(name))
|
|
1258
|
+
upfrontByName.set(name, v);
|
|
1259
|
+
}
|
|
1260
|
+
if (stated === 0) {
|
|
1261
|
+
hedgeIssue(issues, 'ESC-01', 'sources_uses', p, 'ESC-01: an escrow must state at least one of upfront or monthly');
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
// ESC-03: the legacy scalars have to agree with the typed lines.
|
|
1265
|
+
for (const [legacyKey, name] of [
|
|
1266
|
+
['interest_reserve', 'interest'],
|
|
1267
|
+
['operating_reserves', 'operating'],
|
|
1268
|
+
]) {
|
|
1269
|
+
const legacy = uses[legacyKey];
|
|
1270
|
+
const typed = upfrontByName.get(name);
|
|
1271
|
+
if (finiteNum(legacy) && typed !== undefined && !sameMoney(legacy, typed)) {
|
|
1272
|
+
hedgeIssue(issues, 'ESC-03', 'sources_uses', `uses.${legacyKey}`, `ESC-03: uses.${legacyKey} ${legacy} disagrees with the ${name} escrow's upfront ${typed}`, legacy);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return seen;
|
|
1276
|
+
}
|
|
1277
|
+
function checkHedgesAndEscrows(parsed, issues) {
|
|
1278
|
+
const debt = resolveCrossCheckSection(parsed, 'debt_structure').block;
|
|
1279
|
+
const su = resolveCrossCheckSection(parsed, 'sources_uses').block;
|
|
1280
|
+
let assumption;
|
|
1281
|
+
let hedgeStated = false;
|
|
1282
|
+
if (debt) {
|
|
1283
|
+
const h = deepGet(debt.content, 'rate_hedge');
|
|
1284
|
+
if (h !== undefined && h !== null) {
|
|
1285
|
+
if (typeof h !== 'object' || Array.isArray(h)) {
|
|
1286
|
+
hedgeIssue(issues, 'HDG-01', 'debt_structure', 'rate_hedge', 'HDG-01: rate_hedge must be an object when stated', h);
|
|
1287
|
+
}
|
|
1288
|
+
else {
|
|
1289
|
+
hedgeStated = true;
|
|
1290
|
+
const rec = h;
|
|
1291
|
+
assumption = rec['post_expiration_assumption'];
|
|
1292
|
+
checkRateHedge(rec, deepGet(debt.content, 'rate_type'), deepGet(debt.content, 'rate_cap_pct'), issues);
|
|
1293
|
+
// HDG-05: the premium is the same cash as the use that funds it.
|
|
1294
|
+
const premium = rec['premium'];
|
|
1295
|
+
const cost = su ? deepGet(su.content, 'uses.rate_cap_cost') : undefined;
|
|
1296
|
+
if (finiteNum(premium) && finiteNum(cost) && !sameMoney(premium, cost)) {
|
|
1297
|
+
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);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (!su)
|
|
1303
|
+
return;
|
|
1304
|
+
const uses = deepGet(su.content, 'uses');
|
|
1305
|
+
if (uses === null || typeof uses !== 'object' || Array.isArray(uses))
|
|
1306
|
+
return;
|
|
1307
|
+
const u = uses;
|
|
1308
|
+
const escrows = u['escrows'];
|
|
1309
|
+
const names = escrows === undefined || escrows === null
|
|
1310
|
+
? new Set()
|
|
1311
|
+
: checkEscrows(escrows, u, issues);
|
|
1312
|
+
// ESC-04: the rule that turns "this cap expires in year three" from a note
|
|
1313
|
+
// into a funded line. Both directions, so neither side can drift alone.
|
|
1314
|
+
const wantsReplacement = hedgeStated && assumption === 'replace';
|
|
1315
|
+
const hasReplacement = names.has('rate_cap_replacement');
|
|
1316
|
+
if (wantsReplacement && !hasReplacement) {
|
|
1317
|
+
hedgeIssue(issues, 'ESC-04', 'sources_uses', 'uses.escrows', 'ESC-04: rate_hedge.post_expiration_assumption "replace" requires a rate_cap_replacement escrow');
|
|
1318
|
+
}
|
|
1319
|
+
else if (hasReplacement && !wantsReplacement) {
|
|
1320
|
+
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);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
// ─── §4.8 Renovation draw and expense-targeted capex (RFC 0057) ──────────────
|
|
1324
|
+
//
|
|
1325
|
+
// A contingency is the number a construction lender watches, and two deals
|
|
1326
|
+
// stating the same one are indistinguishable when one has drawn none of it and
|
|
1327
|
+
// the other has drawn all of it. These rules read the draw the author stated.
|
|
1328
|
+
//
|
|
1329
|
+
// Nothing here applies a saving. `annual_savings` is never subtracted from an
|
|
1330
|
+
// expense line, from EGI or from NOI — `in_noi_model` records whether the author
|
|
1331
|
+
// already did, and CAPX-07 makes them say so, because a stated saving with no
|
|
1332
|
+
// such flag is how a document gets double-counted.
|
|
1333
|
+
/** Where the payback arithmetic is compared. Years, not money. */
|
|
1334
|
+
const PAYBACK_DP = 4;
|
|
1335
|
+
function capxIssue(issues, code, field, message, value) {
|
|
1336
|
+
issues.push({
|
|
1337
|
+
code, severity: 'error', section: 'sources_uses', field, message,
|
|
1338
|
+
...(value !== undefined ? { value } : {}),
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* One `expense_targeted` entry. `expenseKeys` is null when `noi_model` is absent
|
|
1343
|
+
* or unreadable, in which case CAPX-06 checks the shape but not the target.
|
|
1344
|
+
*/
|
|
1345
|
+
function checkExpenseTargeted(e, expenseKeys, issues, at) {
|
|
1346
|
+
const label = e['label'];
|
|
1347
|
+
if (typeof label !== 'string' || label.trim().length === 0) {
|
|
1348
|
+
capxIssue(issues, 'CAPX-06', `${at}.label`, 'CAPX-06: an expense-targeted capex entry requires a nonempty label', label);
|
|
1349
|
+
}
|
|
1350
|
+
for (const key of ['amount', 'annual_savings']) {
|
|
1351
|
+
const v = e[key];
|
|
1352
|
+
if (!finiteNum(v) || v < 0) {
|
|
1353
|
+
capxIssue(issues, 'CAPX-06', `${at}.${key}`, `CAPX-06: ${key} must be a finite nonnegative amount`, v);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
const begin = e['savings_begin'];
|
|
1357
|
+
if (typeof begin !== 'string' || taxPeriodKind(begin) === null) {
|
|
1358
|
+
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);
|
|
1359
|
+
}
|
|
1360
|
+
// The target is checked against the keys actually present, not a hardcoded
|
|
1361
|
+
// list, so a module adding a class-specific expense line keeps working.
|
|
1362
|
+
const targets = e['targets'];
|
|
1363
|
+
if (typeof targets !== 'string' || targets.trim().length === 0) {
|
|
1364
|
+
capxIssue(issues, 'CAPX-06', `${at}.targets`, 'CAPX-06: targets must name the noi_model.expenses key this project reduces', targets);
|
|
1365
|
+
}
|
|
1366
|
+
else if (expenseKeys !== null && !expenseKeys.has(targets)) {
|
|
1367
|
+
capxIssue(issues, 'CAPX-06', `${at}.targets`, `CAPX-06: targets ${JSON.stringify(targets)} names no key under noi_model.expenses`, targets);
|
|
1368
|
+
}
|
|
1369
|
+
// CAPX-07: the disclosure that keeps a reader from applying the saving twice.
|
|
1370
|
+
if (typeof e['in_noi_model'] !== 'boolean') {
|
|
1371
|
+
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']);
|
|
1372
|
+
}
|
|
1373
|
+
const payback = e['simple_payback_years'];
|
|
1374
|
+
if (payback === undefined || payback === null)
|
|
1375
|
+
return;
|
|
1376
|
+
const amount = e['amount'];
|
|
1377
|
+
const savings = e['annual_savings'];
|
|
1378
|
+
if (!finiteNum(payback) || payback < 0) {
|
|
1379
|
+
capxIssue(issues, 'CAPX-08', `${at}.simple_payback_years`, 'CAPX-08: simple_payback_years must be a finite nonnegative number', payback);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (!finiteNum(amount) || !finiteNum(savings))
|
|
1383
|
+
return;
|
|
1384
|
+
// A project with no stated saving has no payback period; a number there
|
|
1385
|
+
// would be a fiction, so state nothing rather than Infinity.
|
|
1386
|
+
if (savings === 0) {
|
|
1387
|
+
capxIssue(issues, 'CAPX-08', `${at}.simple_payback_years`, 'CAPX-08: simple_payback_years cannot be stated against zero annual_savings', payback);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const expected = quantizeAtDecimals(amount / savings, PAYBACK_DP);
|
|
1391
|
+
if (quantizeAtDecimals(payback, PAYBACK_DP) !== expected) {
|
|
1392
|
+
capxIssue(issues, 'CAPX-08', `${at}.simple_payback_years`, `CAPX-08: simple_payback_years ${payback} must equal amount / annual_savings (${expected})`, payback);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
function checkRenovationDraw(parsed, issues) {
|
|
1396
|
+
const su = resolveCrossCheckSection(parsed, 'sources_uses').block;
|
|
1397
|
+
if (!su)
|
|
1398
|
+
return;
|
|
1399
|
+
const r = deepGet(su.content, 'uses.renovation');
|
|
1400
|
+
if (r === undefined || r === null)
|
|
1401
|
+
return;
|
|
1402
|
+
const at = 'uses.renovation';
|
|
1403
|
+
if (typeof r !== 'object' || Array.isArray(r)) {
|
|
1404
|
+
capxIssue(issues, 'CAPX-01', at, 'CAPX-01: renovation must be an object when stated', r);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
const ren = r;
|
|
1408
|
+
for (const key of ['budget', 'contingency', 'contingency_used', 'drawn_to_date']) {
|
|
1409
|
+
const v = ren[key];
|
|
1410
|
+
if (!finiteNum(v) || v < 0) {
|
|
1411
|
+
capxIssue(issues, 'CAPX-01', `${at}.${key}`, `CAPX-01: ${key} must be a finite nonnegative amount`, v);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (!isDate(ren['as_of_date'])) {
|
|
1415
|
+
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']);
|
|
1416
|
+
}
|
|
1417
|
+
// The relational rules below only compare figures CAPX-01 accepted: a bound
|
|
1418
|
+
// computed from a budget we just refused as negative is noise, not a finding.
|
|
1419
|
+
const amt = (v) => finiteNum(v) && v >= 0;
|
|
1420
|
+
const budget = ren['budget'];
|
|
1421
|
+
const contingency = ren['contingency'];
|
|
1422
|
+
const used = ren['contingency_used'];
|
|
1423
|
+
const drawn = ren['drawn_to_date'];
|
|
1424
|
+
// CAPX-02: a contingency drawn past its size is an overrun, and calling it a
|
|
1425
|
+
// contingency is what hides that.
|
|
1426
|
+
if (amt(contingency) && amt(used) && used > contingency) {
|
|
1427
|
+
capxIssue(issues, 'CAPX-02', `${at}.contingency_used`, `CAPX-02: contingency_used ${used} exceeds the contingency ${contingency} — that is an overrun, not a contingency`, used);
|
|
1428
|
+
}
|
|
1429
|
+
if (amt(drawn)) {
|
|
1430
|
+
if (amt(budget) && amt(contingency) && drawn > budget + contingency) {
|
|
1431
|
+
capxIssue(issues, 'CAPX-03', `${at}.drawn_to_date`, `CAPX-03: drawn_to_date ${drawn} exceeds budget plus contingency (${budget + contingency})`, drawn);
|
|
1432
|
+
}
|
|
1433
|
+
if (amt(used) && drawn < used) {
|
|
1434
|
+
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);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
// CAPX-04: stated and verified, the RFC 0052 net_sale_proceeds posture — the
|
|
1438
|
+
// figure a lender quotes should be checkable, not recomputed by every reader.
|
|
1439
|
+
const remaining = ren['contingency_remaining'];
|
|
1440
|
+
if (remaining !== undefined && remaining !== null) {
|
|
1441
|
+
if (!finiteNum(remaining)) {
|
|
1442
|
+
capxIssue(issues, 'CAPX-04', `${at}.contingency_remaining`, 'CAPX-04: contingency_remaining must be a finite number when stated', remaining);
|
|
1443
|
+
}
|
|
1444
|
+
else if (amt(contingency) && amt(used) && !sameMoney(remaining, contingency - used)) {
|
|
1445
|
+
capxIssue(issues, 'CAPX-04', `${at}.contingency_remaining`, `CAPX-04: contingency_remaining ${remaining} must equal contingency less contingency_used (${contingency - used})`, remaining);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
// CAPX-05: the legacy scalars have to agree with the typed body.
|
|
1449
|
+
for (const [legacyKey, typedKey] of [
|
|
1450
|
+
['renovation_budget', 'budget'],
|
|
1451
|
+
['renovation_contingency', 'contingency'],
|
|
1452
|
+
]) {
|
|
1453
|
+
const legacy = deepGet(su.content, `uses.${legacyKey}`);
|
|
1454
|
+
const typed = ren[typedKey];
|
|
1455
|
+
if (finiteNum(legacy) && amt(typed) && !sameMoney(legacy, typed)) {
|
|
1456
|
+
capxIssue(issues, 'CAPX-05', `uses.${legacyKey}`, `CAPX-05: uses.${legacyKey} ${legacy} disagrees with renovation.${typedKey} ${typed}`, legacy);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
const targeted = ren['expense_targeted'];
|
|
1460
|
+
if (targeted === undefined || targeted === null)
|
|
1461
|
+
return;
|
|
1462
|
+
if (!Array.isArray(targeted) || targeted.length === 0) {
|
|
1463
|
+
capxIssue(issues, 'CAPX-06', `${at}.expense_targeted`, 'CAPX-06: expense_targeted must be a nonempty array when stated');
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const noi = resolveCrossCheckSection(parsed, 'noi_model').block;
|
|
1467
|
+
const expenses = noi ? deepGet(noi.content, 'expenses') : undefined;
|
|
1468
|
+
const expenseKeys = expenses !== null && typeof expenses === 'object' && !Array.isArray(expenses)
|
|
1469
|
+
? new Set(Object.keys(expenses))
|
|
1470
|
+
: null;
|
|
1471
|
+
for (const [i, raw] of targeted.entries()) {
|
|
1472
|
+
const p = `${at}.expense_targeted[${i}]`;
|
|
1473
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1474
|
+
capxIssue(issues, 'CAPX-06', p, 'CAPX-06: each expense_targeted entry must be an object');
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
checkExpenseTargeted(raw, expenseKeys, issues, p);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
880
1480
|
// ─── §4.23 Mixed-use components (RFC 0019) ───────────────────────────────────
|
|
881
1481
|
// The eight income classes admissible as a mixed-use component. `land` is
|
|
882
1482
|
// excluded (its NOI model nets negative) and `mixed_use` cannot nest in itself.
|