@hyperscale0/hsx 1.0.0-alpha.4 → 1.0.0-alpha.6
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/CHANGELOG.md +88 -1
- package/README.md +1 -1
- package/dist/src/archetypes.d.ts +17 -0
- package/dist/src/archetypes.d.ts.map +1 -0
- package/dist/src/archetypes.js +327 -0
- package/dist/src/archetypes.js.map +1 -0
- package/dist/src/ast.d.ts +2 -3
- package/dist/src/ast.d.ts.map +1 -1
- package/dist/src/check.d.ts +1 -2
- package/dist/src/check.d.ts.map +1 -1
- package/dist/src/check.js +805 -109
- package/dist/src/check.js.map +1 -1
- package/dist/src/entry-overrides.d.ts +44 -0
- package/dist/src/entry-overrides.d.ts.map +1 -0
- package/dist/src/entry-overrides.js +139 -0
- package/dist/src/entry-overrides.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lower.d.ts +57 -4
- package/dist/src/lower.d.ts.map +1 -1
- package/dist/src/lower.js +2587 -98
- package/dist/src/lower.js.map +1 -1
- package/dist/src/model.d.ts +167 -7
- package/dist/src/model.d.ts.map +1 -1
- package/dist/src/model.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/spec/hsx-ir.schema.json +406 -8
- package/src/archetypes.ts +340 -0
- package/src/ast.ts +2 -3
- package/src/check.ts +1363 -124
- package/src/entry-overrides.ts +213 -0
- package/src/index.ts +7 -0
- package/src/lower.ts +2922 -125
- package/src/model.ts +187 -8
- package/src/version.ts +1 -1
package/src/check.ts
CHANGED
|
@@ -14,6 +14,11 @@ import type {
|
|
|
14
14
|
Program,
|
|
15
15
|
Span,
|
|
16
16
|
} from "./ast.ts";
|
|
17
|
+
import {
|
|
18
|
+
ARCHETYPE_DEFINITIONS,
|
|
19
|
+
type ArchetypeName,
|
|
20
|
+
SETTLEMENT_ARCHETYPES,
|
|
21
|
+
} from "./archetypes.ts";
|
|
17
22
|
import {
|
|
18
23
|
ASSET_KINDS,
|
|
19
24
|
PARTY_KINDS,
|
|
@@ -23,9 +28,20 @@ import {
|
|
|
23
28
|
type CheckDiagnostic,
|
|
24
29
|
type CheckResult,
|
|
25
30
|
type CheckedAsset,
|
|
31
|
+
type CheckedCaptureReservation,
|
|
32
|
+
type CheckedConditionalDisbursement,
|
|
33
|
+
type CheckedCreditFacility,
|
|
34
|
+
type CheckedDerivedAmount,
|
|
35
|
+
type CheckedFundingRound,
|
|
26
36
|
type CheckedParty,
|
|
27
37
|
type CheckedPort,
|
|
38
|
+
type CheckedPremiumForward,
|
|
39
|
+
type CheckedSettlementReference,
|
|
28
40
|
type CheckedSettlement,
|
|
41
|
+
type CheckedSettlementBatch,
|
|
42
|
+
type CheckedRecurringCollection,
|
|
43
|
+
type CheckedRotatingPool,
|
|
44
|
+
type CheckedWeightedDistribution,
|
|
29
45
|
type FeeTerm,
|
|
30
46
|
type MeterRate,
|
|
31
47
|
type MoneyField,
|
|
@@ -37,75 +53,7 @@ import {
|
|
|
37
53
|
type SwapFee,
|
|
38
54
|
} from "./model.ts";
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
export const SETTLEMENT_ARCHETYPES = [
|
|
42
|
-
"advance",
|
|
43
|
-
"deposit",
|
|
44
|
-
"held_payment",
|
|
45
|
-
"instant_transfer",
|
|
46
|
-
"metered",
|
|
47
|
-
"pooled_split",
|
|
48
|
-
"premium_forward",
|
|
49
|
-
"scheduled",
|
|
50
|
-
"swap",
|
|
51
|
-
] as const;
|
|
52
|
-
|
|
53
|
-
type ArchetypeName = (typeof SETTLEMENT_ARCHETYPES)[number];
|
|
54
|
-
|
|
55
|
-
/** Each archetype's parameter surface: the entries its body understands. */
|
|
56
|
-
const ARCHETYPE_SURFACES: Record<
|
|
57
|
-
ArchetypeName,
|
|
58
|
-
{ readonly keys: readonly string[]; readonly required: readonly string[] }
|
|
59
|
-
> = {
|
|
60
|
-
advance: {
|
|
61
|
-
keys: [
|
|
62
|
-
"against",
|
|
63
|
-
"amount",
|
|
64
|
-
"count",
|
|
65
|
-
"every",
|
|
66
|
-
"fee",
|
|
67
|
-
"first_due",
|
|
68
|
-
"funder",
|
|
69
|
-
"to",
|
|
70
|
-
],
|
|
71
|
-
// The repayment source is one of two shapes (a schedule, or a carve out of
|
|
72
|
-
// a hold's release), so the advance case below states that requirement
|
|
73
|
-
// itself rather than listing either shape's keys here.
|
|
74
|
-
required: ["funder", "to", "amount"],
|
|
75
|
-
},
|
|
76
|
-
deposit: {
|
|
77
|
-
keys: ["amount", "claim", "holder", "payer", "return"],
|
|
78
|
-
required: ["payer", "holder", "amount", "claim", "return"],
|
|
79
|
-
},
|
|
80
|
-
held_payment: {
|
|
81
|
-
keys: ["amount", "fees", "on_cancel", "payer", "payee", "release"],
|
|
82
|
-
required: ["payer", "payee", "amount", "release"],
|
|
83
|
-
},
|
|
84
|
-
instant_transfer: {
|
|
85
|
-
keys: ["amount", "fees", "payer", "payee"],
|
|
86
|
-
required: ["payer", "payee", "amount"],
|
|
87
|
-
},
|
|
88
|
-
metered: {
|
|
89
|
-
keys: ["close_by", "payer", "payee", "rates"],
|
|
90
|
-
required: ["payer", "payee", "rates", "close_by"],
|
|
91
|
-
},
|
|
92
|
-
pooled_split: {
|
|
93
|
-
keys: ["amount", "payer", "payout_due", "split"],
|
|
94
|
-
required: ["payer", "amount", "split", "payout_due"],
|
|
95
|
-
},
|
|
96
|
-
premium_forward: {
|
|
97
|
-
keys: ["amount", "bind", "carrier", "commission", "on_cancel", "payer"],
|
|
98
|
-
required: ["payer", "carrier", "amount", "bind"],
|
|
99
|
-
},
|
|
100
|
-
scheduled: {
|
|
101
|
-
keys: ["amount", "count", "every", "first_due", "payer", "payee"],
|
|
102
|
-
required: ["payer", "payee", "amount", "count", "every", "first_due"],
|
|
103
|
-
},
|
|
104
|
-
swap: {
|
|
105
|
-
keys: ["amounts", "between", "dispute", "fees", "release"],
|
|
106
|
-
required: ["between", "amounts", "release"],
|
|
107
|
-
},
|
|
108
|
-
};
|
|
56
|
+
export { SETTLEMENT_ARCHETYPES } from "./archetypes.ts";
|
|
109
57
|
|
|
110
58
|
/** The three terms that together declare a finite schedule. */
|
|
111
59
|
const SCHEDULE_KEYS = ["count", "every", "first_due"] as const;
|
|
@@ -124,6 +72,7 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
124
72
|
const warning = (span: Span, message: string): void => {
|
|
125
73
|
diagnostics.push({ message, severity: "warning", span });
|
|
126
74
|
};
|
|
75
|
+
const derivedAmounts: CheckedDerivedAmount[] = [];
|
|
127
76
|
|
|
128
77
|
// --- Header ---------------------------------------------------------------
|
|
129
78
|
const headers = program.decls.filter((decl) => decl.kind === "program");
|
|
@@ -338,6 +287,7 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
338
287
|
/^piece\d+Amount$/,
|
|
339
288
|
/^installment\d+Amount$/,
|
|
340
289
|
/^repayment\d+Amount$/,
|
|
290
|
+
/^carve(?:Hold|Recourse\d+)Id$/,
|
|
341
291
|
/ShareAmount$/,
|
|
342
292
|
/AccountId$/,
|
|
343
293
|
];
|
|
@@ -464,6 +414,7 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
464
414
|
const parseDisputeEntry = (
|
|
465
415
|
entry: Entry | undefined,
|
|
466
416
|
owner: string,
|
|
417
|
+
label = "dispute",
|
|
467
418
|
):
|
|
468
419
|
| {
|
|
469
420
|
readonly origin: Span;
|
|
@@ -477,35 +428,35 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
477
428
|
if (value.kind !== "port_ref") {
|
|
478
429
|
error(
|
|
479
430
|
value.span,
|
|
480
|
-
`${owner} needs one
|
|
431
|
+
`${owner} needs one ${label} decision through a fixed windowed port`,
|
|
481
432
|
);
|
|
482
433
|
return undefined;
|
|
483
434
|
}
|
|
484
435
|
if (!portNames.has(value.name.name)) {
|
|
485
436
|
error(
|
|
486
437
|
value.name.span,
|
|
487
|
-
`${owner}
|
|
438
|
+
`${owner} routes ${label} through port ${value.name.name}, but no port with that name is declared`,
|
|
488
439
|
);
|
|
489
440
|
return undefined;
|
|
490
441
|
}
|
|
491
442
|
if (!value.within) {
|
|
492
443
|
error(
|
|
493
444
|
value.span,
|
|
494
|
-
`${owner}
|
|
445
|
+
`${owner} ${label} needs a fixed window, like: ${entry.key.name}: port ${value.name.name} within P14D`,
|
|
495
446
|
);
|
|
496
447
|
return undefined;
|
|
497
448
|
}
|
|
498
449
|
const raw = value.within.name;
|
|
499
450
|
const match = /^P([1-9]\d{0,3})([DW])$/.exec(raw);
|
|
500
|
-
if (
|
|
451
|
+
if (!match) {
|
|
501
452
|
error(
|
|
502
453
|
value.within.span,
|
|
503
|
-
|
|
454
|
+
`${label} on ${owner} uses a fixed duration in days or weeks, like P14D; calendar months cannot define an exact money deadline`,
|
|
504
455
|
);
|
|
505
456
|
return undefined;
|
|
506
457
|
}
|
|
507
|
-
const magnitude =
|
|
508
|
-
const days = match
|
|
458
|
+
const magnitude = Number(match[1]);
|
|
459
|
+
const days = match[2] === "W" ? magnitude * 7 : magnitude;
|
|
509
460
|
referencedPorts.add(value.name.name);
|
|
510
461
|
return {
|
|
511
462
|
origin: value.span,
|
|
@@ -771,10 +722,98 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
771
722
|
return undefined;
|
|
772
723
|
};
|
|
773
724
|
|
|
725
|
+
const parseFieldName = (
|
|
726
|
+
entry: Entry | undefined,
|
|
727
|
+
owner: string,
|
|
728
|
+
): string | undefined => {
|
|
729
|
+
if (!entry) return undefined;
|
|
730
|
+
noQualifiers(entry, owner);
|
|
731
|
+
if (entry.value.kind === "ident" && CAMEL_CASE.test(entry.value.name)) {
|
|
732
|
+
return reservedFieldName(entry.value.name, entry.value.span, owner)
|
|
733
|
+
? undefined
|
|
734
|
+
: entry.value.name;
|
|
735
|
+
}
|
|
736
|
+
error(
|
|
737
|
+
entry.value.span,
|
|
738
|
+
`${entry.key.name} on ${owner} names a camelCase lineage field, like: ${entry.key.name}: captureReference`,
|
|
739
|
+
);
|
|
740
|
+
return undefined;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
const requireIdentPolicy = (
|
|
744
|
+
entry: Entry | undefined,
|
|
745
|
+
owner: string,
|
|
746
|
+
expected: string,
|
|
747
|
+
refusal: string,
|
|
748
|
+
): boolean => {
|
|
749
|
+
if (!entry) return false;
|
|
750
|
+
noQualifiers(entry, owner);
|
|
751
|
+
if (entry.value.kind === "ident" && entry.value.name === expected) {
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
error(
|
|
755
|
+
entry.value.span,
|
|
756
|
+
`${entry.key.name} on ${owner} is ${expected}; ${refusal}`,
|
|
757
|
+
);
|
|
758
|
+
return false;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const parseLiteralCount = (
|
|
762
|
+
entry: Entry | undefined,
|
|
763
|
+
owner: string,
|
|
764
|
+
label: string,
|
|
765
|
+
minimum: number,
|
|
766
|
+
maximum: number,
|
|
767
|
+
): number | undefined => {
|
|
768
|
+
if (!entry) return undefined;
|
|
769
|
+
noQualifiers(entry, owner);
|
|
770
|
+
const parsed =
|
|
771
|
+
entry.value.kind === "number" ? Number(entry.value.raw) : Number.NaN;
|
|
772
|
+
if (Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum) {
|
|
773
|
+
return parsed;
|
|
774
|
+
}
|
|
775
|
+
error(
|
|
776
|
+
entry.value.span,
|
|
777
|
+
`${label} on ${owner} is a literal integer from ${minimum} through ${maximum}`,
|
|
778
|
+
);
|
|
779
|
+
return undefined;
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const parsePartyList = (
|
|
783
|
+
entry: Entry | undefined,
|
|
784
|
+
owner: string,
|
|
785
|
+
label: string,
|
|
786
|
+
minimum: number,
|
|
787
|
+
maximum: number,
|
|
788
|
+
): string[] | undefined => {
|
|
789
|
+
if (!entry) return undefined;
|
|
790
|
+
noQualifiers(entry, owner);
|
|
791
|
+
if (
|
|
792
|
+
entry.value.kind !== "list" ||
|
|
793
|
+
entry.value.items.length < minimum ||
|
|
794
|
+
entry.value.items.length > maximum
|
|
795
|
+
) {
|
|
796
|
+
error(
|
|
797
|
+
entry.value.span,
|
|
798
|
+
`${label} on ${owner} lists ${minimum} through ${maximum} declared parties`,
|
|
799
|
+
);
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
const names = entry.value.items
|
|
803
|
+
.map((item) => partyRef(item, `${owner} ${label}`))
|
|
804
|
+
.filter((name): name is string => name !== undefined);
|
|
805
|
+
if (new Set(names).size !== names.length) {
|
|
806
|
+
error(entry.value.span, `${label} on ${owner} must not repeat a party`);
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
return names.length === entry.value.items.length ? names : undefined;
|
|
810
|
+
};
|
|
811
|
+
|
|
774
812
|
const parseSchedule = (
|
|
775
813
|
entries: Map<string, Entry>,
|
|
776
814
|
owner: string,
|
|
777
815
|
origin: Span,
|
|
816
|
+
maxCount = 12,
|
|
778
817
|
): ScheduleTerms | undefined => {
|
|
779
818
|
const countEntry = entries.get("count");
|
|
780
819
|
const everyEntry = entries.get("every");
|
|
@@ -783,12 +822,12 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
783
822
|
noQualifiers(countEntry, owner);
|
|
784
823
|
const value = countEntry.value;
|
|
785
824
|
const parsed = value.kind === "number" ? Number(value.raw) : Number.NaN;
|
|
786
|
-
if (Number.isInteger(parsed) && parsed >= 2 && parsed <=
|
|
825
|
+
if (Number.isInteger(parsed) && parsed >= 2 && parsed <= maxCount) {
|
|
787
826
|
count = parsed;
|
|
788
827
|
} else {
|
|
789
828
|
error(
|
|
790
829
|
value.span,
|
|
791
|
-
`count on ${owner} is a literal number of anchors between 2 and
|
|
830
|
+
`count on ${owner} is a literal number of anchors between 2 and ${maxCount}; the schedule stays finite by construction`,
|
|
792
831
|
);
|
|
793
832
|
}
|
|
794
833
|
}
|
|
@@ -821,6 +860,61 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
821
860
|
return { count, every, firstDueField, origin };
|
|
822
861
|
};
|
|
823
862
|
|
|
863
|
+
const parseSettlementReference = (
|
|
864
|
+
entry: Entry,
|
|
865
|
+
owner: string,
|
|
866
|
+
): CheckedSettlementReference | undefined => {
|
|
867
|
+
noQualifiers(entry, owner);
|
|
868
|
+
const value = entry.value;
|
|
869
|
+
if (value.kind !== "settlement_ref") {
|
|
870
|
+
error(
|
|
871
|
+
value.span,
|
|
872
|
+
`${owner} draws against a held payment's release, like: against: retention.release`,
|
|
873
|
+
);
|
|
874
|
+
return undefined;
|
|
875
|
+
}
|
|
876
|
+
const target = value.owner.name;
|
|
877
|
+
const targetDecl = settlementDecls.find(
|
|
878
|
+
(decl) => decl.name.name === target,
|
|
879
|
+
);
|
|
880
|
+
if (!targetDecl) {
|
|
881
|
+
const other = declared.get(target);
|
|
882
|
+
error(
|
|
883
|
+
value.owner.span,
|
|
884
|
+
other
|
|
885
|
+
? `${owner} references ${target}, which is a ${other.kind}; settlement exits belong to settlements`
|
|
886
|
+
: `${owner} references ${target}, but no settlement with that name is declared`,
|
|
887
|
+
);
|
|
888
|
+
return undefined;
|
|
889
|
+
}
|
|
890
|
+
const targetArchetype = targetDecl.archetype.name;
|
|
891
|
+
if (
|
|
892
|
+
!(SETTLEMENT_ARCHETYPES as readonly string[]).includes(targetArchetype)
|
|
893
|
+
) {
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
const exits =
|
|
897
|
+
ARCHETYPE_DEFINITIONS[targetArchetype as ArchetypeName]
|
|
898
|
+
.referenceableExits;
|
|
899
|
+
if (!exits.includes(value.member.name)) {
|
|
900
|
+
const offered =
|
|
901
|
+
exits.length > 0
|
|
902
|
+
? `it exposes ${exits.join(", ")}`
|
|
903
|
+
: "it exposes no referenceable exits";
|
|
904
|
+
error(
|
|
905
|
+
value.member.span,
|
|
906
|
+
`${owner} references ${target}.${value.member.name}, but ${targetArchetype} does not expose that exit; ${offered}`,
|
|
907
|
+
);
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
exit: value.member.name,
|
|
912
|
+
origin: value.span,
|
|
913
|
+
settlement: target,
|
|
914
|
+
targetArchetype: targetArchetype as ArchetypeName,
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
|
|
824
918
|
/**
|
|
825
919
|
* An advance draws its repayment from exactly one source: its own finite
|
|
826
920
|
* schedule, or a carve out of the release of a hold the advanced party is
|
|
@@ -855,23 +949,8 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
855
949
|
const schedule = parseSchedule(entries, owner, origin);
|
|
856
950
|
return schedule ? { kind: "schedule", schedule } : undefined;
|
|
857
951
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (value.kind !== "settlement_ref") {
|
|
861
|
-
error(
|
|
862
|
-
value.span,
|
|
863
|
-
`${owner} draws against a held payment's release, like: against: retention.release`,
|
|
864
|
-
);
|
|
865
|
-
return undefined;
|
|
866
|
-
}
|
|
867
|
-
if (value.member.name !== "release") {
|
|
868
|
-
error(
|
|
869
|
-
value.member.span,
|
|
870
|
-
`an advance carves a hold's release; there is no exit named ${value.member.name} on ${value.owner.name} to draw against`,
|
|
871
|
-
);
|
|
872
|
-
return undefined;
|
|
873
|
-
}
|
|
874
|
-
return { kind: "carve", origin: value.span, settlement: value.owner.name };
|
|
952
|
+
const reference = parseSettlementReference(againstEntry, owner);
|
|
953
|
+
return reference ? { kind: "carve", ...reference } : undefined;
|
|
875
954
|
};
|
|
876
955
|
|
|
877
956
|
// --- Settlements: one checker per archetype ---------------------------------
|
|
@@ -888,8 +967,12 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
888
967
|
}
|
|
889
968
|
|
|
890
969
|
const owner = `settlement ${decl.name.name}`;
|
|
891
|
-
const surface =
|
|
892
|
-
const entries = entriesOf(
|
|
970
|
+
const surface = ARCHETYPE_DEFINITIONS[archetype as ArchetypeName];
|
|
971
|
+
const entries = entriesOf(
|
|
972
|
+
decl.body,
|
|
973
|
+
[...surface.keys, "derived_amount"],
|
|
974
|
+
owner,
|
|
975
|
+
);
|
|
893
976
|
for (const required of surface.required) {
|
|
894
977
|
if (!entries.has(required)) {
|
|
895
978
|
error(decl.span, `${owner} is missing ${required}`);
|
|
@@ -912,8 +995,737 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
912
995
|
}
|
|
913
996
|
return true;
|
|
914
997
|
};
|
|
998
|
+
const derived = entries.get("derived_amount");
|
|
999
|
+
if (derived) {
|
|
1000
|
+
noQualifiers(derived, owner);
|
|
1001
|
+
if (derived.value.kind !== "block") {
|
|
1002
|
+
error(
|
|
1003
|
+
derived.value.span,
|
|
1004
|
+
"derived_amount is a block with field, source, rule, and bearer",
|
|
1005
|
+
);
|
|
1006
|
+
} else {
|
|
1007
|
+
const terms = entriesOf(
|
|
1008
|
+
derived.value,
|
|
1009
|
+
["field", "source", "rule", "bearer"],
|
|
1010
|
+
`${owner} derived_amount`,
|
|
1011
|
+
);
|
|
1012
|
+
for (const key of ["field", "source", "rule", "bearer"] as const) {
|
|
1013
|
+
if (!terms.has(key))
|
|
1014
|
+
error(derived.span, `${owner} derived_amount is missing ${key}`);
|
|
1015
|
+
}
|
|
1016
|
+
const fieldValue = terms.get("field")?.value;
|
|
1017
|
+
const baseValue = terms.get("source")?.value;
|
|
1018
|
+
const ruleValue = terms.get("rule")?.value;
|
|
1019
|
+
const bearerValue = terms.get("bearer")?.value;
|
|
1020
|
+
const field =
|
|
1021
|
+
fieldValue?.kind === "ident" ? fieldValue.name : undefined;
|
|
1022
|
+
const baseField =
|
|
1023
|
+
baseValue?.kind === "ident" ? baseValue.name : undefined;
|
|
1024
|
+
const bearer = bearerValue
|
|
1025
|
+
? partyRef(bearerValue, `${owner} derived amount bearer`)
|
|
1026
|
+
: undefined;
|
|
1027
|
+
if (fieldValue && fieldValue.kind !== "ident")
|
|
1028
|
+
error(
|
|
1029
|
+
fieldValue.span,
|
|
1030
|
+
"derived amount field must be a camelCase identifier",
|
|
1031
|
+
);
|
|
1032
|
+
if (baseValue && baseValue.kind !== "ident")
|
|
1033
|
+
error(
|
|
1034
|
+
baseValue.span,
|
|
1035
|
+
"derived amount source must name one stored money field",
|
|
1036
|
+
);
|
|
1037
|
+
if (ruleValue && ruleValue.kind !== "percent") {
|
|
1038
|
+
error(
|
|
1039
|
+
ruleValue.span,
|
|
1040
|
+
"derived_amount proves percentage-of rules only; fixed and tiered rules are refused",
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
if (
|
|
1044
|
+
ruleValue?.kind === "percent" &&
|
|
1045
|
+
(ruleValue.bps <= 0 || ruleValue.bps >= TOTAL_BPS)
|
|
1046
|
+
) {
|
|
1047
|
+
error(
|
|
1048
|
+
ruleValue.span,
|
|
1049
|
+
"derived amount percentage must be above 0% and below 100%",
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (field && !CAMEL_CASE.test(field))
|
|
1053
|
+
error(
|
|
1054
|
+
fieldValue!.span,
|
|
1055
|
+
`derived amount field ${field} must be camelCase`,
|
|
1056
|
+
);
|
|
1057
|
+
if (baseField && !CAMEL_CASE.test(baseField))
|
|
1058
|
+
error(
|
|
1059
|
+
baseValue!.span,
|
|
1060
|
+
`derived amount source ${baseField} must be camelCase`,
|
|
1061
|
+
);
|
|
1062
|
+
if (field && baseField && field === baseField)
|
|
1063
|
+
error(
|
|
1064
|
+
derived.span,
|
|
1065
|
+
"derived amount field must differ from its source field",
|
|
1066
|
+
);
|
|
1067
|
+
if (
|
|
1068
|
+
field &&
|
|
1069
|
+
baseField &&
|
|
1070
|
+
bearer &&
|
|
1071
|
+
ruleValue?.kind === "percent" &&
|
|
1072
|
+
ruleValue.bps > 0 &&
|
|
1073
|
+
ruleValue.bps < TOTAL_BPS
|
|
1074
|
+
) {
|
|
1075
|
+
derivedAmounts.push({
|
|
1076
|
+
baseField,
|
|
1077
|
+
bearer,
|
|
1078
|
+
bps: ruleValue.bps,
|
|
1079
|
+
field,
|
|
1080
|
+
origin: derived.span,
|
|
1081
|
+
settlement: decl.name.name,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
915
1086
|
|
|
916
1087
|
switch (archetype as ArchetypeName) {
|
|
1088
|
+
case "captured_payment": {
|
|
1089
|
+
const payer = party("payer", "payer");
|
|
1090
|
+
const payee = party("payee", "payee");
|
|
1091
|
+
const sound = distinct(
|
|
1092
|
+
payer,
|
|
1093
|
+
payee,
|
|
1094
|
+
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
1095
|
+
);
|
|
1096
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1097
|
+
const reserveUntilField = parseDateField(
|
|
1098
|
+
entries.get("reserve_until"),
|
|
1099
|
+
owner,
|
|
1100
|
+
);
|
|
1101
|
+
const correction = parsePortEntry(
|
|
1102
|
+
entries.get("correction"),
|
|
1103
|
+
owner,
|
|
1104
|
+
"payee correction",
|
|
1105
|
+
);
|
|
1106
|
+
const externalReversal = parseDisputeEntry(
|
|
1107
|
+
entries.get("external_reversal"),
|
|
1108
|
+
owner,
|
|
1109
|
+
"external reversal",
|
|
1110
|
+
);
|
|
1111
|
+
const captureInSlices = requireIdentPolicy(
|
|
1112
|
+
entries.get("capture_mode"),
|
|
1113
|
+
owner,
|
|
1114
|
+
"partial_then_full",
|
|
1115
|
+
"capture calls must leave a remainder and settle posts the final remainder",
|
|
1116
|
+
);
|
|
1117
|
+
const fullCorrection = requireIdentPolicy(
|
|
1118
|
+
entries.get("correction_mode"),
|
|
1119
|
+
owner,
|
|
1120
|
+
"full_only",
|
|
1121
|
+
"repeated partial corrections are not representable yet, so the checker refuses them",
|
|
1122
|
+
);
|
|
1123
|
+
const noNegativePosition = requireIdentPolicy(
|
|
1124
|
+
entries.get("negative_position"),
|
|
1125
|
+
owner,
|
|
1126
|
+
"reject",
|
|
1127
|
+
"a correction or reversal must fail when the payee cannot fund it",
|
|
1128
|
+
);
|
|
1129
|
+
const timeoutRejects = requireIdentPolicy(
|
|
1130
|
+
entries.get("timeout"),
|
|
1131
|
+
owner,
|
|
1132
|
+
"reject",
|
|
1133
|
+
"a timeout records no money movement; only a confirmed decision may reverse funds",
|
|
1134
|
+
);
|
|
1135
|
+
if (entries.has("fees")) {
|
|
1136
|
+
error(
|
|
1137
|
+
entries.get("fees")!.span,
|
|
1138
|
+
`${owner} does not price fees inside captured_payment; compose a separate fee settlement so capture and correction amounts stay exact`,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
if (amount && reserveUntilField === amount.name) {
|
|
1142
|
+
error(
|
|
1143
|
+
entries.get("reserve_until")?.span ?? decl.span,
|
|
1144
|
+
`${owner} uses ${amount.name} as both the reserved amount and reserve_until date field; they need distinct names`,
|
|
1145
|
+
);
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
if (
|
|
1149
|
+
payer &&
|
|
1150
|
+
payee &&
|
|
1151
|
+
amount &&
|
|
1152
|
+
reserveUntilField &&
|
|
1153
|
+
correction &&
|
|
1154
|
+
externalReversal &&
|
|
1155
|
+
captureInSlices &&
|
|
1156
|
+
fullCorrection &&
|
|
1157
|
+
noNegativePosition &&
|
|
1158
|
+
timeoutRejects &&
|
|
1159
|
+
sound
|
|
1160
|
+
) {
|
|
1161
|
+
settlements.push({
|
|
1162
|
+
amount,
|
|
1163
|
+
archetype: "captured_payment",
|
|
1164
|
+
correction,
|
|
1165
|
+
externalReversal,
|
|
1166
|
+
name: decl.name.name,
|
|
1167
|
+
origin: decl.span,
|
|
1168
|
+
payee,
|
|
1169
|
+
payer,
|
|
1170
|
+
reserveUntilField,
|
|
1171
|
+
} satisfies CheckedCaptureReservation);
|
|
1172
|
+
}
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
case "settlement_batch": {
|
|
1176
|
+
const settlementAccount = party(
|
|
1177
|
+
"settlement_account",
|
|
1178
|
+
"settlement account",
|
|
1179
|
+
);
|
|
1180
|
+
const payoutDestination = party(
|
|
1181
|
+
"payout_destination",
|
|
1182
|
+
"payout destination",
|
|
1183
|
+
);
|
|
1184
|
+
const sound = distinct(
|
|
1185
|
+
settlementAccount,
|
|
1186
|
+
payoutDestination,
|
|
1187
|
+
`routes payout from ${settlementAccount} back to itself; settlement account and payout destination must differ`,
|
|
1188
|
+
);
|
|
1189
|
+
const sourceCaptureReferenceField = parseFieldName(
|
|
1190
|
+
entries.get("source_capture_refs"),
|
|
1191
|
+
owner,
|
|
1192
|
+
);
|
|
1193
|
+
const feeReferenceField = parseFieldName(
|
|
1194
|
+
entries.get("fee_entries"),
|
|
1195
|
+
owner,
|
|
1196
|
+
);
|
|
1197
|
+
const externalReversalReferenceField = parseFieldName(
|
|
1198
|
+
entries.get("external_reversal_offsets"),
|
|
1199
|
+
owner,
|
|
1200
|
+
);
|
|
1201
|
+
const closeTriggerField = parseDateField(
|
|
1202
|
+
entries.get("close_trigger"),
|
|
1203
|
+
owner,
|
|
1204
|
+
);
|
|
1205
|
+
const payoutAcknowledgement = parsePortEntry(
|
|
1206
|
+
entries.get("payout_acknowledgement"),
|
|
1207
|
+
owner,
|
|
1208
|
+
"payout acknowledgement",
|
|
1209
|
+
);
|
|
1210
|
+
const payoutBeneficiaryReferenceField = parseFieldName(
|
|
1211
|
+
entries.get("payout_beneficiary_ref"),
|
|
1212
|
+
owner,
|
|
1213
|
+
);
|
|
1214
|
+
const rejectsNegative = requireIdentPolicy(
|
|
1215
|
+
entries.get("negative_position"),
|
|
1216
|
+
owner,
|
|
1217
|
+
"reject",
|
|
1218
|
+
"offsets beyond gross capture entries must stop calculation and post no payout",
|
|
1219
|
+
);
|
|
1220
|
+
const lineageFields = [
|
|
1221
|
+
sourceCaptureReferenceField,
|
|
1222
|
+
feeReferenceField,
|
|
1223
|
+
externalReversalReferenceField,
|
|
1224
|
+
closeTriggerField,
|
|
1225
|
+
payoutBeneficiaryReferenceField,
|
|
1226
|
+
].filter((field): field is string => field !== undefined);
|
|
1227
|
+
if (new Set(lineageFields).size !== lineageFields.length) {
|
|
1228
|
+
error(
|
|
1229
|
+
decl.span,
|
|
1230
|
+
`${owner} needs distinct source_capture_refs, fee_entries, external_reversal_offsets, close_trigger, and payout_beneficiary_ref field names`,
|
|
1231
|
+
);
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
if (
|
|
1235
|
+
settlementAccount &&
|
|
1236
|
+
payoutDestination &&
|
|
1237
|
+
sourceCaptureReferenceField &&
|
|
1238
|
+
feeReferenceField &&
|
|
1239
|
+
externalReversalReferenceField &&
|
|
1240
|
+
closeTriggerField &&
|
|
1241
|
+
payoutAcknowledgement &&
|
|
1242
|
+
payoutBeneficiaryReferenceField &&
|
|
1243
|
+
rejectsNegative &&
|
|
1244
|
+
sound
|
|
1245
|
+
) {
|
|
1246
|
+
settlements.push({
|
|
1247
|
+
archetype: "settlement_batch",
|
|
1248
|
+
closeTriggerField,
|
|
1249
|
+
externalReversalReferenceField,
|
|
1250
|
+
feeReferenceField,
|
|
1251
|
+
name: decl.name.name,
|
|
1252
|
+
origin: decl.span,
|
|
1253
|
+
payoutDestination,
|
|
1254
|
+
payoutAcknowledgement,
|
|
1255
|
+
payoutBeneficiaryReferenceField,
|
|
1256
|
+
settlementAccount,
|
|
1257
|
+
sourceCaptureReferenceField,
|
|
1258
|
+
} satisfies CheckedSettlementBatch);
|
|
1259
|
+
}
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
case "funding_round": {
|
|
1263
|
+
const contributor = party("contributor", "contributor");
|
|
1264
|
+
const beneficiary = party("beneficiary", "beneficiary");
|
|
1265
|
+
const target = parseAmount(entries.get("target"), owner);
|
|
1266
|
+
const commitment = parseAmount(entries.get("commitment"), owner);
|
|
1267
|
+
const maxContributors = parseLiteralCount(
|
|
1268
|
+
entries.get("max_contributors"),
|
|
1269
|
+
owner,
|
|
1270
|
+
"max_contributors",
|
|
1271
|
+
2,
|
|
1272
|
+
10_000,
|
|
1273
|
+
);
|
|
1274
|
+
const closeByField = parseDateField(entries.get("close_by"), owner);
|
|
1275
|
+
const policiesSound = [
|
|
1276
|
+
requireIdentPolicy(
|
|
1277
|
+
entries.get("close_policy"),
|
|
1278
|
+
owner,
|
|
1279
|
+
"threshold",
|
|
1280
|
+
"close must follow the locked committed sum at the stored close date",
|
|
1281
|
+
),
|
|
1282
|
+
requireIdentPolicy(
|
|
1283
|
+
entries.get("overfund_policy"),
|
|
1284
|
+
owner,
|
|
1285
|
+
"reject",
|
|
1286
|
+
"a commitment past the remaining target headroom must refuse",
|
|
1287
|
+
),
|
|
1288
|
+
requireIdentPolicy(
|
|
1289
|
+
entries.get("cancel_policy"),
|
|
1290
|
+
owner,
|
|
1291
|
+
"before_close",
|
|
1292
|
+
"a commitment may cancel only while its parent remains open",
|
|
1293
|
+
),
|
|
1294
|
+
requireIdentPolicy(
|
|
1295
|
+
entries.get("fail_policy"),
|
|
1296
|
+
owner,
|
|
1297
|
+
"whole_commitment_refund",
|
|
1298
|
+
"failure refunds each stored commitment whole",
|
|
1299
|
+
),
|
|
1300
|
+
].every(Boolean);
|
|
1301
|
+
const sound = distinct(
|
|
1302
|
+
contributor,
|
|
1303
|
+
beneficiary,
|
|
1304
|
+
`uses ${contributor} as both contributor and beneficiary; the roles must differ`,
|
|
1305
|
+
);
|
|
1306
|
+
if (target && commitment && target.currency !== commitment.currency) {
|
|
1307
|
+
error(
|
|
1308
|
+
commitment.origin,
|
|
1309
|
+
`${owner} mixes ${target.currency} and ${commitment.currency}; target and commitment need one currency`,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
if (target && commitment && target.name === commitment.name) {
|
|
1313
|
+
error(
|
|
1314
|
+
commitment.origin,
|
|
1315
|
+
`${owner} uses ${target.name} for both target and commitment; the parent and child amounts need distinct fields`,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
if (
|
|
1319
|
+
contributor &&
|
|
1320
|
+
beneficiary &&
|
|
1321
|
+
target &&
|
|
1322
|
+
commitment &&
|
|
1323
|
+
target.currency === commitment.currency &&
|
|
1324
|
+
target.name !== commitment.name &&
|
|
1325
|
+
maxContributors &&
|
|
1326
|
+
closeByField &&
|
|
1327
|
+
policiesSound &&
|
|
1328
|
+
sound
|
|
1329
|
+
) {
|
|
1330
|
+
settlements.push({
|
|
1331
|
+
archetype: "funding_round",
|
|
1332
|
+
beneficiary,
|
|
1333
|
+
cancelPolicy: "before_close",
|
|
1334
|
+
closeByField,
|
|
1335
|
+
closePolicy: "threshold",
|
|
1336
|
+
commitment,
|
|
1337
|
+
contributor,
|
|
1338
|
+
failPolicy: "whole_commitment_refund",
|
|
1339
|
+
maxContributors,
|
|
1340
|
+
name: decl.name.name,
|
|
1341
|
+
origin: decl.span,
|
|
1342
|
+
overfundPolicy: "reject",
|
|
1343
|
+
target,
|
|
1344
|
+
} satisfies CheckedFundingRound);
|
|
1345
|
+
}
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
case "weighted_distribution": {
|
|
1349
|
+
const source = party("source", "source");
|
|
1350
|
+
const recipient = party("recipient", "recipient template");
|
|
1351
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1352
|
+
const weight = parseAmount(entries.get("weight"), owner);
|
|
1353
|
+
const maxRecipients = parseLiteralCount(
|
|
1354
|
+
entries.get("max_recipients"),
|
|
1355
|
+
owner,
|
|
1356
|
+
"max_recipients",
|
|
1357
|
+
2,
|
|
1358
|
+
10_000,
|
|
1359
|
+
);
|
|
1360
|
+
const recordAtField = parseDateField(entries.get("record_at"), owner);
|
|
1361
|
+
const snapshot = parsePortEntry(
|
|
1362
|
+
entries.get("snapshot"),
|
|
1363
|
+
owner,
|
|
1364
|
+
"entitlement snapshot",
|
|
1365
|
+
);
|
|
1366
|
+
const policiesSound = [
|
|
1367
|
+
requireIdentPolicy(
|
|
1368
|
+
entries.get("rounding_policy"),
|
|
1369
|
+
owner,
|
|
1370
|
+
"largest_remainder",
|
|
1371
|
+
"floor shares use a deterministic largest-remainder allocation with noun id as the tie-break",
|
|
1372
|
+
),
|
|
1373
|
+
requireIdentPolicy(
|
|
1374
|
+
entries.get("withholding_policy"),
|
|
1375
|
+
owner,
|
|
1376
|
+
"refuse",
|
|
1377
|
+
"withholding needs its own proved retained-amount mechanism",
|
|
1378
|
+
),
|
|
1379
|
+
requireIdentPolicy(
|
|
1380
|
+
entries.get("correction_policy"),
|
|
1381
|
+
owner,
|
|
1382
|
+
"new_distribution",
|
|
1383
|
+
"post-payout correction is a new linked distribution, never a rewrite or caller amount",
|
|
1384
|
+
),
|
|
1385
|
+
].every(Boolean);
|
|
1386
|
+
const sound = distinct(
|
|
1387
|
+
source,
|
|
1388
|
+
recipient,
|
|
1389
|
+
`uses ${source} as both source and recipient; the roles must differ`,
|
|
1390
|
+
);
|
|
1391
|
+
if (amount && weight && amount.currency !== weight.currency) {
|
|
1392
|
+
error(
|
|
1393
|
+
weight.origin,
|
|
1394
|
+
`${owner} mixes ${amount.currency} and ${weight.currency}; pool and stored weights need one denomination`,
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
if (amount && weight && amount.name === weight.name) {
|
|
1398
|
+
error(
|
|
1399
|
+
weight.origin,
|
|
1400
|
+
`${owner} uses ${amount.name} for both pool and weight; the fields must differ`,
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
if (
|
|
1404
|
+
source &&
|
|
1405
|
+
recipient &&
|
|
1406
|
+
amount &&
|
|
1407
|
+
weight &&
|
|
1408
|
+
amount.currency === weight.currency &&
|
|
1409
|
+
amount.name !== weight.name &&
|
|
1410
|
+
maxRecipients &&
|
|
1411
|
+
recordAtField &&
|
|
1412
|
+
snapshot &&
|
|
1413
|
+
policiesSound &&
|
|
1414
|
+
sound
|
|
1415
|
+
) {
|
|
1416
|
+
settlements.push({
|
|
1417
|
+
amount,
|
|
1418
|
+
archetype: "weighted_distribution",
|
|
1419
|
+
correctionPolicy: "new_distribution",
|
|
1420
|
+
maxRecipients,
|
|
1421
|
+
name: decl.name.name,
|
|
1422
|
+
origin: decl.span,
|
|
1423
|
+
recipient,
|
|
1424
|
+
recordAtField,
|
|
1425
|
+
roundingPolicy: "largest_remainder",
|
|
1426
|
+
snapshot,
|
|
1427
|
+
source,
|
|
1428
|
+
weight,
|
|
1429
|
+
withholdingPolicy: "refuse",
|
|
1430
|
+
} satisfies CheckedWeightedDistribution);
|
|
1431
|
+
}
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
case "credit_facility": {
|
|
1435
|
+
const lender = party("lender", "lender");
|
|
1436
|
+
const borrower = party("borrower", "borrower");
|
|
1437
|
+
const drawDestination = party("draw_destination", "draw destination");
|
|
1438
|
+
const limit = parseAmount(entries.get("limit"), owner);
|
|
1439
|
+
const expiresAtField = parseDateField(entries.get("expires_at"), owner);
|
|
1440
|
+
const obligationEntry = entries.get("obligation");
|
|
1441
|
+
const obligation = obligationEntry
|
|
1442
|
+
? parseSettlementReference(obligationEntry, owner)
|
|
1443
|
+
: undefined;
|
|
1444
|
+
const availabilityEntry = entries.get("availability_policy");
|
|
1445
|
+
let availabilityPolicy: "revolving" | "non_revolving" | undefined;
|
|
1446
|
+
if (
|
|
1447
|
+
availabilityEntry?.value.kind === "ident" &&
|
|
1448
|
+
(availabilityEntry.value.name === "revolving" ||
|
|
1449
|
+
availabilityEntry.value.name === "non_revolving")
|
|
1450
|
+
) {
|
|
1451
|
+
availabilityPolicy = availabilityEntry.value.name;
|
|
1452
|
+
} else if (availabilityEntry) {
|
|
1453
|
+
error(
|
|
1454
|
+
availabilityEntry.value.span,
|
|
1455
|
+
`availability_policy on ${owner} is revolving or non_revolving; the factpack does not justify an implicit choice`,
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
const policiesSound = [
|
|
1459
|
+
requireIdentPolicy(
|
|
1460
|
+
entries.get("expiry_policy"),
|
|
1461
|
+
owner,
|
|
1462
|
+
"freeze_draws",
|
|
1463
|
+
"expiry stops new draws without closing linked debt",
|
|
1464
|
+
),
|
|
1465
|
+
requireIdentPolicy(
|
|
1466
|
+
entries.get("close_policy"),
|
|
1467
|
+
owner,
|
|
1468
|
+
"no_open_draws",
|
|
1469
|
+
"the facility closes only after every linked obligation resolves",
|
|
1470
|
+
),
|
|
1471
|
+
].every(Boolean);
|
|
1472
|
+
const rolesSound = [
|
|
1473
|
+
distinct(
|
|
1474
|
+
lender,
|
|
1475
|
+
borrower,
|
|
1476
|
+
`uses ${lender} as both lender and borrower; the roles must differ`,
|
|
1477
|
+
),
|
|
1478
|
+
distinct(
|
|
1479
|
+
lender,
|
|
1480
|
+
drawDestination,
|
|
1481
|
+
`draws back to its lender ${lender}; draw_destination must differ`,
|
|
1482
|
+
),
|
|
1483
|
+
].every(Boolean);
|
|
1484
|
+
if (
|
|
1485
|
+
lender &&
|
|
1486
|
+
borrower &&
|
|
1487
|
+
drawDestination &&
|
|
1488
|
+
limit &&
|
|
1489
|
+
expiresAtField &&
|
|
1490
|
+
obligation &&
|
|
1491
|
+
availabilityPolicy &&
|
|
1492
|
+
policiesSound &&
|
|
1493
|
+
rolesSound
|
|
1494
|
+
) {
|
|
1495
|
+
settlements.push({
|
|
1496
|
+
archetype: "credit_facility",
|
|
1497
|
+
availabilityPolicy,
|
|
1498
|
+
borrower,
|
|
1499
|
+
closePolicy: "no_open_draws",
|
|
1500
|
+
drawDestination,
|
|
1501
|
+
expiresAtField,
|
|
1502
|
+
expiryPolicy: "freeze_draws",
|
|
1503
|
+
lender,
|
|
1504
|
+
limit,
|
|
1505
|
+
name: decl.name.name,
|
|
1506
|
+
obligation,
|
|
1507
|
+
origin: decl.span,
|
|
1508
|
+
} satisfies CheckedCreditFacility);
|
|
1509
|
+
}
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
case "recurring_collection": {
|
|
1513
|
+
const obligationEntry = entries.get("obligation");
|
|
1514
|
+
const obligation = obligationEntry
|
|
1515
|
+
? parseSettlementReference(obligationEntry, owner)
|
|
1516
|
+
: undefined;
|
|
1517
|
+
const mandate = parsePortEntry(
|
|
1518
|
+
entries.get("mandate"),
|
|
1519
|
+
owner,
|
|
1520
|
+
"mandate evidence",
|
|
1521
|
+
);
|
|
1522
|
+
const policiesSound = [
|
|
1523
|
+
requireIdentPolicy(
|
|
1524
|
+
entries.get("attempt_policy"),
|
|
1525
|
+
owner,
|
|
1526
|
+
"explicit",
|
|
1527
|
+
"every attempt is a separate receipted call",
|
|
1528
|
+
),
|
|
1529
|
+
requireIdentPolicy(
|
|
1530
|
+
entries.get("period_idempotency"),
|
|
1531
|
+
owner,
|
|
1532
|
+
"obligation_and_anchor",
|
|
1533
|
+
"the parent obligation and stored anchor form the period identity",
|
|
1534
|
+
),
|
|
1535
|
+
requireIdentPolicy(
|
|
1536
|
+
entries.get("retry_policy"),
|
|
1537
|
+
owner,
|
|
1538
|
+
"explicit_attempt",
|
|
1539
|
+
"PRINCIPLES.md bans hidden retries; a retry needs a new explicit idempotency key",
|
|
1540
|
+
),
|
|
1541
|
+
requireIdentPolicy(
|
|
1542
|
+
entries.get("failure_policy"),
|
|
1543
|
+
owner,
|
|
1544
|
+
"parent_delinquency",
|
|
1545
|
+
"the scheduled obligation owns due state and delinquency",
|
|
1546
|
+
),
|
|
1547
|
+
].every(Boolean);
|
|
1548
|
+
if (obligation && mandate && policiesSound) {
|
|
1549
|
+
settlements.push({
|
|
1550
|
+
archetype: "recurring_collection",
|
|
1551
|
+
attemptPolicy: "explicit",
|
|
1552
|
+
failurePolicy: "parent_delinquency",
|
|
1553
|
+
mandate,
|
|
1554
|
+
name: decl.name.name,
|
|
1555
|
+
obligation,
|
|
1556
|
+
origin: decl.span,
|
|
1557
|
+
periodIdempotency: "obligation_and_anchor",
|
|
1558
|
+
retryPolicy: "explicit_attempt",
|
|
1559
|
+
} satisfies CheckedRecurringCollection);
|
|
1560
|
+
}
|
|
1561
|
+
break;
|
|
1562
|
+
}
|
|
1563
|
+
case "conditional_disbursement": {
|
|
1564
|
+
const source = party("source", "source");
|
|
1565
|
+
const destination = party("destination", "destination");
|
|
1566
|
+
const cap = parseAmount(entries.get("cap"), owner);
|
|
1567
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1568
|
+
const decision = parsePortEntry(
|
|
1569
|
+
entries.get("decision"),
|
|
1570
|
+
owner,
|
|
1571
|
+
"capped disbursement decision",
|
|
1572
|
+
);
|
|
1573
|
+
const policiesSound = [
|
|
1574
|
+
requireIdentPolicy(
|
|
1575
|
+
entries.get("reopen_policy"),
|
|
1576
|
+
owner,
|
|
1577
|
+
"refuse",
|
|
1578
|
+
"a closed decision cannot be reopened; create a new linked disbursement",
|
|
1579
|
+
),
|
|
1580
|
+
requireIdentPolicy(
|
|
1581
|
+
entries.get("recovery_policy"),
|
|
1582
|
+
owner,
|
|
1583
|
+
"separate_transfer",
|
|
1584
|
+
"recovery is a new money movement with its own receipt",
|
|
1585
|
+
),
|
|
1586
|
+
].every(Boolean);
|
|
1587
|
+
const sound = distinct(
|
|
1588
|
+
source,
|
|
1589
|
+
destination,
|
|
1590
|
+
`pays ${source} from itself; source and destination must differ`,
|
|
1591
|
+
);
|
|
1592
|
+
if (cap && amount && cap.currency !== amount.currency) {
|
|
1593
|
+
error(
|
|
1594
|
+
amount.origin,
|
|
1595
|
+
`${owner} mixes ${cap.currency} and ${amount.currency}; cap and approved amount need one currency`,
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
if (cap && amount && cap.name === amount.name) {
|
|
1599
|
+
error(
|
|
1600
|
+
amount.origin,
|
|
1601
|
+
`${owner} uses ${cap.name} for both cap and approved amount; the fields must differ`,
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
if (
|
|
1605
|
+
source &&
|
|
1606
|
+
destination &&
|
|
1607
|
+
cap &&
|
|
1608
|
+
amount &&
|
|
1609
|
+
cap.currency === amount.currency &&
|
|
1610
|
+
cap.name !== amount.name &&
|
|
1611
|
+
decision &&
|
|
1612
|
+
policiesSound &&
|
|
1613
|
+
sound
|
|
1614
|
+
) {
|
|
1615
|
+
settlements.push({
|
|
1616
|
+
amount,
|
|
1617
|
+
archetype: "conditional_disbursement",
|
|
1618
|
+
cap,
|
|
1619
|
+
decision,
|
|
1620
|
+
destination,
|
|
1621
|
+
name: decl.name.name,
|
|
1622
|
+
origin: decl.span,
|
|
1623
|
+
recoveryPolicy: "separate_transfer",
|
|
1624
|
+
reopenPolicy: "refuse",
|
|
1625
|
+
source,
|
|
1626
|
+
} satisfies CheckedConditionalDisbursement);
|
|
1627
|
+
}
|
|
1628
|
+
break;
|
|
1629
|
+
}
|
|
1630
|
+
case "rotating_pool": {
|
|
1631
|
+
const members = parsePartyList(
|
|
1632
|
+
entries.get("members"),
|
|
1633
|
+
owner,
|
|
1634
|
+
"members",
|
|
1635
|
+
2,
|
|
1636
|
+
5,
|
|
1637
|
+
);
|
|
1638
|
+
const payoutOrder = parsePartyList(
|
|
1639
|
+
entries.get("payout_order"),
|
|
1640
|
+
owner,
|
|
1641
|
+
"payout_order",
|
|
1642
|
+
2,
|
|
1643
|
+
5,
|
|
1644
|
+
);
|
|
1645
|
+
const contribution = parseAmount(entries.get("contribution"), owner);
|
|
1646
|
+
const schedule = parseSchedule(entries, owner, decl.span, 12);
|
|
1647
|
+
const guarantor = entries.has("guarantor")
|
|
1648
|
+
? party("guarantor", "guarantor")
|
|
1649
|
+
: undefined;
|
|
1650
|
+
const policiesSound = [
|
|
1651
|
+
requireIdentPolicy(
|
|
1652
|
+
entries.get("default_policy"),
|
|
1653
|
+
owner,
|
|
1654
|
+
"due_condition",
|
|
1655
|
+
"default follows an unmet stored contribution due condition",
|
|
1656
|
+
),
|
|
1657
|
+
requireIdentPolicy(
|
|
1658
|
+
entries.get("guarantee_policy"),
|
|
1659
|
+
owner,
|
|
1660
|
+
"funded_only",
|
|
1661
|
+
"a guarantee changes money only through an explicit funded contribution",
|
|
1662
|
+
),
|
|
1663
|
+
requireIdentPolicy(
|
|
1664
|
+
entries.get("exit_policy"),
|
|
1665
|
+
owner,
|
|
1666
|
+
"before_activation_only",
|
|
1667
|
+
"membership and order freeze before the first contribution",
|
|
1668
|
+
),
|
|
1669
|
+
].every(Boolean);
|
|
1670
|
+
let rosterSound = true;
|
|
1671
|
+
if (members && payoutOrder) {
|
|
1672
|
+
const memberSet = new Set(members);
|
|
1673
|
+
if (
|
|
1674
|
+
payoutOrder.length !== members.length ||
|
|
1675
|
+
payoutOrder.some((name) => !memberSet.has(name))
|
|
1676
|
+
) {
|
|
1677
|
+
error(
|
|
1678
|
+
entries.get("payout_order")?.span ?? decl.span,
|
|
1679
|
+
`${owner} payout_order must contain every member exactly once`,
|
|
1680
|
+
);
|
|
1681
|
+
rosterSound = false;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
if (members && schedule && schedule.count !== members.length) {
|
|
1685
|
+
error(
|
|
1686
|
+
entries.get("count")?.span ?? decl.span,
|
|
1687
|
+
`${owner} count ${schedule.count} must equal its ${members.length}-member roster`,
|
|
1688
|
+
);
|
|
1689
|
+
rosterSound = false;
|
|
1690
|
+
}
|
|
1691
|
+
if (members && guarantor && members.includes(guarantor)) {
|
|
1692
|
+
error(
|
|
1693
|
+
entries.get("guarantor")?.span ?? decl.span,
|
|
1694
|
+
`${owner} guarantor must differ from every member`,
|
|
1695
|
+
);
|
|
1696
|
+
rosterSound = false;
|
|
1697
|
+
}
|
|
1698
|
+
if (members && guarantor && members.length > 4) {
|
|
1699
|
+
error(
|
|
1700
|
+
entries.get("members")?.span ?? decl.span,
|
|
1701
|
+
`${owner} supports at most 4 members with a guarantor so every generated child binding stays within the 8-entry cap`,
|
|
1702
|
+
);
|
|
1703
|
+
rosterSound = false;
|
|
1704
|
+
}
|
|
1705
|
+
if (
|
|
1706
|
+
members &&
|
|
1707
|
+
payoutOrder &&
|
|
1708
|
+
contribution &&
|
|
1709
|
+
schedule &&
|
|
1710
|
+
policiesSound &&
|
|
1711
|
+
rosterSound
|
|
1712
|
+
) {
|
|
1713
|
+
settlements.push({
|
|
1714
|
+
archetype: "rotating_pool",
|
|
1715
|
+
contribution,
|
|
1716
|
+
defaultPolicy: "due_condition",
|
|
1717
|
+
exitPolicy: "before_activation_only",
|
|
1718
|
+
guaranteePolicy: "funded_only",
|
|
1719
|
+
...(guarantor ? { guarantor } : {}),
|
|
1720
|
+
members,
|
|
1721
|
+
name: decl.name.name,
|
|
1722
|
+
origin: decl.span,
|
|
1723
|
+
payoutOrder,
|
|
1724
|
+
schedule,
|
|
1725
|
+
} satisfies CheckedRotatingPool);
|
|
1726
|
+
}
|
|
1727
|
+
break;
|
|
1728
|
+
}
|
|
917
1729
|
case "swap": {
|
|
918
1730
|
const betweenEntry = entries.get("between");
|
|
919
1731
|
let sideA: string | undefined;
|
|
@@ -1151,18 +1963,91 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1151
1963
|
[payer, carrier].filter((name): name is string => name !== undefined),
|
|
1152
1964
|
"this settlement's payer or carrier",
|
|
1153
1965
|
);
|
|
1154
|
-
|
|
1155
|
-
|
|
1966
|
+
const extensionKeys = [
|
|
1967
|
+
"policy_ref",
|
|
1968
|
+
"renewal_due",
|
|
1969
|
+
"renewal_policy",
|
|
1970
|
+
"endorsement",
|
|
1971
|
+
"endorsement_policy",
|
|
1972
|
+
"lapse_policy",
|
|
1973
|
+
];
|
|
1974
|
+
const extendsPolicy = extensionKeys.some((key) => entries.has(key));
|
|
1975
|
+
if (extendsPolicy) {
|
|
1976
|
+
for (const key of extensionKeys) {
|
|
1977
|
+
if (!entries.has(key))
|
|
1978
|
+
error(decl.span, `${owner} is missing ${key}`);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
const policyReferenceField = extendsPolicy
|
|
1982
|
+
? parseFieldName(entries.get("policy_ref"), owner)
|
|
1983
|
+
: undefined;
|
|
1984
|
+
const renewalDueField = extendsPolicy
|
|
1985
|
+
? parseDateField(entries.get("renewal_due"), owner)
|
|
1986
|
+
: undefined;
|
|
1987
|
+
const endorsement = extendsPolicy
|
|
1988
|
+
? parsePortEntry(
|
|
1989
|
+
entries.get("endorsement"),
|
|
1990
|
+
owner,
|
|
1991
|
+
"endorsement evidence",
|
|
1992
|
+
)
|
|
1993
|
+
: undefined;
|
|
1994
|
+
const extensionPoliciesSound = !extendsPolicy
|
|
1995
|
+
? true
|
|
1996
|
+
: [
|
|
1997
|
+
requireIdentPolicy(
|
|
1998
|
+
entries.get("renewal_policy"),
|
|
1999
|
+
owner,
|
|
2000
|
+
"explicit_new_forward",
|
|
2001
|
+
"each renewal creates a new premium_forward instead of moving money from policy state",
|
|
2002
|
+
),
|
|
2003
|
+
requireIdentPolicy(
|
|
2004
|
+
entries.get("endorsement_policy"),
|
|
2005
|
+
owner,
|
|
2006
|
+
"non_money_only",
|
|
2007
|
+
"a money adjustment needs a separate corrected movement",
|
|
2008
|
+
),
|
|
2009
|
+
requireIdentPolicy(
|
|
2010
|
+
entries.get("lapse_policy"),
|
|
2011
|
+
owner,
|
|
2012
|
+
"due_condition",
|
|
2013
|
+
"lapse follows the stored renewal due condition",
|
|
2014
|
+
),
|
|
2015
|
+
].every(Boolean);
|
|
2016
|
+
if (
|
|
2017
|
+
payer &&
|
|
2018
|
+
carrier &&
|
|
2019
|
+
amount &&
|
|
2020
|
+
bind &&
|
|
2021
|
+
sound &&
|
|
2022
|
+
extensionPoliciesSound &&
|
|
2023
|
+
(!extendsPolicy ||
|
|
2024
|
+
(policyReferenceField && renewalDueField && endorsement))
|
|
2025
|
+
) {
|
|
2026
|
+
const checked: CheckedPremiumForward = {
|
|
1156
2027
|
amount,
|
|
1157
2028
|
archetype: "premium_forward",
|
|
1158
2029
|
bind,
|
|
1159
2030
|
carrier,
|
|
1160
2031
|
commissionBps,
|
|
2032
|
+
...(endorsement ? { endorsement } : {}),
|
|
2033
|
+
...(extendsPolicy &&
|
|
2034
|
+
policyReferenceField &&
|
|
2035
|
+
renewalDueField &&
|
|
2036
|
+
endorsement
|
|
2037
|
+
? {
|
|
2038
|
+
endorsementPolicy: "non_money_only" as const,
|
|
2039
|
+
lapsePolicy: "due_condition" as const,
|
|
2040
|
+
policyReferenceField,
|
|
2041
|
+
renewalDueField,
|
|
2042
|
+
renewalPolicy: "explicit_new_forward" as const,
|
|
2043
|
+
}
|
|
2044
|
+
: {}),
|
|
1161
2045
|
name: decl.name.name,
|
|
1162
2046
|
...(onCancel ? { onCancel } : {}),
|
|
1163
2047
|
origin: decl.span,
|
|
1164
2048
|
payer,
|
|
1165
|
-
}
|
|
2049
|
+
};
|
|
2050
|
+
settlements.push(checked);
|
|
1166
2051
|
}
|
|
1167
2052
|
break;
|
|
1168
2053
|
}
|
|
@@ -1211,7 +2096,16 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1211
2096
|
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
1212
2097
|
);
|
|
1213
2098
|
const amount = parseAmount(entries.get("amount"), owner);
|
|
1214
|
-
const
|
|
2099
|
+
const requestedMode = entries.get("mode")?.value;
|
|
2100
|
+
const obligationModeRequested =
|
|
2101
|
+
requestedMode?.kind === "ident" &&
|
|
2102
|
+
requestedMode.name === "obligation";
|
|
2103
|
+
const schedule = parseSchedule(
|
|
2104
|
+
entries,
|
|
2105
|
+
owner,
|
|
2106
|
+
decl.span,
|
|
2107
|
+
obligationModeRequested ? 7 : 12,
|
|
2108
|
+
);
|
|
1215
2109
|
if (amount && schedule && amount.name === schedule.firstDueField) {
|
|
1216
2110
|
error(
|
|
1217
2111
|
schedule.origin,
|
|
@@ -1219,10 +2113,126 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1219
2113
|
);
|
|
1220
2114
|
break;
|
|
1221
2115
|
}
|
|
1222
|
-
|
|
2116
|
+
const modeEntry = entries.get("mode");
|
|
2117
|
+
const obligationKeys = [
|
|
2118
|
+
"advance_to",
|
|
2119
|
+
"debtor",
|
|
2120
|
+
"delinquency_policy",
|
|
2121
|
+
"partial_payment",
|
|
2122
|
+
"refund_policy",
|
|
2123
|
+
"repayment_matching",
|
|
2124
|
+
"reschedule_policy",
|
|
2125
|
+
];
|
|
2126
|
+
const hasObligationKey = obligationKeys.some((key) => entries.has(key));
|
|
2127
|
+
if (!modeEntry && hasObligationKey) {
|
|
2128
|
+
error(
|
|
2129
|
+
decl.span,
|
|
2130
|
+
`${owner} uses obligation policies without mode: obligation`,
|
|
2131
|
+
);
|
|
2132
|
+
break;
|
|
2133
|
+
}
|
|
2134
|
+
if (!modeEntry) {
|
|
2135
|
+
if (payer && payee && amount && schedule && sound) {
|
|
2136
|
+
settlements.push({
|
|
2137
|
+
amount,
|
|
2138
|
+
archetype: "scheduled",
|
|
2139
|
+
mode: "transfer",
|
|
2140
|
+
name: decl.name.name,
|
|
2141
|
+
origin: decl.span,
|
|
2142
|
+
payee,
|
|
2143
|
+
payer,
|
|
2144
|
+
schedule,
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
break;
|
|
2148
|
+
}
|
|
2149
|
+
if (
|
|
2150
|
+
!requireIdentPolicy(
|
|
2151
|
+
modeEntry,
|
|
2152
|
+
owner,
|
|
2153
|
+
"obligation",
|
|
2154
|
+
"omit mode for a fixed transfer schedule",
|
|
2155
|
+
)
|
|
2156
|
+
) {
|
|
2157
|
+
break;
|
|
2158
|
+
}
|
|
2159
|
+
const requiredPolicies = [
|
|
2160
|
+
"debtor",
|
|
2161
|
+
"partial_payment",
|
|
2162
|
+
"repayment_matching",
|
|
2163
|
+
"refund_policy",
|
|
2164
|
+
"reschedule_policy",
|
|
2165
|
+
"delinquency_policy",
|
|
2166
|
+
];
|
|
2167
|
+
for (const key of requiredPolicies) {
|
|
2168
|
+
if (!entries.has(key)) error(decl.span, `${owner} is missing ${key}`);
|
|
2169
|
+
}
|
|
2170
|
+
const debtor = party("debtor", "debtor");
|
|
2171
|
+
const advanceTo = entries.has("advance_to")
|
|
2172
|
+
? party("advance_to", "advance recipient")
|
|
2173
|
+
: undefined;
|
|
2174
|
+
const policiesSound = [
|
|
2175
|
+
requireIdentPolicy(
|
|
2176
|
+
entries.get("partial_payment"),
|
|
2177
|
+
owner,
|
|
2178
|
+
"anchor_bound",
|
|
2179
|
+
"payments must bind to one stored installment anchor",
|
|
2180
|
+
),
|
|
2181
|
+
requireIdentPolicy(
|
|
2182
|
+
entries.get("repayment_matching"),
|
|
2183
|
+
owner,
|
|
2184
|
+
"obligation_and_anchor",
|
|
2185
|
+
"each payment must name both the obligation and its installment anchor; fuzzy or balance-wide matching is refused",
|
|
2186
|
+
),
|
|
2187
|
+
requireIdentPolicy(
|
|
2188
|
+
entries.get("refund_policy"),
|
|
2189
|
+
owner,
|
|
2190
|
+
"full_payment_only",
|
|
2191
|
+
"a refund reverses one stored payment whole so it cannot exceed that payment",
|
|
2192
|
+
),
|
|
2193
|
+
requireIdentPolicy(
|
|
2194
|
+
entries.get("reschedule_policy"),
|
|
2195
|
+
owner,
|
|
2196
|
+
"refuse",
|
|
2197
|
+
"forward-version carryover is not mechanically proven; create a new obligation after closing this one",
|
|
2198
|
+
),
|
|
2199
|
+
requireIdentPolicy(
|
|
2200
|
+
entries.get("delinquency_policy"),
|
|
2201
|
+
owner,
|
|
2202
|
+
"due_condition",
|
|
2203
|
+
"delinquency must come from an unmet stored due anchor",
|
|
2204
|
+
),
|
|
2205
|
+
].every(Boolean);
|
|
2206
|
+
const rolesSound = [
|
|
2207
|
+
distinct(
|
|
2208
|
+
debtor,
|
|
2209
|
+
payee,
|
|
2210
|
+
`${owner} names ${debtor} as both debtor and settlement recipient; they must differ`,
|
|
2211
|
+
),
|
|
2212
|
+
advanceTo
|
|
2213
|
+
? distinct(
|
|
2214
|
+
advanceTo,
|
|
2215
|
+
debtor,
|
|
2216
|
+
`${owner} advances to its debtor ${debtor}; advance_to must name a distinct recipient`,
|
|
2217
|
+
)
|
|
2218
|
+
: true,
|
|
2219
|
+
].every(Boolean);
|
|
2220
|
+
if (
|
|
2221
|
+
payer &&
|
|
2222
|
+
payee &&
|
|
2223
|
+
amount &&
|
|
2224
|
+
schedule &&
|
|
2225
|
+
sound &&
|
|
2226
|
+
debtor &&
|
|
2227
|
+
policiesSound &&
|
|
2228
|
+
rolesSound
|
|
2229
|
+
) {
|
|
1223
2230
|
settlements.push({
|
|
2231
|
+
...(advanceTo ? { advanceTo } : {}),
|
|
1224
2232
|
amount,
|
|
1225
2233
|
archetype: "scheduled",
|
|
2234
|
+
debtor,
|
|
2235
|
+
mode: "obligation",
|
|
1226
2236
|
name: decl.name.name,
|
|
1227
2237
|
origin: decl.span,
|
|
1228
2238
|
payee,
|
|
@@ -1279,6 +2289,7 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1279
2289
|
const closeByField = parseDateField(entries.get("close_by"), owner);
|
|
1280
2290
|
const rates: MeterRate[] = [];
|
|
1281
2291
|
const ratesEntry = entries.get("rates");
|
|
2292
|
+
let withinEventCap = true;
|
|
1282
2293
|
if (ratesEntry) {
|
|
1283
2294
|
noQualifiers(ratesEntry, owner);
|
|
1284
2295
|
if (ratesEntry.value.kind !== "block") {
|
|
@@ -1287,6 +2298,16 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1287
2298
|
`rates is a block of per-unit prices, like: rates { api_call: unitFee: money(SAR) }`,
|
|
1288
2299
|
);
|
|
1289
2300
|
} else {
|
|
2301
|
+
const maxRates =
|
|
2302
|
+
ARCHETYPE_DEFINITIONS.metered.maxRates ??
|
|
2303
|
+
ARCHETYPE_DEFINITIONS.metered.eventCap;
|
|
2304
|
+
if (ratesEntry.value.entries.length > maxRates) {
|
|
2305
|
+
error(
|
|
2306
|
+
ratesEntry.value.span,
|
|
2307
|
+
`${owner} prices ${ratesEntry.value.entries.length} meters, but metered allows at most ${maxRates} so one settlement stays within its ${ARCHETYPE_DEFINITIONS.metered.eventCap}-event cap`,
|
|
2308
|
+
);
|
|
2309
|
+
withinEventCap = false;
|
|
2310
|
+
}
|
|
1290
2311
|
for (const rate of ratesEntry.value.entries) {
|
|
1291
2312
|
if (!SNAKE_CASE.test(rate.key.name)) {
|
|
1292
2313
|
error(
|
|
@@ -1338,7 +2359,14 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1338
2359
|
);
|
|
1339
2360
|
break;
|
|
1340
2361
|
}
|
|
1341
|
-
if (
|
|
2362
|
+
if (
|
|
2363
|
+
payer &&
|
|
2364
|
+
payee &&
|
|
2365
|
+
closeByField &&
|
|
2366
|
+
rates.length > 0 &&
|
|
2367
|
+
sound &&
|
|
2368
|
+
withinEventCap
|
|
2369
|
+
) {
|
|
1342
2370
|
settlements.push({
|
|
1343
2371
|
archetype: "metered",
|
|
1344
2372
|
closeByField,
|
|
@@ -1376,6 +2404,16 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1376
2404
|
);
|
|
1377
2405
|
break;
|
|
1378
2406
|
}
|
|
2407
|
+
const maxRecipients =
|
|
2408
|
+
ARCHETYPE_DEFINITIONS.pooled_split.maxRecipients ??
|
|
2409
|
+
Math.floor(ARCHETYPE_DEFINITIONS.pooled_split.eventCap / 2);
|
|
2410
|
+
const withinEventCap = !split || split.shares.length <= maxRecipients;
|
|
2411
|
+
if (!withinEventCap) {
|
|
2412
|
+
error(
|
|
2413
|
+
splitEntry!.value.span,
|
|
2414
|
+
`${owner} splits to ${split!.shares.length} recipients, but pooled_split allows at most ${maxRecipients} so funding and payout stay within its ${ARCHETYPE_DEFINITIONS.pooled_split.eventCap}-event cap`,
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
1379
2417
|
// The lowering names each share field ${camelCase(party)}ShareAmount;
|
|
1380
2418
|
// camelCasing is not injective (a_2b and a2b collide), so two shares
|
|
1381
2419
|
// must never map onto one generated field.
|
|
@@ -1410,7 +2448,7 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1410
2448
|
);
|
|
1411
2449
|
break;
|
|
1412
2450
|
}
|
|
1413
|
-
if (payer && amount && distributeDueField && split) {
|
|
2451
|
+
if (payer && amount && distributeDueField && split && withinEventCap) {
|
|
1414
2452
|
settlements.push({
|
|
1415
2453
|
amount,
|
|
1416
2454
|
archetype: "pooled_split",
|
|
@@ -1522,6 +2560,120 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1522
2560
|
|
|
1523
2561
|
const checkedPortByName = new Map(ports.map((port) => [port.name, port]));
|
|
1524
2562
|
for (const settlement of settlements) {
|
|
2563
|
+
if (settlement.archetype === "captured_payment") {
|
|
2564
|
+
const correctionPort = checkedPortByName.get(settlement.correction.port);
|
|
2565
|
+
if (
|
|
2566
|
+
correctionPort &&
|
|
2567
|
+
(correctionPort.allowed.length !== 1 ||
|
|
2568
|
+
correctionPort.allowed[0] !== settlement.payee)
|
|
2569
|
+
) {
|
|
2570
|
+
error(
|
|
2571
|
+
correctionPort.origin,
|
|
2572
|
+
`captured_payment ${settlement.name} correction port ${correctionPort.name} must allow only its payee ${settlement.payee}`,
|
|
2573
|
+
);
|
|
2574
|
+
}
|
|
2575
|
+
const reversalPort = checkedPortByName.get(
|
|
2576
|
+
settlement.externalReversal.port,
|
|
2577
|
+
);
|
|
2578
|
+
if (
|
|
2579
|
+
reversalPort &&
|
|
2580
|
+
!reversalPort.fields.some(
|
|
2581
|
+
(field) =>
|
|
2582
|
+
field.name === "externalReference" && field.type.kind === "text",
|
|
2583
|
+
)
|
|
2584
|
+
) {
|
|
2585
|
+
error(
|
|
2586
|
+
reversalPort.origin,
|
|
2587
|
+
`captured_payment ${settlement.name} external reversal port ${reversalPort.name} needs shape { externalReference: text }`,
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
if (settlement.archetype === "settlement_batch") {
|
|
2593
|
+
const acknowledgementPort = checkedPortByName.get(
|
|
2594
|
+
settlement.payoutAcknowledgement.port,
|
|
2595
|
+
);
|
|
2596
|
+
if (
|
|
2597
|
+
acknowledgementPort &&
|
|
2598
|
+
!acknowledgementPort.fields.some(
|
|
2599
|
+
(field) =>
|
|
2600
|
+
field.name === "acknowledgementReference" &&
|
|
2601
|
+
field.type.kind === "text",
|
|
2602
|
+
)
|
|
2603
|
+
) {
|
|
2604
|
+
error(
|
|
2605
|
+
acknowledgementPort.origin,
|
|
2606
|
+
`settlement_batch ${settlement.name} payout acknowledgement port ${acknowledgementPort.name} needs shape { acknowledgementReference: text }`,
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
if (settlement.archetype === "weighted_distribution") {
|
|
2612
|
+
const port = checkedPortByName.get(settlement.snapshot.port);
|
|
2613
|
+
if (
|
|
2614
|
+
port &&
|
|
2615
|
+
!port.fields.some(
|
|
2616
|
+
(field) =>
|
|
2617
|
+
field.name === "evidenceReference" && field.type.kind === "text",
|
|
2618
|
+
)
|
|
2619
|
+
) {
|
|
2620
|
+
error(
|
|
2621
|
+
port.origin,
|
|
2622
|
+
`weighted_distribution ${settlement.name} snapshot port ${port.name} needs shape { evidenceReference: text }`,
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
continue;
|
|
2626
|
+
}
|
|
2627
|
+
if (settlement.archetype === "recurring_collection") {
|
|
2628
|
+
const port = checkedPortByName.get(settlement.mandate.port);
|
|
2629
|
+
if (
|
|
2630
|
+
port &&
|
|
2631
|
+
!port.fields.some(
|
|
2632
|
+
(field) =>
|
|
2633
|
+
field.name === "evidenceReference" && field.type.kind === "text",
|
|
2634
|
+
)
|
|
2635
|
+
) {
|
|
2636
|
+
error(
|
|
2637
|
+
port.origin,
|
|
2638
|
+
`recurring_collection ${settlement.name} mandate port ${port.name} needs shape { evidenceReference: text }`,
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
if (settlement.archetype === "conditional_disbursement") {
|
|
2644
|
+
const port = checkedPortByName.get(settlement.decision.port);
|
|
2645
|
+
if (
|
|
2646
|
+
port &&
|
|
2647
|
+
(!port.fields.some(
|
|
2648
|
+
(field) =>
|
|
2649
|
+
field.name === "evidenceReference" && field.type.kind === "text",
|
|
2650
|
+
) ||
|
|
2651
|
+
port.allowed.length !== 1 ||
|
|
2652
|
+
port.allowed[0] !== settlement.source)
|
|
2653
|
+
) {
|
|
2654
|
+
error(
|
|
2655
|
+
port.origin,
|
|
2656
|
+
`conditional_disbursement ${settlement.name} decision port ${port.name} must allow only ${settlement.source} and needs shape { evidenceReference: text }`,
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
if (settlement.archetype === "premium_forward" && settlement.endorsement) {
|
|
2662
|
+
const port = checkedPortByName.get(settlement.endorsement.port);
|
|
2663
|
+
if (
|
|
2664
|
+
port &&
|
|
2665
|
+
!port.fields.some(
|
|
2666
|
+
(field) =>
|
|
2667
|
+
field.name === "evidenceReference" && field.type.kind === "text",
|
|
2668
|
+
)
|
|
2669
|
+
) {
|
|
2670
|
+
error(
|
|
2671
|
+
port.origin,
|
|
2672
|
+
`premium_forward ${settlement.name} endorsement port ${port.name} needs shape { evidenceReference: text }`,
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
continue;
|
|
2676
|
+
}
|
|
1525
2677
|
if (settlement.archetype !== "swap") continue;
|
|
1526
2678
|
const parties = new Set(settlement.sides.map((side) => side.party));
|
|
1527
2679
|
const decisions = [
|
|
@@ -1544,11 +2696,76 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1544
2696
|
}
|
|
1545
2697
|
}
|
|
1546
2698
|
|
|
2699
|
+
const scheduledObligations = new Map(
|
|
2700
|
+
settlements
|
|
2701
|
+
.filter(
|
|
2702
|
+
(settlement) =>
|
|
2703
|
+
settlement.archetype === "scheduled" &&
|
|
2704
|
+
settlement.mode === "obligation",
|
|
2705
|
+
)
|
|
2706
|
+
.map((settlement) => [settlement.name, settlement]),
|
|
2707
|
+
);
|
|
2708
|
+
const collectionOwnerByObligation = new Map<string, string>();
|
|
2709
|
+
for (const settlement of settlements) {
|
|
2710
|
+
if (
|
|
2711
|
+
settlement.archetype !== "credit_facility" &&
|
|
2712
|
+
settlement.archetype !== "recurring_collection"
|
|
2713
|
+
) {
|
|
2714
|
+
continue;
|
|
2715
|
+
}
|
|
2716
|
+
const target = scheduledObligations.get(settlement.obligation.settlement);
|
|
2717
|
+
const owner = `settlement ${settlement.name}`;
|
|
2718
|
+
if (!target) {
|
|
2719
|
+
error(
|
|
2720
|
+
settlement.obligation.origin,
|
|
2721
|
+
`${owner} must compose with scheduled in mode: obligation; ${settlement.obligation.settlement} is not one`,
|
|
2722
|
+
);
|
|
2723
|
+
continue;
|
|
2724
|
+
}
|
|
2725
|
+
if (settlement.archetype === "credit_facility") {
|
|
2726
|
+
if (target.advanceTo) {
|
|
2727
|
+
error(
|
|
2728
|
+
settlement.obligation.origin,
|
|
2729
|
+
`${owner} references ${target.name}, which already owns an advance; facility draws and obligation repayment must have one disbursement owner`,
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
if (
|
|
2733
|
+
target.payee !== settlement.lender ||
|
|
2734
|
+
target.debtor !== settlement.borrower ||
|
|
2735
|
+
target.amount.currency !== settlement.limit.currency
|
|
2736
|
+
) {
|
|
2737
|
+
error(
|
|
2738
|
+
settlement.obligation.origin,
|
|
2739
|
+
`${owner} must link an obligation whose payee is ${settlement.lender}, debtor is ${settlement.borrower}, and currency is ${settlement.limit.currency}`,
|
|
2740
|
+
);
|
|
2741
|
+
}
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
const mandatePort = checkedPortByName.get(settlement.mandate.port);
|
|
2745
|
+
const firstCollection = collectionOwnerByObligation.get(target.name);
|
|
2746
|
+
if (firstCollection) {
|
|
2747
|
+
error(
|
|
2748
|
+
settlement.obligation.origin,
|
|
2749
|
+
`${owner} and settlement ${firstCollection} both collect ${target.name}; one obligation may have one collection policy owner`,
|
|
2750
|
+
);
|
|
2751
|
+
} else {
|
|
2752
|
+
collectionOwnerByObligation.set(target.name, settlement.name);
|
|
2753
|
+
}
|
|
2754
|
+
if (
|
|
2755
|
+
mandatePort &&
|
|
2756
|
+
(mandatePort.allowed.length !== 1 ||
|
|
2757
|
+
mandatePort.allowed[0] !== target.payer)
|
|
2758
|
+
) {
|
|
2759
|
+
error(
|
|
2760
|
+
mandatePort.origin,
|
|
2761
|
+
`${owner} mandate port ${mandatePort.name} must allow only the obligation payer ${target.payer}`,
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
1547
2766
|
// --- Carved advances: the hold each one draws against -----------------------
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
1550
|
-
// another's. Everything here resolves at check time; nothing is left for a
|
|
1551
|
-
// caller to pick.
|
|
2767
|
+
// The generic reference checker above has resolved the named target and exit.
|
|
2768
|
+
// This pass proves the business terms required by an advance carve.
|
|
1552
2769
|
const heldByName = new Map(
|
|
1553
2770
|
settlements
|
|
1554
2771
|
.filter((settlement) => settlement.archetype === "held_payment")
|
|
@@ -1561,23 +2778,13 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1561
2778
|
const owner = `settlement ${settlement.name}`;
|
|
1562
2779
|
const target = settlement.source.settlement;
|
|
1563
2780
|
const origin = settlement.source.origin;
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
const other = declared.get(target);
|
|
1569
|
-
error(
|
|
1570
|
-
origin,
|
|
1571
|
-
other
|
|
1572
|
-
? `${owner} draws against ${target}, which is a ${other.kind}; an advance carves a held payment's release`
|
|
1573
|
-
: `${owner} draws against ${target}, but no settlement with that name is declared`,
|
|
1574
|
-
);
|
|
1575
|
-
continue;
|
|
1576
|
-
}
|
|
1577
|
-
if (targetDecl.archetype.name !== "held_payment") {
|
|
2781
|
+
if (
|
|
2782
|
+
settlement.source.targetArchetype !== "held_payment" ||
|
|
2783
|
+
settlement.source.exit !== "release"
|
|
2784
|
+
) {
|
|
1578
2785
|
error(
|
|
1579
2786
|
origin,
|
|
1580
|
-
`${owner}
|
|
2787
|
+
`${owner} can only carve held_payment.release; ${target}.${settlement.source.exit} is a ${settlement.source.targetArchetype} exit`,
|
|
1581
2788
|
);
|
|
1582
2789
|
continue;
|
|
1583
2790
|
}
|
|
@@ -1593,6 +2800,34 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1593
2800
|
);
|
|
1594
2801
|
continue;
|
|
1595
2802
|
}
|
|
2803
|
+
if (hold.amount.name !== settlement.amount.name) {
|
|
2804
|
+
error(
|
|
2805
|
+
origin,
|
|
2806
|
+
`${owner} advances field ${settlement.amount.name}, but ${target}.release carries ${hold.amount.name}; a carve must name the same money field`,
|
|
2807
|
+
);
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
if (hold.amount.currency !== settlement.amount.currency) {
|
|
2811
|
+
error(
|
|
2812
|
+
origin,
|
|
2813
|
+
`${owner} advances ${settlement.amount.currency}, but ${target}.release carries ${hold.amount.currency}; a carve must use one currency`,
|
|
2814
|
+
);
|
|
2815
|
+
continue;
|
|
2816
|
+
}
|
|
2817
|
+
if (settlement.feeBps > 0) {
|
|
2818
|
+
error(
|
|
2819
|
+
origin,
|
|
2820
|
+
`${owner} adds a fee to a carved advance, but ${target}.release can only prove repayment of the principal field; use a fee-free carve or a scheduled advance`,
|
|
2821
|
+
);
|
|
2822
|
+
continue;
|
|
2823
|
+
}
|
|
2824
|
+
if (hold.fees.some((fee) => fee.bearer === hold.payee && fee.bps > 0)) {
|
|
2825
|
+
error(
|
|
2826
|
+
origin,
|
|
2827
|
+
`${owner} carves ${target}.release after a payee fee reduces it; a carved hold must release the full principal field to the funder`,
|
|
2828
|
+
);
|
|
2829
|
+
continue;
|
|
2830
|
+
}
|
|
1596
2831
|
if (hold.payer === settlement.funder) {
|
|
1597
2832
|
error(
|
|
1598
2833
|
origin,
|
|
@@ -1617,8 +2852,11 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1617
2852
|
const recourse = settlements.some(
|
|
1618
2853
|
(other) =>
|
|
1619
2854
|
other.archetype === "scheduled" &&
|
|
2855
|
+
other.mode === "transfer" &&
|
|
1620
2856
|
other.payer === settlement.advanced &&
|
|
1621
|
-
other.payee === settlement.funder
|
|
2857
|
+
other.payee === settlement.funder &&
|
|
2858
|
+
other.amount.name === settlement.amount.name &&
|
|
2859
|
+
other.amount.currency === settlement.amount.currency,
|
|
1622
2860
|
);
|
|
1623
2861
|
if (recourse) continue;
|
|
1624
2862
|
const repayment = `add a scheduled settlement collecting from the ${settlement.advanced.replaceAll("_", " ")} to the ${settlement.funder.replaceAll("_", " ")}`;
|
|
@@ -1667,6 +2905,7 @@ export function checkProgram(program: Program): CheckResult {
|
|
|
1667
2905
|
diagnostics,
|
|
1668
2906
|
program: {
|
|
1669
2907
|
assets: [...assets.values()],
|
|
2908
|
+
derivedAmounts,
|
|
1670
2909
|
name: header.name.name,
|
|
1671
2910
|
parties: [...parties.values()],
|
|
1672
2911
|
ports,
|