@uwmd/core 2.9.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 +10 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +11 -2
- package/dist/browser.js.map +1 -1
- package/dist/calc/index.js +3 -3
- package/dist/calc/index.js.map +1 -1
- package/dist/capital-stack.d.ts +6 -2
- package/dist/capital-stack.d.ts.map +1 -1
- package/dist/capital-stack.js +5 -0
- package/dist/capital-stack.js.map +1 -1
- package/dist/cash-flow-series.d.ts +7 -0
- package/dist/cash-flow-series.d.ts.map +1 -1
- package/dist/cash-flow-series.js +10 -3
- package/dist/cash-flow-series.js.map +1 -1
- package/dist/cli-cash-flows.d.ts +16 -0
- package/dist/cli-cash-flows.d.ts.map +1 -0
- package/dist/cli-cash-flows.js +96 -0
- package/dist/cli-cash-flows.js.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -1
- package/dist/format.d.ts +1 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +9 -0
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/property-cash-flow-inputs.d.ts +34 -0
- package/dist/property-cash-flow-inputs.d.ts.map +1 -0
- package/dist/property-cash-flow-inputs.js +95 -0
- package/dist/property-cash-flow-inputs.js.map +1 -0
- package/dist/property-cash-flows.d.ts.map +1 -1
- package/dist/property-cash-flows.js +86 -5
- package/dist/property-cash-flows.js.map +1 -1
- package/dist/protocol.d.ts +34 -3
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +32 -2
- package/dist/protocol.js.map +1 -1
- package/dist/renderer.d.ts +5 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +22 -3
- package/dist/renderer.js.map +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +19 -3
- package/dist/report.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validator.d.ts +24 -0
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +888 -2
- package/dist/validator.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/waterfall.d.ts +6 -0
- package/dist/waterfall.d.ts.map +1 -1
- package/dist/waterfall.js +17 -7
- package/dist/waterfall.js.map +1 -1
- package/package.json +2 -2
package/dist/validator.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// Spec: UW_FORMAT_SPEC_v1.md Part V
|
|
3
3
|
import { DEFAULT_THRESHOLDS, SOURCE_TAGS } from './types.js';
|
|
4
4
|
import { getSection, getSectionVariant, deepGet } from './parser.js';
|
|
5
|
-
import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, STAGE_REQUIREMENTS, requiredSectionsFor, CROSS_CHECK_RULE_IDS, RETURN_TAX_BASES, DEFAULT_RETURN_TAX_BASIS, } from './protocol.js';
|
|
5
|
+
import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, isCurrencyCode, STAGE_REQUIREMENTS, requiredSectionsFor, CROSS_CHECK_RULE_IDS, RETURN_TAX_BASES, DEFAULT_RETURN_TAX_BASIS, } from './protocol.js';
|
|
6
6
|
import { EXTERNAL_ANNOTATION_KEY } from './composition.js';
|
|
7
7
|
import { UW_LITE_SOURCE_EXTENSION } from './lite-bridge.js';
|
|
8
8
|
import { readGapsContent } from './gaps.js';
|
|
9
9
|
import { LEASE_UP_STABILIZED_TOLERANCE } from './lease-up.js';
|
|
10
10
|
import { checkLeaseUpContent } from './lease-up-structure.js';
|
|
11
|
-
import { CASH_FLOW_KINDS } from './cash-flow-series.js';
|
|
11
|
+
import { CASH_FLOW_KINDS, CASH_FLOW_VERIFY_DECIMALS, quantizeAtDecimals } from './cash-flow-series.js';
|
|
12
12
|
import { isDayCountConvention, parseISODate } from './calc/day-count.js';
|
|
13
13
|
import { parseAssetClass, declaredModuleDependencies } from './asset-class.js';
|
|
14
14
|
import { isV2File } from './meta-shape.js';
|
|
@@ -105,7 +105,12 @@ export function validateUWFile(parsed, thresholdOverrides) {
|
|
|
105
105
|
checkSizeIntensive(parsed, issues, ledger);
|
|
106
106
|
checkSectionReadiness(parsed, issues, ledger);
|
|
107
107
|
checkReturnsTaxBasis(parsed, issues);
|
|
108
|
+
checkTaxBasis(parsed, issues);
|
|
109
|
+
checkLeaseClauses(parsed, issues);
|
|
110
|
+
checkHedgesAndEscrows(parsed, issues);
|
|
111
|
+
checkRenovationDraw(parsed, issues);
|
|
108
112
|
checkLocale(parsed, issues);
|
|
113
|
+
checkCurrencyIdentity(parsed, issues);
|
|
109
114
|
checkAssetClassIdentifier(parsed, issues);
|
|
110
115
|
checkMetaIntegrity(parsed, issues);
|
|
111
116
|
checkMetaShape(parsed, issues);
|
|
@@ -636,6 +641,842 @@ function checkReturnsTaxBasis(parsed, issues) {
|
|
|
636
641
|
value: v,
|
|
637
642
|
});
|
|
638
643
|
}
|
|
644
|
+
// ─── §4.5 / §4.9 Tax abatements and reassessment basis (RFC 0053) ────────────
|
|
645
|
+
//
|
|
646
|
+
// Every rule here checks a figure the author stated. Nothing is derived, no
|
|
647
|
+
// jurisdiction rules are inferred, and the exit-value/terminal-tax circularity
|
|
648
|
+
// is left to the author — the calc engine has no iteration and this adds none.
|
|
649
|
+
// Quantize with the §VIII.5 helper the cash-flow verifier exports rather than
|
|
650
|
+
// keeping a second copy of the rounding rule.
|
|
651
|
+
/** What caused the reassessment; `none` means none applies. */
|
|
652
|
+
export const REASSESSMENT_TRIGGERS = Object.freeze([
|
|
653
|
+
'sale', 'construction_completion', 'statutory_cycle', 'none',
|
|
654
|
+
]);
|
|
655
|
+
export const TAX_ABATEMENT_KINDS = Object.freeze([
|
|
656
|
+
'exemption', 'freeze', 'pilot', 'phase_in', 'credit',
|
|
657
|
+
]);
|
|
658
|
+
const TAX_PATH = 'expenses.real_estate_taxes';
|
|
659
|
+
const CURRENCY_DP = CASH_FLOW_VERIFY_DECIMALS.currency;
|
|
660
|
+
function taxIssue(issues, code, section, field, message, value) {
|
|
661
|
+
issues.push({ code, severity: 'error', section, field, message, ...(value !== undefined ? { value } : {}) });
|
|
662
|
+
}
|
|
663
|
+
const finiteNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
664
|
+
const sameMoney = (a, b) => quantizeAtDecimals(a, CURRENCY_DP) === quantizeAtDecimals(b, CURRENCY_DP);
|
|
665
|
+
/**
|
|
666
|
+
* The shape shared by the `noi_model` reassessment and the `dcf` terminal tax.
|
|
667
|
+
* Returns the quantum the stated expense must be rounded at, or null when the
|
|
668
|
+
* object is too malformed to check any further.
|
|
669
|
+
*/
|
|
670
|
+
function checkReassessment(r, issues, section, base) {
|
|
671
|
+
const trigger = r['trigger'];
|
|
672
|
+
if (typeof trigger !== 'string' || !REASSESSMENT_TRIGGERS.includes(trigger)) {
|
|
673
|
+
taxIssue(issues, 'TAX-01', section, `${base}.trigger`, `TAX-01: reassessment.trigger must be one of ${REASSESSMENT_TRIGGERS.join(', ')}`, trigger);
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
const assessed = r['assessed_value'];
|
|
677
|
+
const millage = r['millage_rate'];
|
|
678
|
+
const indicated = r['indicated_tax'];
|
|
679
|
+
let ok = true;
|
|
680
|
+
for (const [key, v] of [['assessed_value', assessed], ['millage_rate', millage], ['indicated_tax', indicated]]) {
|
|
681
|
+
if (!finiteNum(v)) {
|
|
682
|
+
taxIssue(issues, 'TAX-01', section, `${base}.${key}`, `TAX-01: reassessment.${key} must be a finite number`, v);
|
|
683
|
+
ok = false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (!ok)
|
|
687
|
+
return null;
|
|
688
|
+
// TAX-01: the assessed value follows from the basis and ratio when both are stated.
|
|
689
|
+
const basis = r['value_basis'];
|
|
690
|
+
const ratio = r['assessment_ratio'];
|
|
691
|
+
if (basis !== undefined || ratio !== undefined) {
|
|
692
|
+
if (!finiteNum(basis) || !finiteNum(ratio)) {
|
|
693
|
+
taxIssue(issues, 'TAX-01', section, `${base}.assessment_ratio`, 'TAX-01: value_basis and assessment_ratio are stated together or not at all');
|
|
694
|
+
}
|
|
695
|
+
else if (!sameMoney(basis * ratio, assessed)) {
|
|
696
|
+
taxIssue(issues, 'TAX-01', section, `${base}.assessed_value`, `TAX-01: assessed_value ${assessed} must equal value_basis x assessment_ratio (${quantizeAtDecimals(basis * ratio, CURRENCY_DP)})`, assessed);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
// TAX-02: the indicated tax follows from the assessed value and the millage.
|
|
700
|
+
if (!sameMoney(indicated, assessed * millage)) {
|
|
701
|
+
taxIssue(issues, 'TAX-02', section, `${base}.indicated_tax`, `TAX-02: indicated_tax ${indicated} must equal assessed_value x millage_rate (${quantizeAtDecimals(assessed * millage, CURRENCY_DP)})`, indicated);
|
|
702
|
+
}
|
|
703
|
+
const round = r['round_to_decimals'];
|
|
704
|
+
if (round !== undefined && !Number.isSafeInteger(round)) {
|
|
705
|
+
taxIssue(issues, 'TAX-03', section, `${base}.round_to_decimals`, 'TAX-03: round_to_decimals must be an integer; it may be negative to round to a magnitude', round);
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
return round === undefined ? CURRENCY_DP : round;
|
|
709
|
+
}
|
|
710
|
+
function checkAbatement(a, issues, section, base, stated) {
|
|
711
|
+
const kind = a['kind'];
|
|
712
|
+
if (typeof kind !== 'string' || !TAX_ABATEMENT_KINDS.includes(kind)) {
|
|
713
|
+
taxIssue(issues, 'TAX-06', section, `${base}.kind`, `TAX-06: abatement.kind must be one of ${TAX_ABATEMENT_KINDS.join(', ')}`, kind);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const schedule = a['schedule'];
|
|
717
|
+
if (!Array.isArray(schedule) || schedule.length === 0) {
|
|
718
|
+
taxIssue(issues, 'TAX-05', section, `${base}.schedule`, 'TAX-05: abatement.schedule must be a nonempty array');
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const periods = [];
|
|
722
|
+
let shapeOk = true;
|
|
723
|
+
for (const [i, row] of schedule.entries()) {
|
|
724
|
+
const at = `${base}.schedule[${i}]`;
|
|
725
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
|
|
726
|
+
taxIssue(issues, 'TAX-05', section, at, 'TAX-05: each schedule entry must be an object');
|
|
727
|
+
shapeOk = false;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const e = row;
|
|
731
|
+
const period = e['period'];
|
|
732
|
+
if (typeof period !== 'string' || taxPeriodKind(period) === null) {
|
|
733
|
+
taxIssue(issues, 'TAX-05', section, `${at}.period`, 'TAX-05: period must be an RFC 0041 selector (Y1+, YYYY-Qn, YYYY-MM, or YYYY-MM-DD)', period);
|
|
734
|
+
shapeOk = false;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
periods.push(period);
|
|
738
|
+
const full = e['full_tax'];
|
|
739
|
+
const abated = e['abated_tax'];
|
|
740
|
+
if (!finiteNum(full) || !finiteNum(abated)) {
|
|
741
|
+
taxIssue(issues, 'TAX-06', section, at, 'TAX-06: full_tax and abated_tax must both be finite numbers');
|
|
742
|
+
shapeOk = false;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (abated < 0 || full < 0) {
|
|
746
|
+
taxIssue(issues, 'TAX-06', section, at, 'TAX-06: full_tax and abated_tax must be nonnegative', abated);
|
|
747
|
+
}
|
|
748
|
+
else if (abated > full) {
|
|
749
|
+
taxIssue(issues, 'TAX-06', section, `${at}.abated_tax`, `TAX-06: abated_tax ${abated} cannot exceed full_tax ${full}`, abated);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (!shapeOk)
|
|
753
|
+
return;
|
|
754
|
+
// TAX-05: one granularity, strictly increasing, no duplicates.
|
|
755
|
+
const kinds = new Set(periods.map(p => taxPeriodKind(p)));
|
|
756
|
+
if (kinds.size > 1) {
|
|
757
|
+
taxIssue(issues, 'TAX-05', section, `${base}.schedule`, `TAX-05: an abatement schedule uses one period granularity (found ${[...kinds].sort().join(', ')})`);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
for (let i = 1; i < periods.length; i++) {
|
|
761
|
+
if (periods[i - 1].localeCompare(periods[i], 'en', { numeric: true }) >= 0) {
|
|
762
|
+
taxIssue(issues, 'TAX-05', section, `${base}.schedule[${i}].period`, `TAX-05: schedule periods must strictly increase (${periods[i - 1]} then ${periods[i]})`, periods[i]);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// TAX-06: a freeze holds the unabated tax nondecreasing across the schedule.
|
|
767
|
+
if (kind === 'freeze') {
|
|
768
|
+
for (let i = 1; i < schedule.length; i++) {
|
|
769
|
+
const prev = schedule[i - 1]['full_tax'];
|
|
770
|
+
const cur = schedule[i]['full_tax'];
|
|
771
|
+
if (cur < prev) {
|
|
772
|
+
taxIssue(issues, 'TAX-06', section, `${base}.schedule[${i}].full_tax`, `TAX-06: a freeze holds full_tax nondecreasing (${prev} then ${cur})`, cur);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// TAX-07: the stabilized snapshot ties to one named period of the schedule.
|
|
778
|
+
const stabilized = a['stabilized_period'];
|
|
779
|
+
if (stabilized === undefined)
|
|
780
|
+
return;
|
|
781
|
+
if (typeof stabilized !== 'string') {
|
|
782
|
+
taxIssue(issues, 'TAX-07', section, `${base}.stabilized_period`, 'TAX-07: stabilized_period must be a period selector naming a schedule entry', stabilized);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const at = periods.indexOf(stabilized);
|
|
786
|
+
if (at === -1) {
|
|
787
|
+
taxIssue(issues, 'TAX-07', section, `${base}.stabilized_period`, `TAX-07: stabilized_period ${stabilized} does not name a period in the schedule`, stabilized);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (!finiteNum(stated))
|
|
791
|
+
return;
|
|
792
|
+
const abated = schedule[at]['abated_tax'];
|
|
793
|
+
if (!sameMoney(stated, abated)) {
|
|
794
|
+
taxIssue(issues, 'TAX-07', section, `${TAX_PATH}.value`, `TAX-07: the stated tax ${stated} must equal the abated tax ${abated} for stabilized_period ${stabilized}`, stated);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/** The RFC 0041 selector families, or null when the string is not one. */
|
|
798
|
+
function taxPeriodKind(p) {
|
|
799
|
+
if (/^Y[1-9]\d*$/.test(p))
|
|
800
|
+
return 'holding_year';
|
|
801
|
+
if (/^\d{4}-Q[1-4]$/.test(p))
|
|
802
|
+
return 'quarter';
|
|
803
|
+
if (/^\d{4}-(0[1-9]|1[0-2])$/.test(p))
|
|
804
|
+
return 'month';
|
|
805
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(p) && parseISODate(p))
|
|
806
|
+
return 'date';
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
function checkTaxBasis(parsed, issues) {
|
|
810
|
+
const noi = resolveCrossCheckSection(parsed, 'noi_model').block;
|
|
811
|
+
if (noi) {
|
|
812
|
+
const tax = deepGet(noi.content, TAX_PATH);
|
|
813
|
+
if (tax !== null && typeof tax === 'object' && !Array.isArray(tax)) {
|
|
814
|
+
const t = tax;
|
|
815
|
+
const stated = t['value'];
|
|
816
|
+
const r = t['reassessment'];
|
|
817
|
+
if (r !== undefined) {
|
|
818
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
819
|
+
taxIssue(issues, 'TAX-01', 'noi_model', `${TAX_PATH}.reassessment`, 'TAX-01: reassessment must be an object when stated', r);
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
const rr = r;
|
|
823
|
+
const dp = checkReassessment(rr, issues, 'noi_model', `${TAX_PATH}.reassessment`);
|
|
824
|
+
// TAX-03: the stated expense is the indicated tax at the declared quantum.
|
|
825
|
+
if (dp !== null && finiteNum(stated) && finiteNum(rr['indicated_tax'])) {
|
|
826
|
+
const rounded = quantizeAtDecimals(rr['indicated_tax'], dp);
|
|
827
|
+
if (!sameMoney(stated, rounded)) {
|
|
828
|
+
taxIssue(issues, 'TAX-03', 'noi_model', `${TAX_PATH}.value`, `TAX-03: the stated tax ${stated} must equal indicated_tax rounded at ${dp} decimals (${rounded})`, stated);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// TAX-04: the legacy boolean has to agree with the typed trigger.
|
|
832
|
+
const flag = t['sale_triggers_reassessment'];
|
|
833
|
+
if (typeof flag === 'boolean' && flag !== (rr['trigger'] === 'sale')) {
|
|
834
|
+
taxIssue(issues, 'TAX-04', 'noi_model', `${TAX_PATH}.sale_triggers_reassessment`, `TAX-04: sale_triggers_reassessment ${flag} disagrees with reassessment.trigger ${JSON.stringify(rr['trigger'])}`, flag);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const a = t['abatement'];
|
|
839
|
+
if (a !== undefined) {
|
|
840
|
+
if (a === null || typeof a !== 'object' || Array.isArray(a)) {
|
|
841
|
+
taxIssue(issues, 'TAX-06', 'noi_model', `${TAX_PATH}.abatement`, 'TAX-06: abatement must be an object when stated', a);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
checkAbatement(a, issues, 'noi_model', `${TAX_PATH}.abatement`, stated);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// The terminal tax: the next buyer's, after this sale reassesses at the exit
|
|
850
|
+
// price. No arithmetic is asserted over exit_noi — deriveDCF leaves
|
|
851
|
+
// exit_value_gross an input because it capitalizes an unstored forward NOI.
|
|
852
|
+
const dcf = resolveCrossCheckSection(parsed, 'dcf').block;
|
|
853
|
+
if (!dcf)
|
|
854
|
+
return;
|
|
855
|
+
const tt = deepGet(dcf.content, 'exit_analysis.terminal_tax');
|
|
856
|
+
if (tt === undefined)
|
|
857
|
+
return;
|
|
858
|
+
const base = 'exit_analysis.terminal_tax';
|
|
859
|
+
if (tt === null || typeof tt !== 'object' || Array.isArray(tt)) {
|
|
860
|
+
taxIssue(issues, 'TAX-01', 'dcf', base, 'TAX-01: terminal_tax must be an object when stated', tt);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const t = tt;
|
|
864
|
+
checkReassessment(t, issues, 'dcf', base);
|
|
865
|
+
if (typeof t['in_exit_noi'] !== 'boolean') {
|
|
866
|
+
taxIssue(issues, 'TAX-08', 'dcf', `${base}.in_exit_noi`, 'TAX-08: terminal_tax.in_exit_noi must state whether this tax is already inside exit_noi', t['in_exit_noi']);
|
|
867
|
+
}
|
|
868
|
+
// TAX-08: the next buyer is reassessed at what they pay, so the basis is the
|
|
869
|
+
// exit value — unless the author says in words why it is not.
|
|
870
|
+
if (t['trigger'] !== 'sale')
|
|
871
|
+
return;
|
|
872
|
+
const why = t['value_basis_differs_because'];
|
|
873
|
+
if (typeof why === 'string' && why.trim().length > 0)
|
|
874
|
+
return;
|
|
875
|
+
const gross = deepGet(dcf.content, 'exit_analysis.exit_value_gross');
|
|
876
|
+
const basis = t['value_basis'];
|
|
877
|
+
if (!finiteNum(gross) || !finiteNum(basis))
|
|
878
|
+
return;
|
|
879
|
+
if (!sameMoney(basis, gross)) {
|
|
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);
|
|
881
|
+
}
|
|
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
|
+
}
|
|
639
1480
|
// ─── §4.23 Mixed-use components (RFC 0019) ───────────────────────────────────
|
|
640
1481
|
// The eight income classes admissible as a mixed-use component. `land` is
|
|
641
1482
|
// excluded (its NOI model nets negative) and `mixed_use` cannot nest in itself.
|
|
@@ -858,6 +1699,30 @@ function checkStackContent(content, section, prefix, issues) {
|
|
|
858
1699
|
issues.push({ code: 'CS-02', severity: 'error', section, field: `${prefix}${label}.rate`, message: `CS-02: common_equity tranche ${label} must not state a rate` });
|
|
859
1700
|
}
|
|
860
1701
|
}
|
|
1702
|
+
// CS-02b: a split coupon is one preferred-equity tranche with two
|
|
1703
|
+
// explicitly typed rate components. Keep the field-presence rule here,
|
|
1704
|
+
// beside CS-02, so schema-valid and hand-built ParsedUWFile inputs receive
|
|
1705
|
+
// the same refusal.
|
|
1706
|
+
const hasCashRate = Object.prototype.hasOwnProperty.call(t, 'cash_rate');
|
|
1707
|
+
const hasAccruedRate = Object.prototype.hasOwnProperty.call(t, 'accrued_rate');
|
|
1708
|
+
if (t['accrual'] === 'split') {
|
|
1709
|
+
if (cls !== 'preferred_equity') {
|
|
1710
|
+
issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.accrual`, message: `CS-02b: split accrual is supported only for preferred_equity tranche ${label}` });
|
|
1711
|
+
}
|
|
1712
|
+
if (!hasCashRate || !Number.isFinite(t['cash_rate'])) {
|
|
1713
|
+
issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.cash_rate`, message: `CS-02b: split tranche ${label} must state a numeric cash_rate` });
|
|
1714
|
+
}
|
|
1715
|
+
if (!hasAccruedRate || !Number.isFinite(t['accrued_rate'])) {
|
|
1716
|
+
issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.accrued_rate`, message: `CS-02b: split tranche ${label} must state a numeric accrued_rate` });
|
|
1717
|
+
}
|
|
1718
|
+
if (hasRate && Number.isFinite(t['cash_rate']) && Number.isFinite(t['accrued_rate'])
|
|
1719
|
+
&& t['rate'] !== t['cash_rate'] + t['accrued_rate']) {
|
|
1720
|
+
issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.rate`, message: `CS-02b: split tranche ${label}.rate must equal cash_rate + accrued_rate` });
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
else if (hasCashRate || hasAccruedRate) {
|
|
1724
|
+
issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.accrual`, message: `CS-02b: cash_rate and accrued_rate are permitted only when tranche ${label} uses accrual: "split"` });
|
|
1725
|
+
}
|
|
861
1726
|
// A waterfall smuggled in at the tranche level (promote/hurdle/catch-up).
|
|
862
1727
|
for (const key of Object.keys(t)) {
|
|
863
1728
|
if (WATERFALL_MARKERS.includes(key)) {
|
|
@@ -1291,6 +2156,14 @@ function checkWaterfallContent(content, variant, parsed, issues) {
|
|
|
1291
2156
|
irrOk = true;
|
|
1292
2157
|
}
|
|
1293
2158
|
}
|
|
2159
|
+
if (t.hurdle_mode != null) {
|
|
2160
|
+
if (t.hurdle_mode !== 'both' && t.hurdle_mode !== 'any') {
|
|
2161
|
+
wf01(`tiers[${i}].hurdle_mode`, 'hurdle_mode must be "both" or "any"');
|
|
2162
|
+
}
|
|
2163
|
+
else if (t.until_lp_em == null || t.until_lp_irr == null) {
|
|
2164
|
+
wf01(`tiers[${i}].hurdle_mode`, 'hurdle_mode is only permitted when both until_lp_em and until_lp_irr are stated');
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
1294
2167
|
if (t.until_lp_em != null || t.until_lp_irr != null) {
|
|
1295
2168
|
if ((emOk || irrOk) && !(typeof t.lp_share === 'number' && t.lp_share > 0)) {
|
|
1296
2169
|
wf01(`tiers[${i}].lp_share`, 'a capped split must pay the LP (lp_share > 0) or the cap can never bind');
|
|
@@ -1383,6 +2256,19 @@ function checkLocale(parsed, issues) {
|
|
|
1383
2256
|
value: String(declared),
|
|
1384
2257
|
});
|
|
1385
2258
|
}
|
|
2259
|
+
// ─── Document currency identity (RFC 0046) ──────────────────────────────────
|
|
2260
|
+
function checkCurrencyIdentity(parsed, issues) {
|
|
2261
|
+
const declared = parsed.frontmatter.currency_code;
|
|
2262
|
+
if (declared == null)
|
|
2263
|
+
return; // absent preserves legacy symbol behavior
|
|
2264
|
+
if (isCurrencyCode(declared))
|
|
2265
|
+
return;
|
|
2266
|
+
issues.push({
|
|
2267
|
+
code: 'CUR-01', severity: 'error', field: 'currency_code',
|
|
2268
|
+
message: `CUR-01: currency_code ${JSON.stringify(declared)} is not three uppercase ASCII letters; currency identity is never inferred from locale or a display symbol`,
|
|
2269
|
+
value: declared,
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
1386
2272
|
// ─── Asset-class identifier (RFC 0003) ───────────────────────────────────────
|
|
1387
2273
|
/**
|
|
1388
2274
|
* The identifier itself, independent of whether any module is loaded.
|