@hyperscale0/hsx 1.0.0-alpha.6 → 1.0.0-rc.1
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 +48 -10
- package/LICENSING.md +3 -3
- package/README.md +37 -149
- package/TRADEMARKS.md +4 -4
- package/dist/src/ast.d.ts +129 -10
- package/dist/src/ast.d.ts.map +1 -1
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +148 -12
- package/dist/src/cli.js.map +1 -1
- package/dist/src/compile.d.ts +41 -4
- package/dist/src/compile.d.ts.map +1 -1
- package/dist/src/compile.js +82 -15
- package/dist/src/compile.js.map +1 -1
- package/dist/src/cost.d.ts +78 -0
- package/dist/src/cost.d.ts.map +1 -0
- package/dist/src/cost.js +369 -0
- package/dist/src/cost.js.map +1 -0
- package/dist/src/diagnostics.d.ts +11 -0
- package/dist/src/diagnostics.d.ts.map +1 -0
- package/dist/src/diagnostics.js +420 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/emit.d.ts +35 -0
- package/dist/src/emit.d.ts.map +1 -0
- package/dist/src/emit.js +359 -0
- package/dist/src/emit.js.map +1 -0
- package/dist/src/entry-overrides.d.ts +17 -3
- package/dist/src/entry-overrides.d.ts.map +1 -1
- package/dist/src/entry-overrides.js +180 -35
- package/dist/src/entry-overrides.js.map +1 -1
- package/dist/src/format.d.ts +10 -0
- package/dist/src/format.d.ts.map +1 -0
- package/dist/src/format.js +183 -0
- package/dist/src/format.js.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/ir.d.ts +66 -0
- package/dist/src/ir.d.ts.map +1 -0
- package/dist/src/ir.js +15 -0
- package/dist/src/ir.js.map +1 -0
- package/dist/src/lex.d.ts +9 -1
- package/dist/src/lex.d.ts.map +1 -1
- package/dist/src/lex.js +50 -4
- package/dist/src/lex.js.map +1 -1
- package/dist/src/limits.d.ts +2 -3
- package/dist/src/limits.d.ts.map +1 -1
- package/dist/src/limits.js +2 -3
- package/dist/src/limits.js.map +1 -1
- package/dist/src/modules.d.ts +36 -0
- package/dist/src/modules.d.ts.map +1 -0
- package/dist/src/modules.js +372 -0
- package/dist/src/modules.js.map +1 -0
- package/dist/src/parse.d.ts.map +1 -1
- package/dist/src/parse.js +590 -17
- package/dist/src/parse.js.map +1 -1
- package/dist/src/typecheck.d.ts +8 -0
- package/dist/src/typecheck.d.ts.map +1 -0
- package/dist/src/typecheck.js +2958 -0
- package/dist/src/typecheck.js.map +1 -0
- package/dist/src/version.d.ts +4 -15
- package/dist/src/version.d.ts.map +1 -1
- package/dist/src/version.js +4 -15
- package/dist/src/version.js.map +1 -1
- package/docs/README.md +23 -0
- package/docs/guide/01-first-program.md +24 -0
- package/docs/guide/02-money.md +25 -0
- package/docs/guide/03-instruments.md +22 -0
- package/docs/guide/04-lifecycles.md +25 -0
- package/docs/guide/05-fees-and-splits.md +45 -0
- package/docs/guide/06-schedules.md +22 -0
- package/docs/guide/07-composition.md +46 -0
- package/docs/guide/08-writing-a-module.md +26 -0
- package/docs/guide/09-cost.md +24 -0
- package/docs/guide/10-diagnostics.md +27 -0
- package/docs/llms-full.txt +1738 -0
- package/docs/llms.txt +45 -0
- package/docs/reference/cli.md +33 -0
- package/docs/reference/diagnostics.md +619 -0
- package/docs/reference/grammar.md +39 -0
- package/docs/reference/std/advance.md +35 -0
- package/docs/reference/std/cancellable_booking.md +35 -0
- package/docs/reference/std/captured_payment.md +43 -0
- package/docs/reference/std/conditional_disbursement.md +33 -0
- package/docs/reference/std/credit_facility.md +35 -0
- package/docs/reference/std/held_payment.md +64 -0
- package/docs/reference/std/instant_transfer.md +30 -0
- package/docs/reference/std/metered.md +27 -0
- package/docs/reference/std/pooled_split.md +26 -0
- package/docs/reference/std/premium_forward.md +39 -0
- package/docs/reference/std/reconciled_payout.md +33 -0
- package/docs/reference/std/recurring_collection.md +25 -0
- package/docs/reference/std/rotating_pool.md +42 -0
- package/docs/reference/std/scheduled.md +51 -0
- package/docs/reference/std/security_deposit.md +46 -0
- package/docs/reference/std/settlement_batch.md +42 -0
- package/docs/reference/std/swap.md +46 -0
- package/docs/reference/std/threshold_pool.md +47 -0
- package/docs/reference/std/weighted_distribution.md +39 -0
- package/docs/reference/types.md +20 -0
- package/docs/reference/udl-output.md +14 -0
- package/docs/sessions/2026-09-02-two-instruments.hsx +21 -0
- package/docs/sessions/2026-09-02-two-instruments.md +41 -0
- package/examples/01-first-program/README.md +45 -0
- package/examples/01-first-program/tip-jar.hsx +17 -0
- package/examples/02-imports-and-archetypes/README.md +99 -0
- package/examples/02-imports-and-archetypes/photo-booth.hsx +37 -0
- package/examples/03-diagnostics/README.md +68 -0
- package/examples/03-diagnostics/corner-shop-fixed.hsx +22 -0
- package/examples/03-diagnostics/corner-shop.hsx +19 -0
- package/examples/04-complete-product/README.md +87 -0
- package/examples/04-complete-product/study-hall.hsx +67 -0
- package/examples/05-watch-club/README.md +9 -0
- package/examples/05-watch-club/watch-club.hsx +124 -0
- package/examples/README.md +18 -0
- package/examples/advance/README.md +3 -0
- package/examples/advance/advance.hsx +13 -0
- package/examples/advance/advance.udl +339 -0
- package/examples/cancellable_booking/README.md +3 -0
- package/examples/cancellable_booking/cancellable_booking.hsx +14 -0
- package/examples/cancellable_booking/cancellable_booking.udl +391 -0
- package/examples/captured_payment/README.md +3 -0
- package/examples/captured_payment/captured_payment.hsx +21 -0
- package/examples/captured_payment/captured_payment.udl +677 -0
- package/examples/conditional_disbursement/README.md +3 -0
- package/examples/conditional_disbursement/conditional_disbursement.hsx +17 -0
- package/examples/conditional_disbursement/conditional_disbursement.udl +412 -0
- package/examples/cost-table.json +386 -0
- package/examples/credit_facility/README.md +3 -0
- package/examples/credit_facility/credit_facility.hsx +27 -0
- package/examples/credit_facility/credit_facility.udl +1373 -0
- package/examples/held_payment/README.md +3 -0
- package/examples/held_payment/held_payment.hsx +13 -0
- package/examples/held_payment/held_payment.udl +507 -0
- package/examples/instant_transfer/README.md +3 -0
- package/examples/instant_transfer/instant_transfer.hsx +16 -0
- package/examples/instant_transfer/instant_transfer.udl +364 -0
- package/examples/metered/README.md +3 -0
- package/examples/metered/metered.hsx +13 -0
- package/examples/metered/metered.udl +247 -0
- package/examples/pooled_split/README.md +3 -0
- package/examples/pooled_split/pooled_split.hsx +15 -0
- package/examples/pooled_split/pooled_split.udl +425 -0
- package/examples/premium_forward/README.md +3 -0
- package/examples/premium_forward/premium_forward.hsx +19 -0
- package/examples/premium_forward/premium_forward.udl +594 -0
- package/examples/reconciled_payout/README.md +3 -0
- package/examples/reconciled_payout/reconciled_payout.hsx +13 -0
- package/examples/reconciled_payout/reconciled_payout.udl +303 -0
- package/examples/recurring_collection/README.md +3 -0
- package/examples/recurring_collection/recurring_collection.hsx +21 -0
- package/examples/recurring_collection/recurring_collection.udl +1130 -0
- package/examples/rotating_pool/README.md +3 -0
- package/examples/rotating_pool/rotating_pool.hsx +18 -0
- package/examples/rotating_pool/rotating_pool.udl +4352 -0
- package/examples/scheduled/README.md +3 -0
- package/examples/scheduled/scheduled.hsx +12 -0
- package/examples/scheduled/scheduled.udl +335 -0
- package/examples/security_deposit/README.md +3 -0
- package/examples/security_deposit/security_deposit.hsx +21 -0
- package/examples/security_deposit/security_deposit.udl +293 -0
- package/examples/settlement_batch/README.md +3 -0
- package/examples/settlement_batch/settlement_batch.hsx +19 -0
- package/examples/settlement_batch/settlement_batch.udl +753 -0
- package/examples/swap/README.md +3 -0
- package/examples/swap/swap.hsx +19 -0
- package/examples/swap/swap.udl +876 -0
- package/examples/threshold_pool/README.md +3 -0
- package/examples/threshold_pool/threshold_pool.hsx +16 -0
- package/examples/threshold_pool/threshold_pool.udl +667 -0
- package/examples/weighted_distribution/README.md +3 -0
- package/examples/weighted_distribution/weighted_distribution.hsx +20 -0
- package/examples/weighted_distribution/weighted_distribution.udl +337 -0
- package/package.json +19 -6
- package/skills/hsx/SKILL.md +483 -0
- package/src/ast.ts +163 -9
- package/src/cli.ts +164 -12
- package/src/compile.ts +160 -20
- package/src/cost.ts +579 -0
- package/src/diagnostics.ts +442 -0
- package/src/emit.ts +447 -0
- package/src/entry-overrides.ts +263 -48
- package/src/format.ts +261 -0
- package/src/index.ts +32 -4
- package/src/ir.ts +99 -0
- package/src/lex.ts +59 -4
- package/src/limits.ts +2 -3
- package/src/modules.ts +444 -0
- package/src/parse.ts +655 -21
- package/src/typecheck.ts +3876 -0
- package/src/version.ts +4 -16
- package/std/SEMANTICS.md +130 -0
- package/std/settlements/advance.hsx +177 -0
- package/std/settlements/cancellable_booking.hsx +87 -0
- package/std/settlements/captured_payment.hsx +144 -0
- package/std/settlements/conditional_disbursement.hsx +185 -0
- package/std/settlements/credit_facility.hsx +167 -0
- package/std/settlements/held_payment.hsx +578 -0
- package/std/settlements/index.hsx +3 -0
- package/std/settlements/instant_transfer.hsx +190 -0
- package/std/settlements/metered.hsx +67 -0
- package/std/settlements/pooled_split.hsx +68 -0
- package/std/settlements/premium_forward.hsx +257 -0
- package/std/settlements/reconciled_payout.hsx +77 -0
- package/std/settlements/recurring_collection.hsx +27 -0
- package/std/settlements/rotating_pool.hsx +252 -0
- package/std/settlements/scheduled.hsx +641 -0
- package/std/settlements/security_deposit.hsx +166 -0
- package/std/settlements/settlement_batch.hsx +122 -0
- package/std/settlements/swap.hsx +1114 -0
- package/std/settlements/threshold_pool.hsx +221 -0
- package/std/settlements/weighted_distribution.hsx +141 -0
- package/dist/src/archetypes.d.ts +0 -17
- package/dist/src/archetypes.d.ts.map +0 -1
- package/dist/src/archetypes.js +0 -327
- package/dist/src/archetypes.js.map +0 -1
- package/dist/src/check.d.ts +0 -12
- package/dist/src/check.d.ts.map +0 -1
- package/dist/src/check.js +0 -1910
- package/dist/src/check.js.map +0 -1
- package/dist/src/lower.d.ts +0 -146
- package/dist/src/lower.d.ts.map +0 -1
- package/dist/src/lower.js +0 -4570
- package/dist/src/lower.js.map +0 -1
- package/dist/src/model.d.ts +0 -467
- package/dist/src/model.d.ts.map +0 -1
- package/dist/src/model.js +0 -15
- package/dist/src/model.js.map +0 -1
- package/spec/hsx-ir.schema.json +0 -920
- package/src/archetypes.ts +0 -340
- package/src/check.ts +0 -2938
- package/src/lower.ts +0 -5416
- package/src/model.ts +0 -519
package/src/check.ts
DELETED
|
@@ -1,2938 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The HSX typechecker. Takes the parsed AST, resolves every name, validates
|
|
3
|
-
* each archetype's parameter surface, and produces the checked program model
|
|
4
|
-
* the lowering consumes. Total: always returns diagnostics; returns the model
|
|
5
|
-
* exactly when nothing error-severity was found. Warnings are the compiler's
|
|
6
|
-
* lint voice and never block.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type {
|
|
10
|
-
BlockExpr,
|
|
11
|
-
Entry,
|
|
12
|
-
Expr,
|
|
13
|
-
IdentExpr,
|
|
14
|
-
Program,
|
|
15
|
-
Span,
|
|
16
|
-
} from "./ast.ts";
|
|
17
|
-
import {
|
|
18
|
-
ARCHETYPE_DEFINITIONS,
|
|
19
|
-
type ArchetypeName,
|
|
20
|
-
SETTLEMENT_ARCHETYPES,
|
|
21
|
-
} from "./archetypes.ts";
|
|
22
|
-
import {
|
|
23
|
-
ASSET_KINDS,
|
|
24
|
-
PARTY_KINDS,
|
|
25
|
-
type AdvanceSource,
|
|
26
|
-
type AssetKind,
|
|
27
|
-
type CancelPolicy,
|
|
28
|
-
type CheckDiagnostic,
|
|
29
|
-
type CheckResult,
|
|
30
|
-
type CheckedAsset,
|
|
31
|
-
type CheckedCaptureReservation,
|
|
32
|
-
type CheckedConditionalDisbursement,
|
|
33
|
-
type CheckedCreditFacility,
|
|
34
|
-
type CheckedDerivedAmount,
|
|
35
|
-
type CheckedFundingRound,
|
|
36
|
-
type CheckedParty,
|
|
37
|
-
type CheckedPort,
|
|
38
|
-
type CheckedPremiumForward,
|
|
39
|
-
type CheckedSettlementReference,
|
|
40
|
-
type CheckedSettlement,
|
|
41
|
-
type CheckedSettlementBatch,
|
|
42
|
-
type CheckedRecurringCollection,
|
|
43
|
-
type CheckedRotatingPool,
|
|
44
|
-
type CheckedWeightedDistribution,
|
|
45
|
-
type FeeTerm,
|
|
46
|
-
type MeterRate,
|
|
47
|
-
type MoneyField,
|
|
48
|
-
type PartyKind,
|
|
49
|
-
type PortField,
|
|
50
|
-
type PortFieldType,
|
|
51
|
-
type ScheduleTerms,
|
|
52
|
-
type SplitShare,
|
|
53
|
-
type SwapFee,
|
|
54
|
-
} from "./model.ts";
|
|
55
|
-
|
|
56
|
-
export { SETTLEMENT_ARCHETYPES } from "./archetypes.ts";
|
|
57
|
-
|
|
58
|
-
/** The three terms that together declare a finite schedule. */
|
|
59
|
-
const SCHEDULE_KEYS = ["count", "every", "first_due"] as const;
|
|
60
|
-
|
|
61
|
-
const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
62
|
-
const CAMEL_CASE = /^[a-z][A-Za-z0-9]*$/;
|
|
63
|
-
const CURRENCY = /^[A-Z]{3}$/;
|
|
64
|
-
|
|
65
|
-
const TOTAL_BPS = 10_000;
|
|
66
|
-
|
|
67
|
-
export function checkProgram(program: Program): CheckResult {
|
|
68
|
-
const diagnostics: CheckDiagnostic[] = [];
|
|
69
|
-
const error = (span: Span, message: string): void => {
|
|
70
|
-
diagnostics.push({ message, severity: "error", span });
|
|
71
|
-
};
|
|
72
|
-
const warning = (span: Span, message: string): void => {
|
|
73
|
-
diagnostics.push({ message, severity: "warning", span });
|
|
74
|
-
};
|
|
75
|
-
const derivedAmounts: CheckedDerivedAmount[] = [];
|
|
76
|
-
|
|
77
|
-
// --- Header ---------------------------------------------------------------
|
|
78
|
-
const headers = program.decls.filter((decl) => decl.kind === "program");
|
|
79
|
-
const header = headers[0];
|
|
80
|
-
if (!header) {
|
|
81
|
-
error(
|
|
82
|
-
{ end: 0, start: 0 },
|
|
83
|
-
'the file needs a program header naming the company, like: program used_car_escrow "Used-car escrow"',
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
for (const extra of headers.slice(1)) {
|
|
87
|
-
error(extra.span, "a file declares exactly one program");
|
|
88
|
-
}
|
|
89
|
-
if (header && !SNAKE_CASE.test(header.name.name)) {
|
|
90
|
-
error(
|
|
91
|
-
header.name.span,
|
|
92
|
-
`the program name must be snake_case, like used_car_escrow; "${header.name.name}" is not`,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// --- Imports --------------------------------------------------------------
|
|
97
|
-
const imported = new Map<string, Span>();
|
|
98
|
-
for (const decl of program.decls) {
|
|
99
|
-
if (decl.kind !== "import") continue;
|
|
100
|
-
if (decl.from.value !== "settlement") {
|
|
101
|
-
error(
|
|
102
|
-
decl.from.span,
|
|
103
|
-
`"${decl.from.value}" is not an importable module; the settlement algebra lives in "settlement"`,
|
|
104
|
-
);
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
for (const name of decl.names) {
|
|
108
|
-
if (!(SETTLEMENT_ARCHETYPES as readonly string[]).includes(name.name)) {
|
|
109
|
-
error(
|
|
110
|
-
name.span,
|
|
111
|
-
`"settlement" has no archetype named ${name.name}; it offers ${SETTLEMENT_ARCHETYPES.join(", ")}`,
|
|
112
|
-
);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (imported.has(name.name)) {
|
|
116
|
-
warning(name.span, `${name.name} is imported more than once`);
|
|
117
|
-
}
|
|
118
|
-
imported.set(name.name, name.span);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// --- One namespace for every declared name --------------------------------
|
|
123
|
-
const declared = new Map<string, { kind: string; span: Span }>();
|
|
124
|
-
const claim = (name: IdentExpr, kind: string): boolean => {
|
|
125
|
-
if (name.name === "platform" || name.name === "escrow") {
|
|
126
|
-
error(
|
|
127
|
-
name.span,
|
|
128
|
-
`the name ${name.name} is reserved: the platform and each settlement's escrow are always present without being declared`,
|
|
129
|
-
);
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
const existing = declared.get(name.name);
|
|
133
|
-
if (existing) {
|
|
134
|
-
error(
|
|
135
|
-
name.span,
|
|
136
|
-
`the name ${name.name} is already taken by a ${existing.kind}; every party, asset, settlement, and port needs its own name`,
|
|
137
|
-
);
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
if (!SNAKE_CASE.test(name.name)) {
|
|
141
|
-
error(name.span, `${kind} names are snake_case; "${name.name}" is not`);
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
declared.set(name.name, { kind, span: name.span });
|
|
145
|
-
return true;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
// --- Parties and assets ---------------------------------------------------
|
|
149
|
-
const parties = new Map<string, CheckedParty>();
|
|
150
|
-
const assets = new Map<string, CheckedAsset>();
|
|
151
|
-
for (const decl of program.decls) {
|
|
152
|
-
if (decl.kind === "party") {
|
|
153
|
-
if (!claim(decl.name, "party")) continue;
|
|
154
|
-
if (!(PARTY_KINDS as readonly string[]).includes(decl.partyKind.name)) {
|
|
155
|
-
error(
|
|
156
|
-
decl.partyKind.span,
|
|
157
|
-
`a party is a ${PARTY_KINDS.join(" or a ")}; "${decl.partyKind.name}" is neither`,
|
|
158
|
-
);
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
if (decl.attrs) {
|
|
162
|
-
error(
|
|
163
|
-
decl.attrs.span,
|
|
164
|
-
`party ${decl.name.name} takes no attribute block yet`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
parties.set(decl.name.name, {
|
|
168
|
-
kind: decl.partyKind.name as PartyKind,
|
|
169
|
-
name: decl.name.name,
|
|
170
|
-
origin: decl.span,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
if (decl.kind === "asset") {
|
|
174
|
-
if (!claim(decl.name, "asset")) continue;
|
|
175
|
-
if (!(ASSET_KINDS as readonly string[]).includes(decl.assetKind.name)) {
|
|
176
|
-
error(
|
|
177
|
-
decl.assetKind.span,
|
|
178
|
-
`an asset is one of ${ASSET_KINDS.join(", ")}; "${decl.assetKind.name}" is none of those`,
|
|
179
|
-
);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
let titleTransfer: CheckedAsset["titleTransfer"] = "on_platform";
|
|
183
|
-
for (const entry of decl.attrs?.entries ?? []) {
|
|
184
|
-
if (entry.key.name !== "title_transfer") {
|
|
185
|
-
error(
|
|
186
|
-
entry.key.span,
|
|
187
|
-
`asset ${decl.name.name} does not understand "${entry.key.name}"; the only attribute is title_transfer`,
|
|
188
|
-
);
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
const value = entry.value;
|
|
192
|
-
if (
|
|
193
|
-
value.kind === "ident" &&
|
|
194
|
-
(value.name === "off_platform" || value.name === "on_platform")
|
|
195
|
-
) {
|
|
196
|
-
titleTransfer = value.name;
|
|
197
|
-
} else {
|
|
198
|
-
error(
|
|
199
|
-
value.span,
|
|
200
|
-
"title_transfer is either on_platform or off_platform",
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
assets.set(decl.name.name, {
|
|
205
|
-
kind: decl.assetKind.name as AssetKind,
|
|
206
|
-
name: decl.name.name,
|
|
207
|
-
origin: decl.span,
|
|
208
|
-
titleTransfer,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Claim settlement and port names before checking bodies, so declarations
|
|
214
|
-
// may reference each other regardless of file order.
|
|
215
|
-
const portDecls = program.decls.filter((decl) => decl.kind === "port");
|
|
216
|
-
const settlementDecls = program.decls.filter(
|
|
217
|
-
(decl) => decl.kind === "settlement",
|
|
218
|
-
);
|
|
219
|
-
for (const decl of settlementDecls) claim(decl.name, "settlement");
|
|
220
|
-
const portNames = new Set<string>();
|
|
221
|
-
for (const decl of portDecls) {
|
|
222
|
-
if (claim(decl.name, "port")) portNames.add(decl.name.name);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// --- Shared body helpers ----------------------------------------------------
|
|
226
|
-
const entriesOf = (
|
|
227
|
-
block: BlockExpr,
|
|
228
|
-
valid: readonly string[],
|
|
229
|
-
owner: string,
|
|
230
|
-
): Map<string, Entry> => {
|
|
231
|
-
const seen = new Map<string, Entry>();
|
|
232
|
-
for (const entry of block.entries) {
|
|
233
|
-
if (!valid.includes(entry.key.name)) {
|
|
234
|
-
error(
|
|
235
|
-
entry.key.span,
|
|
236
|
-
`${owner} does not understand "${entry.key.name}"; it takes ${valid.join(", ")}`,
|
|
237
|
-
);
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
if (seen.has(entry.key.name)) {
|
|
241
|
-
error(entry.key.span, `${owner} sets ${entry.key.name} twice`);
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
seen.set(entry.key.name, entry);
|
|
245
|
-
}
|
|
246
|
-
return seen;
|
|
247
|
-
};
|
|
248
|
-
const noQualifiers = (entry: Entry, owner: string): void => {
|
|
249
|
-
if (entry.qualifiers.length > 0) {
|
|
250
|
-
error(
|
|
251
|
-
entry.span,
|
|
252
|
-
`${entry.key.name} on ${owner} takes no ( ... ) qualifier`,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
const referencedParties = new Set<string>();
|
|
257
|
-
const partyRef = (expr: Expr, role: string): string | undefined => {
|
|
258
|
-
if (expr.kind === "ident" && parties.has(expr.name)) {
|
|
259
|
-
referencedParties.add(expr.name);
|
|
260
|
-
return expr.name;
|
|
261
|
-
}
|
|
262
|
-
error(
|
|
263
|
-
expr.span,
|
|
264
|
-
expr.kind === "ident"
|
|
265
|
-
? `${role} must name a declared party; there is no party named ${expr.name}`
|
|
266
|
-
: `${role} must name a declared party`,
|
|
267
|
-
);
|
|
268
|
-
return undefined;
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
// --- Settlements: shared term parsers ---------------------------------------
|
|
272
|
-
const referencedPorts = new Set<string>();
|
|
273
|
-
const settlements: CheckedSettlement[] = [];
|
|
274
|
-
|
|
275
|
-
// The lowering mints fields with these names; a user field that collides
|
|
276
|
-
// would silently overwrite the generated one and corrupt the choreography
|
|
277
|
-
// (e.g. an amount named serviceFeeAmount would BE the fee moved to the
|
|
278
|
-
// platform). Refused here, at source coordinates, for every archetype.
|
|
279
|
-
const RESERVED_FIELD_NAMES = new Set([
|
|
280
|
-
"currency",
|
|
281
|
-
"feeAmount",
|
|
282
|
-
"repayableAmount",
|
|
283
|
-
"serviceFeeAmount",
|
|
284
|
-
"clawbackAt",
|
|
285
|
-
]);
|
|
286
|
-
const RESERVED_FIELD_PATTERNS = [
|
|
287
|
-
/^piece\d+Amount$/,
|
|
288
|
-
/^installment\d+Amount$/,
|
|
289
|
-
/^repayment\d+Amount$/,
|
|
290
|
-
/^carve(?:Hold|Recourse\d+)Id$/,
|
|
291
|
-
/ShareAmount$/,
|
|
292
|
-
/AccountId$/,
|
|
293
|
-
];
|
|
294
|
-
const reservedFieldName = (
|
|
295
|
-
name: string,
|
|
296
|
-
span: Span,
|
|
297
|
-
owner: string,
|
|
298
|
-
): boolean => {
|
|
299
|
-
if (
|
|
300
|
-
RESERVED_FIELD_NAMES.has(name) ||
|
|
301
|
-
RESERVED_FIELD_PATTERNS.some((pattern) => pattern.test(name))
|
|
302
|
-
) {
|
|
303
|
-
error(
|
|
304
|
-
span,
|
|
305
|
-
`${owner} names a field ${name}, but the compiler reserves that name for a generated field; pick another name`,
|
|
306
|
-
);
|
|
307
|
-
return true;
|
|
308
|
-
}
|
|
309
|
-
return false;
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
const parseAmount = (
|
|
313
|
-
entry: Entry | undefined,
|
|
314
|
-
owner: string,
|
|
315
|
-
): MoneyField | undefined => {
|
|
316
|
-
if (!entry) return undefined;
|
|
317
|
-
noQualifiers(entry, owner);
|
|
318
|
-
const value = entry.value;
|
|
319
|
-
if (
|
|
320
|
-
value.kind === "binding" &&
|
|
321
|
-
value.type.kind === "call" &&
|
|
322
|
-
value.type.callee.name === "money" &&
|
|
323
|
-
value.type.args.length === 1 &&
|
|
324
|
-
value.type.args[0]?.kind === "ident"
|
|
325
|
-
) {
|
|
326
|
-
const currency = value.type.args[0].name;
|
|
327
|
-
if (!CAMEL_CASE.test(value.name.name)) {
|
|
328
|
-
error(
|
|
329
|
-
value.name.span,
|
|
330
|
-
`the ${entry.key.name} field is a camelCase name like price; "${value.name.name}" is not`,
|
|
331
|
-
);
|
|
332
|
-
} else if (!CURRENCY.test(currency)) {
|
|
333
|
-
error(
|
|
334
|
-
value.type.args[0].span,
|
|
335
|
-
`"${currency}" is not a currency code; use three capital letters like SAR`,
|
|
336
|
-
);
|
|
337
|
-
} else if (!reservedFieldName(value.name.name, value.name.span, owner)) {
|
|
338
|
-
return { currency, name: value.name.name, origin: value.span };
|
|
339
|
-
}
|
|
340
|
-
return undefined;
|
|
341
|
-
}
|
|
342
|
-
error(
|
|
343
|
-
value.span,
|
|
344
|
-
`${owner} declares ${entry.key.name} as a typed field, like: ${entry.key.name}: price: money(SAR)`,
|
|
345
|
-
);
|
|
346
|
-
return undefined;
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
const parsePortEntry = (
|
|
350
|
-
entry: Entry | undefined,
|
|
351
|
-
owner: string,
|
|
352
|
-
role: string,
|
|
353
|
-
acceptsDeadline = false,
|
|
354
|
-
):
|
|
355
|
-
| {
|
|
356
|
-
readonly deadlineField?: string;
|
|
357
|
-
readonly origin: Span;
|
|
358
|
-
readonly port: string;
|
|
359
|
-
}
|
|
360
|
-
| undefined => {
|
|
361
|
-
if (!entry) return undefined;
|
|
362
|
-
noQualifiers(entry, owner);
|
|
363
|
-
const value = entry.value;
|
|
364
|
-
if (value.kind !== "port_ref") {
|
|
365
|
-
error(
|
|
366
|
-
value.span,
|
|
367
|
-
`${owner} needs a ${role} decision, like: ${entry.key.name}: port ${entry.key.name}_decided`,
|
|
368
|
-
);
|
|
369
|
-
return undefined;
|
|
370
|
-
}
|
|
371
|
-
if (value.within) {
|
|
372
|
-
error(
|
|
373
|
-
value.within.span,
|
|
374
|
-
`${entry.key.name} on ${owner} has no time window; only dispute accepts "within <duration>"`,
|
|
375
|
-
);
|
|
376
|
-
return undefined;
|
|
377
|
-
}
|
|
378
|
-
if (value.deadline && !acceptsDeadline) {
|
|
379
|
-
error(
|
|
380
|
-
value.deadline.span,
|
|
381
|
-
`${entry.key.name} on ${owner} has no date default; only a held payment's release falls back to a date`,
|
|
382
|
-
);
|
|
383
|
-
return undefined;
|
|
384
|
-
}
|
|
385
|
-
if (!portNames.has(value.name.name)) {
|
|
386
|
-
error(
|
|
387
|
-
value.name.span,
|
|
388
|
-
`${owner} decides ${role} through port ${value.name.name}, but no port with that name is declared`,
|
|
389
|
-
);
|
|
390
|
-
return undefined;
|
|
391
|
-
}
|
|
392
|
-
let deadlineField: string | undefined;
|
|
393
|
-
if (value.deadline) {
|
|
394
|
-
if (!CAMEL_CASE.test(value.deadline.name)) {
|
|
395
|
-
error(
|
|
396
|
-
value.deadline.span,
|
|
397
|
-
`at( ... ) names a camelCase date field, like at(releaseDueAt); "${value.deadline.name}" is not`,
|
|
398
|
-
);
|
|
399
|
-
return undefined;
|
|
400
|
-
}
|
|
401
|
-
if (reservedFieldName(value.deadline.name, value.deadline.span, owner)) {
|
|
402
|
-
return undefined;
|
|
403
|
-
}
|
|
404
|
-
deadlineField = value.deadline.name;
|
|
405
|
-
}
|
|
406
|
-
referencedPorts.add(value.name.name);
|
|
407
|
-
return {
|
|
408
|
-
...(deadlineField ? { deadlineField } : {}),
|
|
409
|
-
origin: value.span,
|
|
410
|
-
port: value.name.name,
|
|
411
|
-
};
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
const parseDisputeEntry = (
|
|
415
|
-
entry: Entry | undefined,
|
|
416
|
-
owner: string,
|
|
417
|
-
label = "dispute",
|
|
418
|
-
):
|
|
419
|
-
| {
|
|
420
|
-
readonly origin: Span;
|
|
421
|
-
readonly port: string;
|
|
422
|
-
readonly window: { readonly days: number; readonly raw: string };
|
|
423
|
-
}
|
|
424
|
-
| undefined => {
|
|
425
|
-
if (!entry) return undefined;
|
|
426
|
-
noQualifiers(entry, owner);
|
|
427
|
-
const value = entry.value;
|
|
428
|
-
if (value.kind !== "port_ref") {
|
|
429
|
-
error(
|
|
430
|
-
value.span,
|
|
431
|
-
`${owner} needs one ${label} decision through a fixed windowed port`,
|
|
432
|
-
);
|
|
433
|
-
return undefined;
|
|
434
|
-
}
|
|
435
|
-
if (!portNames.has(value.name.name)) {
|
|
436
|
-
error(
|
|
437
|
-
value.name.span,
|
|
438
|
-
`${owner} routes ${label} through port ${value.name.name}, but no port with that name is declared`,
|
|
439
|
-
);
|
|
440
|
-
return undefined;
|
|
441
|
-
}
|
|
442
|
-
if (!value.within) {
|
|
443
|
-
error(
|
|
444
|
-
value.span,
|
|
445
|
-
`${owner} ${label} needs a fixed window, like: ${entry.key.name}: port ${value.name.name} within P14D`,
|
|
446
|
-
);
|
|
447
|
-
return undefined;
|
|
448
|
-
}
|
|
449
|
-
const raw = value.within.name;
|
|
450
|
-
const match = /^P([1-9]\d{0,3})([DW])$/.exec(raw);
|
|
451
|
-
if (!match) {
|
|
452
|
-
error(
|
|
453
|
-
value.within.span,
|
|
454
|
-
`${label} on ${owner} uses a fixed duration in days or weeks, like P14D; calendar months cannot define an exact money deadline`,
|
|
455
|
-
);
|
|
456
|
-
return undefined;
|
|
457
|
-
}
|
|
458
|
-
const magnitude = Number(match[1]);
|
|
459
|
-
const days = match[2] === "W" ? magnitude * 7 : magnitude;
|
|
460
|
-
referencedPorts.add(value.name.name);
|
|
461
|
-
return {
|
|
462
|
-
origin: value.span,
|
|
463
|
-
port: value.name.name,
|
|
464
|
-
window: { days, raw },
|
|
465
|
-
};
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
const parsePercentEntry = (
|
|
469
|
-
entry: Entry | undefined,
|
|
470
|
-
owner: string,
|
|
471
|
-
label: string,
|
|
472
|
-
): number | undefined => {
|
|
473
|
-
if (!entry) return undefined;
|
|
474
|
-
noQualifiers(entry, owner);
|
|
475
|
-
if (entry.value.kind !== "percent") {
|
|
476
|
-
error(entry.value.span, `${label} on ${owner} must be a percent`);
|
|
477
|
-
return undefined;
|
|
478
|
-
}
|
|
479
|
-
if (entry.value.bps >= TOTAL_BPS) {
|
|
480
|
-
error(
|
|
481
|
-
entry.value.span,
|
|
482
|
-
`a ${entry.value.raw}% ${label} consumes the whole amount; it stays under 100%`,
|
|
483
|
-
);
|
|
484
|
-
return undefined;
|
|
485
|
-
}
|
|
486
|
-
if (entry.value.bps === 0) {
|
|
487
|
-
warning(
|
|
488
|
-
entry.value.span,
|
|
489
|
-
`${label} on ${owner} is 0%; drop the entry if none is meant`,
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
return entry.value.bps;
|
|
493
|
-
};
|
|
494
|
-
|
|
495
|
-
const parseFees = (
|
|
496
|
-
entry: Entry | undefined,
|
|
497
|
-
owner: string,
|
|
498
|
-
payer: string | undefined,
|
|
499
|
-
payee: string | undefined,
|
|
500
|
-
): FeeTerm[] => {
|
|
501
|
-
const fees: FeeTerm[] = [];
|
|
502
|
-
if (!entry) return fees;
|
|
503
|
-
noQualifiers(entry, owner);
|
|
504
|
-
if (entry.value.kind !== "block") {
|
|
505
|
-
error(
|
|
506
|
-
entry.value.span,
|
|
507
|
-
`fees is a block of party shares, like: fees { buyer: 1% }`,
|
|
508
|
-
);
|
|
509
|
-
return fees;
|
|
510
|
-
}
|
|
511
|
-
for (const fee of entry.value.entries) {
|
|
512
|
-
const bearer = fee.key.name;
|
|
513
|
-
if (bearer !== payer && bearer !== payee) {
|
|
514
|
-
error(
|
|
515
|
-
fee.key.span,
|
|
516
|
-
`fees are borne by this settlement's payer or payee; ${bearer} is neither`,
|
|
517
|
-
);
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
if (fees.some((existing) => existing.bearer === bearer)) {
|
|
521
|
-
error(fee.key.span, `${owner} sets a fee for ${bearer} twice`);
|
|
522
|
-
continue;
|
|
523
|
-
}
|
|
524
|
-
if (fee.value.kind !== "percent") {
|
|
525
|
-
error(fee.value.span, `the fee for ${bearer} must be a percent`);
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
|
-
if (fee.value.bps >= TOTAL_BPS) {
|
|
529
|
-
error(
|
|
530
|
-
fee.value.span,
|
|
531
|
-
`a ${fee.value.raw}% fee consumes the whole amount; fees stay under 100%`,
|
|
532
|
-
);
|
|
533
|
-
continue;
|
|
534
|
-
}
|
|
535
|
-
if (fee.value.bps === 0) {
|
|
536
|
-
warning(
|
|
537
|
-
fee.value.span,
|
|
538
|
-
`the fee for ${bearer} is 0%; drop the entry if no fee is meant`,
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
fees.push({ bearer, bps: fee.value.bps, origin: fee.span });
|
|
542
|
-
}
|
|
543
|
-
return fees;
|
|
544
|
-
};
|
|
545
|
-
|
|
546
|
-
const parseSwapFees = (
|
|
547
|
-
entry: Entry | undefined,
|
|
548
|
-
owner: string,
|
|
549
|
-
sides: readonly [string | undefined, string | undefined],
|
|
550
|
-
): SwapFee[] => {
|
|
551
|
-
const fees: SwapFee[] = [];
|
|
552
|
-
if (!entry) return fees;
|
|
553
|
-
noQualifiers(entry, owner);
|
|
554
|
-
if (entry.value.kind !== "block") {
|
|
555
|
-
error(
|
|
556
|
-
entry.value.span,
|
|
557
|
-
`fees is a block of exact on-top money fields, like: fees { buyer: buyerFee: money(SAR) }`,
|
|
558
|
-
);
|
|
559
|
-
return fees;
|
|
560
|
-
}
|
|
561
|
-
for (const fee of entry.value.entries) {
|
|
562
|
-
const bearer = fee.key.name;
|
|
563
|
-
if (!sides.includes(bearer)) {
|
|
564
|
-
error(
|
|
565
|
-
fee.key.span,
|
|
566
|
-
`swap fees are borne by one of its two parties; ${bearer} is not in between`,
|
|
567
|
-
);
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
|
-
if (fees.some((existing) => existing.bearer === bearer)) {
|
|
571
|
-
error(fee.key.span, `${owner} sets a fee for ${bearer} twice`);
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
574
|
-
const amount = parseAmount(fee, `${owner} fee for ${bearer}`);
|
|
575
|
-
if (amount) fees.push({ amount, bearer });
|
|
576
|
-
}
|
|
577
|
-
return fees;
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* A percent split block whose shares must sum to exactly 100%. Recipients
|
|
582
|
-
* are constrained to `allowed`; `remainder_to` is accepted only when
|
|
583
|
-
* `remainderAllowed` (pooled_split), naming the share that carries the
|
|
584
|
-
* integer-division remainder.
|
|
585
|
-
*/
|
|
586
|
-
const parseSplitBlock = (
|
|
587
|
-
entry: Entry,
|
|
588
|
-
owner: string,
|
|
589
|
-
allowed: readonly string[],
|
|
590
|
-
allowedLabel: string,
|
|
591
|
-
remainderAllowed: boolean,
|
|
592
|
-
):
|
|
593
|
-
| { readonly remainderTo?: string; readonly shares: SplitShare[] }
|
|
594
|
-
| undefined => {
|
|
595
|
-
if (entry.value.kind !== "block") {
|
|
596
|
-
error(
|
|
597
|
-
entry.value.span,
|
|
598
|
-
`${entry.key.name} on ${owner} is a block of percent shares, like: { ${allowed[0] ?? "party"}: 100% }`,
|
|
599
|
-
);
|
|
600
|
-
return undefined;
|
|
601
|
-
}
|
|
602
|
-
const shares: SplitShare[] = [];
|
|
603
|
-
let remainderTo: { readonly name: string; readonly span: Span } | undefined;
|
|
604
|
-
let sum = 0;
|
|
605
|
-
let sound = true;
|
|
606
|
-
for (const share of entry.value.entries) {
|
|
607
|
-
const to = share.key.name;
|
|
608
|
-
if (to === "remainder_to") {
|
|
609
|
-
if (!remainderAllowed) {
|
|
610
|
-
error(
|
|
611
|
-
share.key.span,
|
|
612
|
-
`${owner} does not take remainder_to here; the first share carries the integer remainder`,
|
|
613
|
-
);
|
|
614
|
-
sound = false;
|
|
615
|
-
continue;
|
|
616
|
-
}
|
|
617
|
-
if (share.value.kind !== "ident") {
|
|
618
|
-
error(share.value.span, `remainder_to names one of the recipients`);
|
|
619
|
-
sound = false;
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
if (remainderTo) {
|
|
623
|
-
error(share.key.span, `${owner} sets remainder_to twice`);
|
|
624
|
-
sound = false;
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
remainderTo = { name: share.value.name, span: share.value.span };
|
|
628
|
-
continue;
|
|
629
|
-
}
|
|
630
|
-
if (!allowed.includes(to)) {
|
|
631
|
-
error(
|
|
632
|
-
share.key.span,
|
|
633
|
-
`the ${entry.key.name} split goes to ${allowedLabel}; ${to} is not among them`,
|
|
634
|
-
);
|
|
635
|
-
sound = false;
|
|
636
|
-
continue;
|
|
637
|
-
}
|
|
638
|
-
referencedParties.add(to);
|
|
639
|
-
if (shares.some((existing) => existing.to === to)) {
|
|
640
|
-
error(share.key.span, `${owner} splits to ${to} twice`);
|
|
641
|
-
sound = false;
|
|
642
|
-
continue;
|
|
643
|
-
}
|
|
644
|
-
if (share.value.kind !== "percent") {
|
|
645
|
-
error(share.value.span, `the share for ${to} must be a percent`);
|
|
646
|
-
sound = false;
|
|
647
|
-
continue;
|
|
648
|
-
}
|
|
649
|
-
// A 0% share would lower to a zero-amount money movement, which the
|
|
650
|
-
// kernel refuses at runtime; a recipient who receives nothing simply
|
|
651
|
-
// leaves the block.
|
|
652
|
-
if (share.value.bps === 0) {
|
|
653
|
-
error(
|
|
654
|
-
share.value.span,
|
|
655
|
-
`the share for ${to} is 0%; drop ${to} from the split instead of giving it nothing`,
|
|
656
|
-
);
|
|
657
|
-
sound = false;
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
shares.push({ bps: share.value.bps, origin: share.span, to });
|
|
661
|
-
sum += share.value.bps;
|
|
662
|
-
}
|
|
663
|
-
if (sound && sum !== TOTAL_BPS) {
|
|
664
|
-
error(
|
|
665
|
-
entry.value.span,
|
|
666
|
-
`the ${entry.key.name} split must account for exactly 100%; these shares total ${(sum / 100).toFixed(2).replace(/\.?0+$/, "")}%`,
|
|
667
|
-
);
|
|
668
|
-
sound = false;
|
|
669
|
-
}
|
|
670
|
-
if (
|
|
671
|
-
remainderTo &&
|
|
672
|
-
!shares.some((share) => share.to === remainderTo?.name)
|
|
673
|
-
) {
|
|
674
|
-
error(
|
|
675
|
-
remainderTo.span,
|
|
676
|
-
`remainder_to must name one of the split's recipients; ${remainderTo.name} holds no share`,
|
|
677
|
-
);
|
|
678
|
-
sound = false;
|
|
679
|
-
}
|
|
680
|
-
if (!sound || shares.length === 0) return undefined;
|
|
681
|
-
return {
|
|
682
|
-
...(remainderTo ? { remainderTo: remainderTo.name } : {}),
|
|
683
|
-
shares,
|
|
684
|
-
};
|
|
685
|
-
};
|
|
686
|
-
|
|
687
|
-
const parseCancelPolicy = (
|
|
688
|
-
entry: Entry | undefined,
|
|
689
|
-
owner: string,
|
|
690
|
-
allowed: readonly string[],
|
|
691
|
-
allowedLabel: string,
|
|
692
|
-
): CancelPolicy | undefined => {
|
|
693
|
-
if (!entry) return undefined;
|
|
694
|
-
const qualifier = entry.qualifiers[0];
|
|
695
|
-
if (entry.qualifiers.length !== 1 || qualifier?.name !== "funded") {
|
|
696
|
-
error(
|
|
697
|
-
entry.span,
|
|
698
|
-
`on_cancel states the phase it covers; today that is on_cancel(funded)`,
|
|
699
|
-
);
|
|
700
|
-
return undefined;
|
|
701
|
-
}
|
|
702
|
-
const split = parseSplitBlock(entry, owner, allowed, allowedLabel, false);
|
|
703
|
-
if (!split) return undefined;
|
|
704
|
-
return { origin: entry.span, shares: split.shares, when: "funded" };
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
const parseDateField = (
|
|
708
|
-
entry: Entry | undefined,
|
|
709
|
-
owner: string,
|
|
710
|
-
): string | undefined => {
|
|
711
|
-
if (!entry) return undefined;
|
|
712
|
-
noQualifiers(entry, owner);
|
|
713
|
-
if (entry.value.kind === "ident" && CAMEL_CASE.test(entry.value.name)) {
|
|
714
|
-
return reservedFieldName(entry.value.name, entry.value.span, owner)
|
|
715
|
-
? undefined
|
|
716
|
-
: entry.value.name;
|
|
717
|
-
}
|
|
718
|
-
error(
|
|
719
|
-
entry.value.span,
|
|
720
|
-
`${entry.key.name} on ${owner} names a camelCase date field, like: ${entry.key.name}: dueDate`,
|
|
721
|
-
);
|
|
722
|
-
return undefined;
|
|
723
|
-
};
|
|
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
|
-
|
|
812
|
-
const parseSchedule = (
|
|
813
|
-
entries: Map<string, Entry>,
|
|
814
|
-
owner: string,
|
|
815
|
-
origin: Span,
|
|
816
|
-
maxCount = 12,
|
|
817
|
-
): ScheduleTerms | undefined => {
|
|
818
|
-
const countEntry = entries.get("count");
|
|
819
|
-
const everyEntry = entries.get("every");
|
|
820
|
-
let count: number | undefined;
|
|
821
|
-
if (countEntry) {
|
|
822
|
-
noQualifiers(countEntry, owner);
|
|
823
|
-
const value = countEntry.value;
|
|
824
|
-
const parsed = value.kind === "number" ? Number(value.raw) : Number.NaN;
|
|
825
|
-
if (Number.isInteger(parsed) && parsed >= 2 && parsed <= maxCount) {
|
|
826
|
-
count = parsed;
|
|
827
|
-
} else {
|
|
828
|
-
error(
|
|
829
|
-
value.span,
|
|
830
|
-
`count on ${owner} is a literal number of anchors between 2 and ${maxCount}; the schedule stays finite by construction`,
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
let every: ScheduleTerms["every"] | undefined;
|
|
835
|
-
if (everyEntry) {
|
|
836
|
-
noQualifiers(everyEntry, owner);
|
|
837
|
-
const value = everyEntry.value;
|
|
838
|
-
const raw =
|
|
839
|
-
value.kind === "ident"
|
|
840
|
-
? value.name
|
|
841
|
-
: value.kind === "string"
|
|
842
|
-
? value.value
|
|
843
|
-
: undefined;
|
|
844
|
-
const match = raw ? /^P([1-9]\d{0,3})([DW])$/.exec(raw) : null;
|
|
845
|
-
if (match && raw) {
|
|
846
|
-
const magnitude = Number(match[1]);
|
|
847
|
-
every = {
|
|
848
|
-
days: match[2] === "W" ? magnitude * 7 : magnitude,
|
|
849
|
-
raw,
|
|
850
|
-
};
|
|
851
|
-
} else {
|
|
852
|
-
error(
|
|
853
|
-
value.span,
|
|
854
|
-
`every on ${owner} is a fixed duration in days or weeks, like P30D or P2W; calendar months drift and cannot anchor an exact schedule`,
|
|
855
|
-
);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
const firstDueField = parseDateField(entries.get("first_due"), owner);
|
|
859
|
-
if (count === undefined || !every || !firstDueField) return undefined;
|
|
860
|
-
return { count, every, firstDueField, origin };
|
|
861
|
-
};
|
|
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
|
-
|
|
918
|
-
/**
|
|
919
|
-
* An advance draws its repayment from exactly one source: its own finite
|
|
920
|
-
* schedule, or a carve out of the release of a hold the advanced party is
|
|
921
|
-
* already owed. Which hold that is resolves after every settlement is
|
|
922
|
-
* checked, so only the reference's shape is validated here.
|
|
923
|
-
*/
|
|
924
|
-
const parseAdvanceSource = (
|
|
925
|
-
entries: Map<string, Entry>,
|
|
926
|
-
owner: string,
|
|
927
|
-
origin: Span,
|
|
928
|
-
): AdvanceSource | undefined => {
|
|
929
|
-
const againstEntry = entries.get("against");
|
|
930
|
-
const scheduleKeys = SCHEDULE_KEYS.filter((key) => entries.has(key));
|
|
931
|
-
if (againstEntry && scheduleKeys.length > 0) {
|
|
932
|
-
error(
|
|
933
|
-
againstEntry.span,
|
|
934
|
-
`${owner} is repaid by a carve and by its own schedule (${scheduleKeys.join(", ")}); an advance draws on one source`,
|
|
935
|
-
);
|
|
936
|
-
return undefined;
|
|
937
|
-
}
|
|
938
|
-
if (!againstEntry) {
|
|
939
|
-
if (scheduleKeys.length === 0) {
|
|
940
|
-
error(
|
|
941
|
-
origin,
|
|
942
|
-
`${owner} needs a repayment source: against: <hold>.release carves it out of a hold's release, or count + every + first_due collects it on a schedule`,
|
|
943
|
-
);
|
|
944
|
-
return undefined;
|
|
945
|
-
}
|
|
946
|
-
for (const key of SCHEDULE_KEYS) {
|
|
947
|
-
if (!entries.has(key)) error(origin, `${owner} is missing ${key}`);
|
|
948
|
-
}
|
|
949
|
-
const schedule = parseSchedule(entries, owner, origin);
|
|
950
|
-
return schedule ? { kind: "schedule", schedule } : undefined;
|
|
951
|
-
}
|
|
952
|
-
const reference = parseSettlementReference(againstEntry, owner);
|
|
953
|
-
return reference ? { kind: "carve", ...reference } : undefined;
|
|
954
|
-
};
|
|
955
|
-
|
|
956
|
-
// --- Settlements: one checker per archetype ---------------------------------
|
|
957
|
-
for (const decl of settlementDecls) {
|
|
958
|
-
const archetype = decl.archetype.name;
|
|
959
|
-
if (!imported.has(archetype)) {
|
|
960
|
-
error(
|
|
961
|
-
decl.archetype.span,
|
|
962
|
-
(SETTLEMENT_ARCHETYPES as readonly string[]).includes(archetype)
|
|
963
|
-
? `${archetype} must be imported first: import { ${archetype} } from "settlement"`
|
|
964
|
-
: `there is no settlement archetype named ${archetype}`,
|
|
965
|
-
);
|
|
966
|
-
continue;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
const owner = `settlement ${decl.name.name}`;
|
|
970
|
-
const surface = ARCHETYPE_DEFINITIONS[archetype as ArchetypeName];
|
|
971
|
-
const entries = entriesOf(
|
|
972
|
-
decl.body,
|
|
973
|
-
[...surface.keys, "derived_amount"],
|
|
974
|
-
owner,
|
|
975
|
-
);
|
|
976
|
-
for (const required of surface.required) {
|
|
977
|
-
if (!entries.has(required)) {
|
|
978
|
-
error(decl.span, `${owner} is missing ${required}`);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
const party = (key: string, role: string): string | undefined => {
|
|
982
|
-
const entry = entries.get(key);
|
|
983
|
-
if (!entry) return undefined;
|
|
984
|
-
noQualifiers(entry, owner);
|
|
985
|
-
return partyRef(entry.value, `${owner} ${role}`);
|
|
986
|
-
};
|
|
987
|
-
const distinct = (
|
|
988
|
-
left: string | undefined,
|
|
989
|
-
right: string | undefined,
|
|
990
|
-
message: string,
|
|
991
|
-
): boolean => {
|
|
992
|
-
if (left && right && left === right) {
|
|
993
|
-
error(decl.span, `${owner} ${message}`);
|
|
994
|
-
return false;
|
|
995
|
-
}
|
|
996
|
-
return true;
|
|
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
|
-
}
|
|
1086
|
-
|
|
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
|
-
}
|
|
1729
|
-
case "swap": {
|
|
1730
|
-
const betweenEntry = entries.get("between");
|
|
1731
|
-
let sideA: string | undefined;
|
|
1732
|
-
let sideB: string | undefined;
|
|
1733
|
-
if (betweenEntry) {
|
|
1734
|
-
noQualifiers(betweenEntry, owner);
|
|
1735
|
-
if (
|
|
1736
|
-
betweenEntry.value.kind !== "list" ||
|
|
1737
|
-
betweenEntry.value.items.length !== 2
|
|
1738
|
-
) {
|
|
1739
|
-
error(
|
|
1740
|
-
betweenEntry.value.span,
|
|
1741
|
-
`${owner} swaps between exactly two parties, like: between: [buyer, seller]`,
|
|
1742
|
-
);
|
|
1743
|
-
} else {
|
|
1744
|
-
sideA = partyRef(
|
|
1745
|
-
betweenEntry.value.items[0] as Expr,
|
|
1746
|
-
`${owner} first side`,
|
|
1747
|
-
);
|
|
1748
|
-
sideB = partyRef(
|
|
1749
|
-
betweenEntry.value.items[1] as Expr,
|
|
1750
|
-
`${owner} second side`,
|
|
1751
|
-
);
|
|
1752
|
-
distinct(
|
|
1753
|
-
sideA,
|
|
1754
|
-
sideB,
|
|
1755
|
-
`names ${sideA} on both sides; the two swap parties must differ`,
|
|
1756
|
-
);
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
const amountByParty = new Map<string, MoneyField>();
|
|
1761
|
-
const amountsEntry = entries.get("amounts");
|
|
1762
|
-
if (amountsEntry) {
|
|
1763
|
-
noQualifiers(amountsEntry, owner);
|
|
1764
|
-
if (amountsEntry.value.kind !== "block") {
|
|
1765
|
-
error(
|
|
1766
|
-
amountsEntry.value.span,
|
|
1767
|
-
`${owner} amounts is a two-party block, like: amounts { buyer: buyerPays: money(SAR), seller: sellerPays: money(SAR) }`,
|
|
1768
|
-
);
|
|
1769
|
-
} else {
|
|
1770
|
-
for (const amountEntry of amountsEntry.value.entries) {
|
|
1771
|
-
const partyName = amountEntry.key.name;
|
|
1772
|
-
if (partyName !== sideA && partyName !== sideB) {
|
|
1773
|
-
error(
|
|
1774
|
-
amountEntry.key.span,
|
|
1775
|
-
`${owner} amount belongs to one of its two parties; ${partyName} is not in between`,
|
|
1776
|
-
);
|
|
1777
|
-
continue;
|
|
1778
|
-
}
|
|
1779
|
-
if (amountByParty.has(partyName)) {
|
|
1780
|
-
error(
|
|
1781
|
-
amountEntry.key.span,
|
|
1782
|
-
`${owner} declares more than one amount for ${partyName}`,
|
|
1783
|
-
);
|
|
1784
|
-
continue;
|
|
1785
|
-
}
|
|
1786
|
-
const amount = parseAmount(amountEntry, owner);
|
|
1787
|
-
if (amount) amountByParty.set(partyName, amount);
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
const sideAAmount = sideA ? amountByParty.get(sideA) : undefined;
|
|
1792
|
-
const sideBAmount = sideB ? amountByParty.get(sideB) : undefined;
|
|
1793
|
-
if (sideA && !sideAAmount) {
|
|
1794
|
-
error(
|
|
1795
|
-
amountsEntry?.span ?? decl.span,
|
|
1796
|
-
`${owner} needs exactly one funded amount for ${sideA}`,
|
|
1797
|
-
);
|
|
1798
|
-
}
|
|
1799
|
-
if (sideB && !sideBAmount) {
|
|
1800
|
-
error(
|
|
1801
|
-
amountsEntry?.span ?? decl.span,
|
|
1802
|
-
`${owner} needs exactly one funded amount for ${sideB}`,
|
|
1803
|
-
);
|
|
1804
|
-
}
|
|
1805
|
-
if (
|
|
1806
|
-
sideAAmount &&
|
|
1807
|
-
sideBAmount &&
|
|
1808
|
-
sideAAmount.currency !== sideBAmount.currency
|
|
1809
|
-
) {
|
|
1810
|
-
error(
|
|
1811
|
-
amountsEntry?.span ?? decl.span,
|
|
1812
|
-
`${owner} cannot link ${sideAAmount.currency} and ${sideBAmount.currency} in one atomic ledger batch; both amounts need one currency`,
|
|
1813
|
-
);
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
const release = parsePortEntry(
|
|
1817
|
-
entries.get("release"),
|
|
1818
|
-
owner,
|
|
1819
|
-
"whole-trade release",
|
|
1820
|
-
);
|
|
1821
|
-
const dispute = parseDisputeEntry(entries.get("dispute"), owner);
|
|
1822
|
-
const fees = parseSwapFees(entries.get("fees"), owner, [sideA, sideB]);
|
|
1823
|
-
const swapAmounts = [sideAAmount, sideBAmount].filter(
|
|
1824
|
-
(amount): amount is MoneyField => amount !== undefined,
|
|
1825
|
-
);
|
|
1826
|
-
for (const fee of fees) {
|
|
1827
|
-
if (
|
|
1828
|
-
swapAmounts.some((amount) => amount.name === fee.amount.name) ||
|
|
1829
|
-
fees.some(
|
|
1830
|
-
(other) => other !== fee && other.amount.name === fee.amount.name,
|
|
1831
|
-
)
|
|
1832
|
-
) {
|
|
1833
|
-
error(
|
|
1834
|
-
fee.amount.origin,
|
|
1835
|
-
`${owner} fee field ${fee.amount.name} must be distinct from both principal and fee fields`,
|
|
1836
|
-
);
|
|
1837
|
-
}
|
|
1838
|
-
if (sideAAmount && fee.amount.currency !== sideAAmount.currency) {
|
|
1839
|
-
error(
|
|
1840
|
-
fee.amount.origin,
|
|
1841
|
-
`${owner} fee field ${fee.amount.name} must use the trade currency ${sideAAmount.currency}`,
|
|
1842
|
-
);
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
if (
|
|
1846
|
-
sideA &&
|
|
1847
|
-
sideB &&
|
|
1848
|
-
sideAAmount &&
|
|
1849
|
-
sideBAmount &&
|
|
1850
|
-
sideAAmount.currency === sideBAmount.currency &&
|
|
1851
|
-
release
|
|
1852
|
-
) {
|
|
1853
|
-
const sideAFee = fees.find((fee) => fee.bearer === sideA);
|
|
1854
|
-
const sideBFee = fees.find((fee) => fee.bearer === sideB);
|
|
1855
|
-
settlements.push({
|
|
1856
|
-
archetype: "swap",
|
|
1857
|
-
...(dispute ? { dispute } : {}),
|
|
1858
|
-
name: decl.name.name,
|
|
1859
|
-
origin: decl.span,
|
|
1860
|
-
release,
|
|
1861
|
-
sides: [
|
|
1862
|
-
{
|
|
1863
|
-
amount: sideAAmount,
|
|
1864
|
-
...(sideAFee ? { fee: sideAFee } : {}),
|
|
1865
|
-
party: sideA,
|
|
1866
|
-
},
|
|
1867
|
-
{
|
|
1868
|
-
amount: sideBAmount,
|
|
1869
|
-
...(sideBFee ? { fee: sideBFee } : {}),
|
|
1870
|
-
party: sideB,
|
|
1871
|
-
},
|
|
1872
|
-
],
|
|
1873
|
-
});
|
|
1874
|
-
}
|
|
1875
|
-
break;
|
|
1876
|
-
}
|
|
1877
|
-
case "held_payment": {
|
|
1878
|
-
const payer = party("payer", "payer");
|
|
1879
|
-
const payee = party("payee", "payee");
|
|
1880
|
-
const sound = distinct(
|
|
1881
|
-
payer,
|
|
1882
|
-
payee,
|
|
1883
|
-
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
1884
|
-
);
|
|
1885
|
-
const amount = parseAmount(entries.get("amount"), owner);
|
|
1886
|
-
const release = parsePortEntry(
|
|
1887
|
-
entries.get("release"),
|
|
1888
|
-
owner,
|
|
1889
|
-
"release",
|
|
1890
|
-
true,
|
|
1891
|
-
);
|
|
1892
|
-
const fees = parseFees(entries.get("fees"), owner, payer, payee);
|
|
1893
|
-
const onCancel = parseCancelPolicy(
|
|
1894
|
-
entries.get("on_cancel"),
|
|
1895
|
-
owner,
|
|
1896
|
-
[payer, payee].filter((name): name is string => name !== undefined),
|
|
1897
|
-
"this settlement's payer or payee",
|
|
1898
|
-
);
|
|
1899
|
-
if (amount && release?.deadlineField === amount.name) {
|
|
1900
|
-
error(
|
|
1901
|
-
release.origin,
|
|
1902
|
-
`${owner} uses ${amount.name} as both the held amount and the release date field; they need distinct names`,
|
|
1903
|
-
);
|
|
1904
|
-
break;
|
|
1905
|
-
}
|
|
1906
|
-
if (payer && payee && amount && release && sound) {
|
|
1907
|
-
settlements.push({
|
|
1908
|
-
amount,
|
|
1909
|
-
archetype: "held_payment",
|
|
1910
|
-
fees,
|
|
1911
|
-
name: decl.name.name,
|
|
1912
|
-
...(onCancel ? { onCancel } : {}),
|
|
1913
|
-
origin: decl.span,
|
|
1914
|
-
payee,
|
|
1915
|
-
payer,
|
|
1916
|
-
release: { origin: release.origin, port: release.port },
|
|
1917
|
-
...(release.deadlineField
|
|
1918
|
-
? { releaseDeadlineField: release.deadlineField }
|
|
1919
|
-
: {}),
|
|
1920
|
-
});
|
|
1921
|
-
}
|
|
1922
|
-
break;
|
|
1923
|
-
}
|
|
1924
|
-
case "instant_transfer": {
|
|
1925
|
-
const payer = party("payer", "payer");
|
|
1926
|
-
const payee = party("payee", "payee");
|
|
1927
|
-
const sound = distinct(
|
|
1928
|
-
payer,
|
|
1929
|
-
payee,
|
|
1930
|
-
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
1931
|
-
);
|
|
1932
|
-
const amount = parseAmount(entries.get("amount"), owner);
|
|
1933
|
-
const fees = parseFees(entries.get("fees"), owner, payer, payee);
|
|
1934
|
-
if (payer && payee && amount && sound) {
|
|
1935
|
-
settlements.push({
|
|
1936
|
-
amount,
|
|
1937
|
-
archetype: "instant_transfer",
|
|
1938
|
-
fees,
|
|
1939
|
-
name: decl.name.name,
|
|
1940
|
-
origin: decl.span,
|
|
1941
|
-
payee,
|
|
1942
|
-
payer,
|
|
1943
|
-
});
|
|
1944
|
-
}
|
|
1945
|
-
break;
|
|
1946
|
-
}
|
|
1947
|
-
case "premium_forward": {
|
|
1948
|
-
const payer = party("payer", "payer");
|
|
1949
|
-
const carrier = party("carrier", "carrier");
|
|
1950
|
-
const sound = distinct(
|
|
1951
|
-
payer,
|
|
1952
|
-
carrier,
|
|
1953
|
-
`forwards the premium from ${payer} to ${payer}; payer and carrier must differ`,
|
|
1954
|
-
);
|
|
1955
|
-
const amount = parseAmount(entries.get("amount"), owner);
|
|
1956
|
-
const bind = parsePortEntry(entries.get("bind"), owner, "binding");
|
|
1957
|
-
const commissionBps =
|
|
1958
|
-
parsePercentEntry(entries.get("commission"), owner, "commission") ??
|
|
1959
|
-
0;
|
|
1960
|
-
const onCancel = parseCancelPolicy(
|
|
1961
|
-
entries.get("on_cancel"),
|
|
1962
|
-
owner,
|
|
1963
|
-
[payer, carrier].filter((name): name is string => name !== undefined),
|
|
1964
|
-
"this settlement's payer or carrier",
|
|
1965
|
-
);
|
|
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 = {
|
|
2027
|
-
amount,
|
|
2028
|
-
archetype: "premium_forward",
|
|
2029
|
-
bind,
|
|
2030
|
-
carrier,
|
|
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
|
-
: {}),
|
|
2045
|
-
name: decl.name.name,
|
|
2046
|
-
...(onCancel ? { onCancel } : {}),
|
|
2047
|
-
origin: decl.span,
|
|
2048
|
-
payer,
|
|
2049
|
-
};
|
|
2050
|
-
settlements.push(checked);
|
|
2051
|
-
}
|
|
2052
|
-
break;
|
|
2053
|
-
}
|
|
2054
|
-
case "deposit": {
|
|
2055
|
-
const payer = party("payer", "payer");
|
|
2056
|
-
const holder = party("holder", "holder");
|
|
2057
|
-
const sound = distinct(
|
|
2058
|
-
payer,
|
|
2059
|
-
holder,
|
|
2060
|
-
`holds ${payer}'s deposit for ${payer}; payer and holder must differ`,
|
|
2061
|
-
);
|
|
2062
|
-
const amount = parseAmount(entries.get("amount"), owner);
|
|
2063
|
-
const claim = parsePortEntry(entries.get("claim"), owner, "claim");
|
|
2064
|
-
const returnPort = parsePortEntry(
|
|
2065
|
-
entries.get("return"),
|
|
2066
|
-
owner,
|
|
2067
|
-
"return",
|
|
2068
|
-
);
|
|
2069
|
-
if (claim && returnPort && claim.port === returnPort.port) {
|
|
2070
|
-
error(
|
|
2071
|
-
returnPort.origin,
|
|
2072
|
-
`${owner} claims and returns through the same port ${claim.port}; the two exits need their own ports`,
|
|
2073
|
-
);
|
|
2074
|
-
break;
|
|
2075
|
-
}
|
|
2076
|
-
if (payer && holder && amount && claim && returnPort && sound) {
|
|
2077
|
-
settlements.push({
|
|
2078
|
-
amount,
|
|
2079
|
-
archetype: "deposit",
|
|
2080
|
-
claim,
|
|
2081
|
-
holder,
|
|
2082
|
-
name: decl.name.name,
|
|
2083
|
-
origin: decl.span,
|
|
2084
|
-
payer,
|
|
2085
|
-
return: returnPort,
|
|
2086
|
-
});
|
|
2087
|
-
}
|
|
2088
|
-
break;
|
|
2089
|
-
}
|
|
2090
|
-
case "scheduled": {
|
|
2091
|
-
const payer = party("payer", "payer");
|
|
2092
|
-
const payee = party("payee", "payee");
|
|
2093
|
-
const sound = distinct(
|
|
2094
|
-
payer,
|
|
2095
|
-
payee,
|
|
2096
|
-
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
2097
|
-
);
|
|
2098
|
-
const amount = parseAmount(entries.get("amount"), owner);
|
|
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
|
-
);
|
|
2109
|
-
if (amount && schedule && amount.name === schedule.firstDueField) {
|
|
2110
|
-
error(
|
|
2111
|
-
schedule.origin,
|
|
2112
|
-
`${owner} uses ${amount.name} as both the amount and the first_due date field; they need distinct names`,
|
|
2113
|
-
);
|
|
2114
|
-
break;
|
|
2115
|
-
}
|
|
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
|
-
) {
|
|
2230
|
-
settlements.push({
|
|
2231
|
-
...(advanceTo ? { advanceTo } : {}),
|
|
2232
|
-
amount,
|
|
2233
|
-
archetype: "scheduled",
|
|
2234
|
-
debtor,
|
|
2235
|
-
mode: "obligation",
|
|
2236
|
-
name: decl.name.name,
|
|
2237
|
-
origin: decl.span,
|
|
2238
|
-
payee,
|
|
2239
|
-
payer,
|
|
2240
|
-
schedule,
|
|
2241
|
-
});
|
|
2242
|
-
}
|
|
2243
|
-
break;
|
|
2244
|
-
}
|
|
2245
|
-
case "advance": {
|
|
2246
|
-
const funder = party("funder", "funder");
|
|
2247
|
-
const advanced = party("to", "advanced party");
|
|
2248
|
-
const sound = distinct(
|
|
2249
|
-
funder,
|
|
2250
|
-
advanced,
|
|
2251
|
-
`advances ${funder} their own money; funder and advanced party must differ`,
|
|
2252
|
-
);
|
|
2253
|
-
const amount = parseAmount(entries.get("amount"), owner);
|
|
2254
|
-
const feeBps = parsePercentEntry(entries.get("fee"), owner, "fee") ?? 0;
|
|
2255
|
-
const source = parseAdvanceSource(entries, owner, decl.span);
|
|
2256
|
-
if (
|
|
2257
|
-
amount &&
|
|
2258
|
-
source?.kind === "schedule" &&
|
|
2259
|
-
amount.name === source.schedule.firstDueField
|
|
2260
|
-
) {
|
|
2261
|
-
error(
|
|
2262
|
-
source.schedule.origin,
|
|
2263
|
-
`${owner} uses ${amount.name} as both the amount and the first_due date field; they need distinct names`,
|
|
2264
|
-
);
|
|
2265
|
-
break;
|
|
2266
|
-
}
|
|
2267
|
-
if (funder && advanced && amount && source && sound) {
|
|
2268
|
-
settlements.push({
|
|
2269
|
-
advanced,
|
|
2270
|
-
amount,
|
|
2271
|
-
archetype: "advance",
|
|
2272
|
-
feeBps,
|
|
2273
|
-
funder,
|
|
2274
|
-
name: decl.name.name,
|
|
2275
|
-
origin: decl.span,
|
|
2276
|
-
source,
|
|
2277
|
-
});
|
|
2278
|
-
}
|
|
2279
|
-
break;
|
|
2280
|
-
}
|
|
2281
|
-
case "metered": {
|
|
2282
|
-
const payer = party("payer", "payer");
|
|
2283
|
-
const payee = party("payee", "payee");
|
|
2284
|
-
const sound = distinct(
|
|
2285
|
-
payer,
|
|
2286
|
-
payee,
|
|
2287
|
-
`meters ${payer} against ${payer}; payer and payee must differ`,
|
|
2288
|
-
);
|
|
2289
|
-
const closeByField = parseDateField(entries.get("close_by"), owner);
|
|
2290
|
-
const rates: MeterRate[] = [];
|
|
2291
|
-
const ratesEntry = entries.get("rates");
|
|
2292
|
-
let withinEventCap = true;
|
|
2293
|
-
if (ratesEntry) {
|
|
2294
|
-
noQualifiers(ratesEntry, owner);
|
|
2295
|
-
if (ratesEntry.value.kind !== "block") {
|
|
2296
|
-
error(
|
|
2297
|
-
ratesEntry.value.span,
|
|
2298
|
-
`rates is a block of per-unit prices, like: rates { api_call: unitFee: money(SAR) }`,
|
|
2299
|
-
);
|
|
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
|
-
}
|
|
2311
|
-
for (const rate of ratesEntry.value.entries) {
|
|
2312
|
-
if (!SNAKE_CASE.test(rate.key.name)) {
|
|
2313
|
-
error(
|
|
2314
|
-
rate.key.span,
|
|
2315
|
-
`meter names are snake_case; "${rate.key.name}" is not`,
|
|
2316
|
-
);
|
|
2317
|
-
continue;
|
|
2318
|
-
}
|
|
2319
|
-
if (rates.some((existing) => existing.meter === rate.key.name)) {
|
|
2320
|
-
error(
|
|
2321
|
-
rate.key.span,
|
|
2322
|
-
`${owner} prices meter ${rate.key.name} twice`,
|
|
2323
|
-
);
|
|
2324
|
-
continue;
|
|
2325
|
-
}
|
|
2326
|
-
const field = parseAmount(rate, owner);
|
|
2327
|
-
if (!field) continue;
|
|
2328
|
-
if (
|
|
2329
|
-
rates.some((existing) => existing.field.name === field.name)
|
|
2330
|
-
) {
|
|
2331
|
-
error(
|
|
2332
|
-
field.origin,
|
|
2333
|
-
`${owner} reuses the field ${field.name} for two meters`,
|
|
2334
|
-
);
|
|
2335
|
-
continue;
|
|
2336
|
-
}
|
|
2337
|
-
const currency = rates[0]?.field.currency;
|
|
2338
|
-
if (currency && currency !== field.currency) {
|
|
2339
|
-
error(
|
|
2340
|
-
field.origin,
|
|
2341
|
-
`${owner} mixes ${currency} and ${field.currency}; one settlement meters in one currency`,
|
|
2342
|
-
);
|
|
2343
|
-
continue;
|
|
2344
|
-
}
|
|
2345
|
-
rates.push({ field, meter: rate.key.name, origin: rate.span });
|
|
2346
|
-
}
|
|
2347
|
-
if (ratesEntry.value.entries.length === 0) {
|
|
2348
|
-
error(ratesEntry.value.span, `${owner} prices no meters`);
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
if (
|
|
2353
|
-
closeByField &&
|
|
2354
|
-
rates.some((rate) => rate.field.name === closeByField)
|
|
2355
|
-
) {
|
|
2356
|
-
error(
|
|
2357
|
-
decl.span,
|
|
2358
|
-
`${owner} uses ${closeByField} as both a rate field and the close_by date field; they need distinct names`,
|
|
2359
|
-
);
|
|
2360
|
-
break;
|
|
2361
|
-
}
|
|
2362
|
-
if (
|
|
2363
|
-
payer &&
|
|
2364
|
-
payee &&
|
|
2365
|
-
closeByField &&
|
|
2366
|
-
rates.length > 0 &&
|
|
2367
|
-
sound &&
|
|
2368
|
-
withinEventCap
|
|
2369
|
-
) {
|
|
2370
|
-
settlements.push({
|
|
2371
|
-
archetype: "metered",
|
|
2372
|
-
closeByField,
|
|
2373
|
-
name: decl.name.name,
|
|
2374
|
-
origin: decl.span,
|
|
2375
|
-
payee,
|
|
2376
|
-
payer,
|
|
2377
|
-
rates,
|
|
2378
|
-
});
|
|
2379
|
-
}
|
|
2380
|
-
break;
|
|
2381
|
-
}
|
|
2382
|
-
case "pooled_split": {
|
|
2383
|
-
const payer = party("payer", "payer");
|
|
2384
|
-
const amount = parseAmount(entries.get("amount"), owner);
|
|
2385
|
-
const distributeDueField = parseDateField(
|
|
2386
|
-
entries.get("payout_due"),
|
|
2387
|
-
owner,
|
|
2388
|
-
);
|
|
2389
|
-
const splitEntry = entries.get("split");
|
|
2390
|
-
const recipients = [...parties.keys()].filter((name) => name !== payer);
|
|
2391
|
-
const split = splitEntry
|
|
2392
|
-
? parseSplitBlock(
|
|
2393
|
-
splitEntry,
|
|
2394
|
-
owner,
|
|
2395
|
-
recipients,
|
|
2396
|
-
"declared parties other than the funder",
|
|
2397
|
-
true,
|
|
2398
|
-
)
|
|
2399
|
-
: undefined;
|
|
2400
|
-
if (split && split.shares.length < 2) {
|
|
2401
|
-
error(
|
|
2402
|
-
splitEntry!.value.span,
|
|
2403
|
-
`${owner} splits to a single recipient; a pool distributes between at least two`,
|
|
2404
|
-
);
|
|
2405
|
-
break;
|
|
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
|
-
}
|
|
2417
|
-
// The lowering names each share field ${camelCase(party)}ShareAmount;
|
|
2418
|
-
// camelCasing is not injective (a_2b and a2b collide), so two shares
|
|
2419
|
-
// must never map onto one generated field.
|
|
2420
|
-
if (split) {
|
|
2421
|
-
const shareFieldOwners = new Map<string, string>();
|
|
2422
|
-
for (const share of split.shares) {
|
|
2423
|
-
const [head = "", ...rest] = share.to.split("_");
|
|
2424
|
-
const field = `${
|
|
2425
|
-
head +
|
|
2426
|
-
rest
|
|
2427
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
2428
|
-
.join("")
|
|
2429
|
-
}ShareAmount`;
|
|
2430
|
-
const other = shareFieldOwners.get(field);
|
|
2431
|
-
if (other) {
|
|
2432
|
-
error(
|
|
2433
|
-
share.origin,
|
|
2434
|
-
`${owner} splits to ${other} and ${share.to}, whose generated share fields both come out as ${field}; rename one party`,
|
|
2435
|
-
);
|
|
2436
|
-
}
|
|
2437
|
-
shareFieldOwners.set(field, share.to);
|
|
2438
|
-
}
|
|
2439
|
-
}
|
|
2440
|
-
if (
|
|
2441
|
-
amount &&
|
|
2442
|
-
distributeDueField &&
|
|
2443
|
-
amount.name === distributeDueField
|
|
2444
|
-
) {
|
|
2445
|
-
error(
|
|
2446
|
-
decl.span,
|
|
2447
|
-
`${owner} uses ${amount.name} as both the amount and the payout_due date field; they need distinct names`,
|
|
2448
|
-
);
|
|
2449
|
-
break;
|
|
2450
|
-
}
|
|
2451
|
-
if (payer && amount && distributeDueField && split && withinEventCap) {
|
|
2452
|
-
settlements.push({
|
|
2453
|
-
amount,
|
|
2454
|
-
archetype: "pooled_split",
|
|
2455
|
-
distributeDueField,
|
|
2456
|
-
name: decl.name.name,
|
|
2457
|
-
origin: decl.span,
|
|
2458
|
-
payer,
|
|
2459
|
-
remainderTo: split.remainderTo ?? split.shares[0]!.to,
|
|
2460
|
-
shares: split.shares,
|
|
2461
|
-
});
|
|
2462
|
-
}
|
|
2463
|
-
break;
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
}
|
|
2467
|
-
|
|
2468
|
-
// --- Ports ------------------------------------------------------------------
|
|
2469
|
-
const ports: CheckedPort[] = [];
|
|
2470
|
-
for (const decl of portDecls) {
|
|
2471
|
-
const owner = `port ${decl.name.name}`;
|
|
2472
|
-
// The tenant's own backend is always the decider, so a port carries only
|
|
2473
|
-
// who may ask (allowed) and what the answer looks like (shape).
|
|
2474
|
-
const entries = entriesOf(decl.body, ["allowed", "shape"], owner);
|
|
2475
|
-
if (!entries.has("allowed")) {
|
|
2476
|
-
error(decl.span, `${owner} is missing allowed`);
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2479
|
-
const allowed: string[] = [];
|
|
2480
|
-
const allowedEntry = entries.get("allowed");
|
|
2481
|
-
if (allowedEntry) {
|
|
2482
|
-
noQualifiers(allowedEntry, owner);
|
|
2483
|
-
const value = allowedEntry.value;
|
|
2484
|
-
if (value.kind !== "list" || value.items.length === 0) {
|
|
2485
|
-
error(
|
|
2486
|
-
value.span,
|
|
2487
|
-
`allowed lists who may ask this question, like: allowed: [buyer]`,
|
|
2488
|
-
);
|
|
2489
|
-
} else {
|
|
2490
|
-
for (const item of value.items) {
|
|
2491
|
-
const party = partyRef(item, `${owner} allowed`);
|
|
2492
|
-
if (!party) continue;
|
|
2493
|
-
if (allowed.includes(party)) {
|
|
2494
|
-
error(item.span, `${owner} allows ${party} twice`);
|
|
2495
|
-
continue;
|
|
2496
|
-
}
|
|
2497
|
-
allowed.push(party);
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
const fields: PortField[] = [];
|
|
2503
|
-
const shapeEntry = entries.get("shape");
|
|
2504
|
-
if (shapeEntry) {
|
|
2505
|
-
noQualifiers(shapeEntry, owner);
|
|
2506
|
-
if (shapeEntry.value.kind !== "block") {
|
|
2507
|
-
error(
|
|
2508
|
-
shapeEntry.value.span,
|
|
2509
|
-
`shape is a block of typed fields, like: shape { vehicleId: id(vehicle) }`,
|
|
2510
|
-
);
|
|
2511
|
-
} else {
|
|
2512
|
-
for (const field of shapeEntry.value.entries) {
|
|
2513
|
-
if (!CAMEL_CASE.test(field.key.name)) {
|
|
2514
|
-
error(
|
|
2515
|
-
field.key.span,
|
|
2516
|
-
`shape fields are camelCase names; "${field.key.name}" is not`,
|
|
2517
|
-
);
|
|
2518
|
-
continue;
|
|
2519
|
-
}
|
|
2520
|
-
if (fields.some((existing) => existing.name === field.key.name)) {
|
|
2521
|
-
error(field.key.span, `${owner} declares ${field.key.name} twice`);
|
|
2522
|
-
continue;
|
|
2523
|
-
}
|
|
2524
|
-
const type = portFieldType(field.value);
|
|
2525
|
-
if (!type) {
|
|
2526
|
-
error(
|
|
2527
|
-
field.value.span,
|
|
2528
|
-
`${field.key.name} needs a type: id(<asset>), money(<CUR>), text, or date`,
|
|
2529
|
-
);
|
|
2530
|
-
continue;
|
|
2531
|
-
}
|
|
2532
|
-
if (type.kind === "asset_id" && !assets.has(type.asset)) {
|
|
2533
|
-
error(
|
|
2534
|
-
field.value.span,
|
|
2535
|
-
`id(${type.asset}) points at an asset that is not declared`,
|
|
2536
|
-
);
|
|
2537
|
-
continue;
|
|
2538
|
-
}
|
|
2539
|
-
if (type.kind === "money" && !CURRENCY.test(type.currency)) {
|
|
2540
|
-
error(
|
|
2541
|
-
field.value.span,
|
|
2542
|
-
`"${type.currency}" is not a currency code; use three capital letters like SAR`,
|
|
2543
|
-
);
|
|
2544
|
-
continue;
|
|
2545
|
-
}
|
|
2546
|
-
fields.push({ name: field.key.name, origin: field.span, type });
|
|
2547
|
-
}
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
if (allowed.length > 0) {
|
|
2552
|
-
ports.push({
|
|
2553
|
-
allowed,
|
|
2554
|
-
fields,
|
|
2555
|
-
name: decl.name.name,
|
|
2556
|
-
origin: decl.span,
|
|
2557
|
-
});
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
const checkedPortByName = new Map(ports.map((port) => [port.name, port]));
|
|
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
|
-
}
|
|
2677
|
-
if (settlement.archetype !== "swap") continue;
|
|
2678
|
-
const parties = new Set(settlement.sides.map((side) => side.party));
|
|
2679
|
-
const decisions = [
|
|
2680
|
-
{ label: "release", portRef: settlement.release },
|
|
2681
|
-
...(settlement.dispute
|
|
2682
|
-
? [{ label: "dispute", portRef: settlement.dispute }]
|
|
2683
|
-
: []),
|
|
2684
|
-
];
|
|
2685
|
-
for (const decision of decisions) {
|
|
2686
|
-
const port = checkedPortByName.get(decision.portRef.port);
|
|
2687
|
-
if (!port) continue;
|
|
2688
|
-
for (const actor of port.allowed) {
|
|
2689
|
-
if (!parties.has(actor)) {
|
|
2690
|
-
error(
|
|
2691
|
-
port.origin,
|
|
2692
|
-
`swap ${settlement.name} ${decision.label} port ${port.name} allows ${actor}, but whole-trade decisions belong only to ${settlement.sides.map((side) => side.party).join(" or ")}`,
|
|
2693
|
-
);
|
|
2694
|
-
}
|
|
2695
|
-
}
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
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
|
-
|
|
2766
|
-
// --- Carved advances: the hold each one draws against -----------------------
|
|
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.
|
|
2769
|
-
const heldByName = new Map(
|
|
2770
|
-
settlements
|
|
2771
|
-
.filter((settlement) => settlement.archetype === "held_payment")
|
|
2772
|
-
.map((settlement) => [settlement.name, settlement]),
|
|
2773
|
-
);
|
|
2774
|
-
const carvedBy = new Map<string, string>();
|
|
2775
|
-
for (const settlement of settlements) {
|
|
2776
|
-
if (settlement.archetype !== "advance") continue;
|
|
2777
|
-
if (settlement.source.kind !== "carve") continue;
|
|
2778
|
-
const owner = `settlement ${settlement.name}`;
|
|
2779
|
-
const target = settlement.source.settlement;
|
|
2780
|
-
const origin = settlement.source.origin;
|
|
2781
|
-
if (
|
|
2782
|
-
settlement.source.targetArchetype !== "held_payment" ||
|
|
2783
|
-
settlement.source.exit !== "release"
|
|
2784
|
-
) {
|
|
2785
|
-
error(
|
|
2786
|
-
origin,
|
|
2787
|
-
`${owner} can only carve held_payment.release; ${target}.${settlement.source.exit} is a ${settlement.source.targetArchetype} exit`,
|
|
2788
|
-
);
|
|
2789
|
-
continue;
|
|
2790
|
-
}
|
|
2791
|
-
const hold = heldByName.get(target);
|
|
2792
|
-
// The hold's own body failed to check and already said why; a second
|
|
2793
|
-
// complaint about it here would only bury the first.
|
|
2794
|
-
if (!hold) continue;
|
|
2795
|
-
|
|
2796
|
-
if (hold.payee !== settlement.advanced) {
|
|
2797
|
-
error(
|
|
2798
|
-
origin,
|
|
2799
|
-
`${owner} advances ${settlement.advanced}, but ${target} releases to ${hold.payee}; an advance carves the release of the party it finances`,
|
|
2800
|
-
);
|
|
2801
|
-
continue;
|
|
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
|
-
}
|
|
2831
|
-
if (hold.payer === settlement.funder) {
|
|
2832
|
-
error(
|
|
2833
|
-
origin,
|
|
2834
|
-
`${settlement.funder} funds ${target} and finances it too; the party who pays the hold cannot be the funder its release repays`,
|
|
2835
|
-
);
|
|
2836
|
-
continue;
|
|
2837
|
-
}
|
|
2838
|
-
const first = carvedBy.get(target);
|
|
2839
|
-
if (first) {
|
|
2840
|
-
error(
|
|
2841
|
-
origin,
|
|
2842
|
-
`${owner} and settlement ${first} both draw against ${target}; one release repays one advance`,
|
|
2843
|
-
);
|
|
2844
|
-
continue;
|
|
2845
|
-
}
|
|
2846
|
-
carvedBy.set(target, settlement.name);
|
|
2847
|
-
|
|
2848
|
-
// The hold's release is the advance's only repayment, so every exit that
|
|
2849
|
-
// is not that release leaves the funder unpaid. Cancellation is the exit
|
|
2850
|
-
// the author chose to declare, so an uncovered one is an error; the
|
|
2851
|
-
// pre-funding abandonment every hold carries is a lint.
|
|
2852
|
-
const recourse = settlements.some(
|
|
2853
|
-
(other) =>
|
|
2854
|
-
other.archetype === "scheduled" &&
|
|
2855
|
-
other.mode === "transfer" &&
|
|
2856
|
-
other.payer === settlement.advanced &&
|
|
2857
|
-
other.payee === settlement.funder &&
|
|
2858
|
-
other.amount.name === settlement.amount.name &&
|
|
2859
|
-
other.amount.currency === settlement.amount.currency,
|
|
2860
|
-
);
|
|
2861
|
-
if (recourse) continue;
|
|
2862
|
-
const repayment = `add a scheduled settlement collecting from the ${settlement.advanced.replaceAll("_", " ")} to the ${settlement.funder.replaceAll("_", " ")}`;
|
|
2863
|
-
if (hold.onCancel) {
|
|
2864
|
-
error(
|
|
2865
|
-
origin,
|
|
2866
|
-
`${owner} has no repayment path when ${target} is refunded instead of released; ${repayment}`,
|
|
2867
|
-
);
|
|
2868
|
-
continue;
|
|
2869
|
-
}
|
|
2870
|
-
warning(
|
|
2871
|
-
origin,
|
|
2872
|
-
`${owner} has no repayment path if ${target} is abandoned before it funds; ${repayment}`,
|
|
2873
|
-
);
|
|
2874
|
-
}
|
|
2875
|
-
|
|
2876
|
-
// --- Whole-program lint -----------------------------------------------------
|
|
2877
|
-
for (const [name, party] of parties) {
|
|
2878
|
-
if (!referencedParties.has(name)) {
|
|
2879
|
-
warning(
|
|
2880
|
-
party.origin,
|
|
2881
|
-
`party ${name} is declared but no settlement or port involves them`,
|
|
2882
|
-
);
|
|
2883
|
-
}
|
|
2884
|
-
}
|
|
2885
|
-
for (const decl of portDecls) {
|
|
2886
|
-
if (portNames.has(decl.name.name) && !referencedPorts.has(decl.name.name)) {
|
|
2887
|
-
warning(
|
|
2888
|
-
decl.span,
|
|
2889
|
-
`port ${decl.name.name} is declared but nothing releases through it`,
|
|
2890
|
-
);
|
|
2891
|
-
}
|
|
2892
|
-
}
|
|
2893
|
-
for (const [name, span] of imported) {
|
|
2894
|
-
const used = settlementDecls.some((decl) => decl.archetype.name === name);
|
|
2895
|
-
if (!used) {
|
|
2896
|
-
warning(span, `${name} is imported but never instantiated`);
|
|
2897
|
-
}
|
|
2898
|
-
}
|
|
2899
|
-
|
|
2900
|
-
const hasErrors = diagnostics.some(
|
|
2901
|
-
(diagnostic) => diagnostic.severity === "error",
|
|
2902
|
-
);
|
|
2903
|
-
if (hasErrors || !header) return { diagnostics };
|
|
2904
|
-
return {
|
|
2905
|
-
diagnostics,
|
|
2906
|
-
program: {
|
|
2907
|
-
assets: [...assets.values()],
|
|
2908
|
-
derivedAmounts,
|
|
2909
|
-
name: header.name.name,
|
|
2910
|
-
parties: [...parties.values()],
|
|
2911
|
-
ports,
|
|
2912
|
-
settlements,
|
|
2913
|
-
title: header.title?.value ?? titleize(header.name.name),
|
|
2914
|
-
},
|
|
2915
|
-
};
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
function portFieldType(expr: Expr): PortFieldType | undefined {
|
|
2919
|
-
if (expr.kind === "ident" && expr.name === "text") return { kind: "text" };
|
|
2920
|
-
if (expr.kind === "ident" && expr.name === "date") return { kind: "date" };
|
|
2921
|
-
if (
|
|
2922
|
-
expr.kind === "call" &&
|
|
2923
|
-
expr.args.length === 1 &&
|
|
2924
|
-
expr.args[0]?.kind === "ident"
|
|
2925
|
-
) {
|
|
2926
|
-
const argument = expr.args[0].name;
|
|
2927
|
-
if (expr.callee.name === "id") return { asset: argument, kind: "asset_id" };
|
|
2928
|
-
if (expr.callee.name === "money") {
|
|
2929
|
-
return { currency: argument, kind: "money" };
|
|
2930
|
-
}
|
|
2931
|
-
}
|
|
2932
|
-
return undefined;
|
|
2933
|
-
}
|
|
2934
|
-
|
|
2935
|
-
function titleize(snake: string): string {
|
|
2936
|
-
const spaced = snake.replaceAll("_", " ");
|
|
2937
|
-
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
2938
|
-
}
|