@hyperscale0/hsx 1.0.0-alpha.4 → 1.0.0-alpha.5
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 +76 -1
- package/README.md +1 -1
- package/dist/src/archetypes.d.ts +17 -0
- package/dist/src/archetypes.d.ts.map +1 -0
- package/dist/src/archetypes.js +327 -0
- package/dist/src/archetypes.js.map +1 -0
- package/dist/src/ast.d.ts +2 -3
- package/dist/src/ast.d.ts.map +1 -1
- package/dist/src/check.d.ts +1 -2
- package/dist/src/check.d.ts.map +1 -1
- package/dist/src/check.js +805 -109
- package/dist/src/check.js.map +1 -1
- package/dist/src/entry-overrides.d.ts +44 -0
- package/dist/src/entry-overrides.d.ts.map +1 -0
- package/dist/src/entry-overrides.js +139 -0
- package/dist/src/entry-overrides.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lower.d.ts +57 -4
- package/dist/src/lower.d.ts.map +1 -1
- package/dist/src/lower.js +2581 -100
- package/dist/src/lower.js.map +1 -1
- package/dist/src/model.d.ts +167 -7
- package/dist/src/model.d.ts.map +1 -1
- package/dist/src/model.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/spec/hsx-ir.schema.json +406 -8
- package/src/archetypes.ts +340 -0
- package/src/ast.ts +2 -3
- package/src/check.ts +1363 -124
- package/src/entry-overrides.ts +213 -0
- package/src/index.ts +7 -0
- package/src/lower.ts +2914 -127
- package/src/model.ts +187 -8
- package/src/version.ts +1 -1
package/src/lower.ts
CHANGED
|
@@ -31,10 +31,16 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import type { Span } from "./ast.ts";
|
|
34
|
+
import { ARCHETYPE_DEFINITIONS } from "./archetypes.ts";
|
|
34
35
|
import type {
|
|
35
36
|
CancelPolicy,
|
|
36
37
|
CheckedAdvance,
|
|
38
|
+
CheckedCaptureReservation,
|
|
39
|
+
CheckedConditionalDisbursement,
|
|
40
|
+
CheckedCreditFacility,
|
|
37
41
|
CheckedDeposit,
|
|
42
|
+
CheckedDerivedAmount,
|
|
43
|
+
CheckedFundingRound,
|
|
38
44
|
CheckedHeldPayment,
|
|
39
45
|
CheckedInstantTransfer,
|
|
40
46
|
CheckedMetered,
|
|
@@ -42,9 +48,14 @@ import type {
|
|
|
42
48
|
CheckedPort,
|
|
43
49
|
CheckedPremiumForward,
|
|
44
50
|
CheckedProgram,
|
|
51
|
+
CheckedRecurringCollection,
|
|
52
|
+
CheckedRotatingPool,
|
|
45
53
|
CheckedScheduled,
|
|
54
|
+
CheckedScheduledObligation,
|
|
46
55
|
CheckedSettlement,
|
|
56
|
+
CheckedSettlementBatch,
|
|
47
57
|
CheckedSwap,
|
|
58
|
+
CheckedWeightedDistribution,
|
|
48
59
|
MoneyField,
|
|
49
60
|
ScheduleTerms,
|
|
50
61
|
} from "./model.ts";
|
|
@@ -55,11 +66,13 @@ type Json = Record<string, unknown>;
|
|
|
55
66
|
/**
|
|
56
67
|
* The most money events one program may mint. The Business Frame contract caps
|
|
57
68
|
* its moneyEvents array at the same number, and a runtime spec pins the two
|
|
58
|
-
* against each other, so neither can drift alone.
|
|
59
|
-
*
|
|
69
|
+
* against each other, so neither can drift alone. A repeatable schedule costs
|
|
70
|
+
* one event declaration. Fee legs, cancellation legs, refunds, and forwards
|
|
71
|
+
* each count when they emit their own event declaration.
|
|
60
72
|
*/
|
|
61
|
-
export const MONEY_EVENT_BUDGET =
|
|
73
|
+
export const MONEY_EVENT_BUDGET = 20;
|
|
62
74
|
|
|
75
|
+
const PUBLIC_INTENT_BUDGET = 48;
|
|
63
76
|
const TOTAL_BPS = 10_000n;
|
|
64
77
|
|
|
65
78
|
interface LoweredPiece {
|
|
@@ -82,7 +95,7 @@ interface LoweredSettlement {
|
|
|
82
95
|
readonly serviceFee?: { readonly bps: number; readonly field: string };
|
|
83
96
|
}
|
|
84
97
|
|
|
85
|
-
interface LoweringIssue {
|
|
98
|
+
export interface LoweringIssue {
|
|
86
99
|
readonly message: string;
|
|
87
100
|
readonly span: Span;
|
|
88
101
|
}
|
|
@@ -132,12 +145,124 @@ interface LoweredNoun {
|
|
|
132
145
|
readonly feeLines: readonly Json[];
|
|
133
146
|
readonly moneyEvents: readonly Json[];
|
|
134
147
|
readonly noun: Json;
|
|
148
|
+
readonly extraNouns?: readonly Json[];
|
|
149
|
+
readonly generatedPrefixNounIds?: readonly string[];
|
|
150
|
+
readonly repeatableCounterparty?: RepeatableCounterpartyRole;
|
|
135
151
|
readonly rules: readonly Json[];
|
|
136
152
|
readonly settlement: LoweredSettlement;
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
|
|
155
|
+
export type FrameActorRole =
|
|
156
|
+
| "beneficiary"
|
|
157
|
+
| "guardian"
|
|
158
|
+
| "holder"
|
|
159
|
+
| "payer"
|
|
160
|
+
| "provider";
|
|
161
|
+
|
|
162
|
+
/** One role whose account endpoint repeats within one settlement. */
|
|
163
|
+
export interface RepeatableCounterpartyRole {
|
|
164
|
+
readonly key: string;
|
|
165
|
+
readonly label: string;
|
|
166
|
+
readonly maxCount: number;
|
|
167
|
+
readonly minCount: number;
|
|
168
|
+
readonly origin: Span;
|
|
169
|
+
readonly role: FrameActorRole;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type AmountDependencyExpression =
|
|
173
|
+
| {
|
|
174
|
+
readonly kind: "bounded_by_reference";
|
|
175
|
+
readonly reference: string;
|
|
176
|
+
}
|
|
177
|
+
| {
|
|
178
|
+
readonly kind: "net_of_offsets";
|
|
179
|
+
readonly offsets: readonly string[];
|
|
180
|
+
readonly source: string;
|
|
181
|
+
}
|
|
182
|
+
| {
|
|
183
|
+
readonly bps: number;
|
|
184
|
+
readonly kind: "percent_of_reference";
|
|
185
|
+
readonly reference: string;
|
|
186
|
+
}
|
|
187
|
+
| {
|
|
188
|
+
readonly consumed: readonly string[];
|
|
189
|
+
readonly kind: "remainder";
|
|
190
|
+
readonly source: string;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
interface EventAmountFields {
|
|
194
|
+
readonly amountDependencies: readonly string[];
|
|
195
|
+
readonly amountMode: "fixed" | "remaining_balance" | "runtime_bounded";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Lower one arithmetic dependency into the Business Frame's existing keys. */
|
|
199
|
+
export function lowerAmountDependency(
|
|
200
|
+
expression?: AmountDependencyExpression,
|
|
201
|
+
): EventAmountFields {
|
|
202
|
+
if (!expression) return { amountDependencies: [], amountMode: "fixed" };
|
|
203
|
+
switch (expression.kind) {
|
|
204
|
+
case "bounded_by_reference":
|
|
205
|
+
return {
|
|
206
|
+
amountDependencies: [frameKey(expression.reference)],
|
|
207
|
+
amountMode: "runtime_bounded",
|
|
208
|
+
};
|
|
209
|
+
case "net_of_offsets": {
|
|
210
|
+
if (expression.offsets.length === 0) {
|
|
211
|
+
throw new Error("net_of_offsets requires at least one offset event");
|
|
212
|
+
}
|
|
213
|
+
const dependencies = canonicalDependencies(
|
|
214
|
+
expression.source,
|
|
215
|
+
expression.offsets,
|
|
216
|
+
);
|
|
217
|
+
return {
|
|
218
|
+
amountDependencies: dependencies,
|
|
219
|
+
amountMode: "runtime_bounded",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
case "percent_of_reference":
|
|
223
|
+
if (
|
|
224
|
+
!Number.isInteger(expression.bps) ||
|
|
225
|
+
expression.bps <= 0 ||
|
|
226
|
+
expression.bps > 10_000
|
|
227
|
+
) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
"percent_of_reference bps must be an integer between 1 and 10000",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
amountDependencies: [frameKey(expression.reference)],
|
|
234
|
+
amountMode: "runtime_bounded",
|
|
235
|
+
};
|
|
236
|
+
case "remainder": {
|
|
237
|
+
if (expression.consumed.length === 0) {
|
|
238
|
+
throw new Error("remainder requires at least one consumed event");
|
|
239
|
+
}
|
|
240
|
+
const dependencies = canonicalDependencies(
|
|
241
|
+
expression.source,
|
|
242
|
+
expression.consumed,
|
|
243
|
+
);
|
|
244
|
+
return {
|
|
245
|
+
amountDependencies: dependencies,
|
|
246
|
+
amountMode: "remaining_balance",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function canonicalDependencies(
|
|
253
|
+
source: string,
|
|
254
|
+
dependents: readonly string[],
|
|
255
|
+
): readonly string[] {
|
|
256
|
+
const all = [source, ...dependents].map(frameKey);
|
|
257
|
+
if (new Set(all).size !== all.length) {
|
|
258
|
+
throw new Error("amount dependency event keys must be distinct");
|
|
259
|
+
}
|
|
260
|
+
return all;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface EventSpec {
|
|
140
264
|
readonly amount: string;
|
|
265
|
+
readonly amountDependency?: AmountDependencyExpression;
|
|
141
266
|
readonly fromActor: string;
|
|
142
267
|
readonly key: string;
|
|
143
268
|
readonly kind: string;
|
|
@@ -212,12 +337,26 @@ function frameKey(key: string): string {
|
|
|
212
337
|
return `${key.slice(0, 33)}_${(hash >>> 0).toString(36).slice(0, 6)}`;
|
|
213
338
|
}
|
|
214
339
|
|
|
215
|
-
function
|
|
340
|
+
function dependentAmountDescription(spec: EventSpec): string {
|
|
341
|
+
const expression = spec.amountDependency;
|
|
342
|
+
if (!expression) return spec.amount;
|
|
343
|
+
switch (expression.kind) {
|
|
344
|
+
case "bounded_by_reference":
|
|
345
|
+
return `Bounded by ${expression.reference}: ${spec.amount}`;
|
|
346
|
+
case "net_of_offsets":
|
|
347
|
+
return `Net of ${expression.source} after ${expression.offsets.join(", ")}: ${spec.amount}`;
|
|
348
|
+
case "percent_of_reference":
|
|
349
|
+
return `${formatBps(expression.bps)} of ${expression.reference}: ${spec.amount}`;
|
|
350
|
+
case "remainder":
|
|
351
|
+
return `Remainder of ${expression.source} after ${expression.consumed.join(", ")}: ${spec.amount}`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function mintEvent(spec: EventSpec): Json {
|
|
216
356
|
return {
|
|
217
357
|
allocationTotalBps: 0,
|
|
218
|
-
amount: spec
|
|
219
|
-
|
|
220
|
-
amountMode: "fixed",
|
|
358
|
+
amount: dependentAmountDescription(spec),
|
|
359
|
+
...lowerAmountDependency(spec.amountDependency),
|
|
221
360
|
amountSchedule: [],
|
|
222
361
|
distribution: "single",
|
|
223
362
|
fromActor: spec.fromActor,
|
|
@@ -285,6 +424,48 @@ function dateFieldSpec(desc: string): Json {
|
|
|
285
424
|
return { desc, type: "date" };
|
|
286
425
|
}
|
|
287
426
|
|
|
427
|
+
function optionalDateFieldSpec(desc: string): Json {
|
|
428
|
+
return { desc, type: "date?" };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function derivedNounPrefix(noun: Json): string {
|
|
432
|
+
if (typeof noun.prefix === "string") return noun.prefix;
|
|
433
|
+
const id = noun.id as string;
|
|
434
|
+
const words = id.split("_");
|
|
435
|
+
const derived =
|
|
436
|
+
words.length > 1 ? words.map((word) => word[0]).join("") : id.slice(0, 4);
|
|
437
|
+
return derived.slice(0, 8).padEnd(2, "x");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function allocatedGeneratedPrefixes(
|
|
441
|
+
nouns: readonly Json[],
|
|
442
|
+
generatedIds: ReadonlySet<string>,
|
|
443
|
+
): Json[] {
|
|
444
|
+
const used = new Set(
|
|
445
|
+
nouns
|
|
446
|
+
.filter((noun) => !generatedIds.has(noun.id as string))
|
|
447
|
+
.map(derivedNounPrefix),
|
|
448
|
+
);
|
|
449
|
+
let ordinal = 0;
|
|
450
|
+
const nextPrefix = (): string => {
|
|
451
|
+
while (true) {
|
|
452
|
+
const high = String.fromCharCode(97 + Math.floor(ordinal / 26));
|
|
453
|
+
const low = String.fromCharCode(97 + (ordinal % 26));
|
|
454
|
+
ordinal += 1;
|
|
455
|
+
const candidate = `zz${high}${low}`;
|
|
456
|
+
if (!used.has(candidate)) {
|
|
457
|
+
used.add(candidate);
|
|
458
|
+
return candidate;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
return nouns.map((noun) =>
|
|
463
|
+
generatedIds.has(noun.id as string)
|
|
464
|
+
? { ...noun, prefix: nextPrefix() }
|
|
465
|
+
: noun,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
288
469
|
// ---------------------------------------------------------------------------
|
|
289
470
|
// The whole-program lowering
|
|
290
471
|
|
|
@@ -296,6 +477,8 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
296
477
|
const rules: Json[] = [];
|
|
297
478
|
const design: string[] = [];
|
|
298
479
|
const feeLines: Json[] = [];
|
|
480
|
+
const repeatableCounterparties: RepeatableCounterpartyRole[] = [];
|
|
481
|
+
const generatedPrefixNounIds = new Set<string>();
|
|
299
482
|
const mintedKeys = new Map<string, string>();
|
|
300
483
|
const portsByName = new Map(program.ports.map((port) => [port.name, port]));
|
|
301
484
|
|
|
@@ -325,6 +508,33 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
325
508
|
: [],
|
|
326
509
|
),
|
|
327
510
|
);
|
|
511
|
+
const recoursesByAdvance = new Map(
|
|
512
|
+
program.settlements.flatMap((settlement) => {
|
|
513
|
+
if (
|
|
514
|
+
settlement.archetype !== "advance" ||
|
|
515
|
+
settlement.source.kind !== "carve"
|
|
516
|
+
) {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
const recourses = program.settlements.filter(
|
|
520
|
+
(candidate): candidate is CheckedScheduled =>
|
|
521
|
+
candidate.archetype === "scheduled" &&
|
|
522
|
+
candidate.mode === "transfer" &&
|
|
523
|
+
candidate.payer === settlement.advanced &&
|
|
524
|
+
candidate.payee === settlement.funder &&
|
|
525
|
+
candidate.amount.name === settlement.amount.name &&
|
|
526
|
+
candidate.amount.currency === settlement.amount.currency,
|
|
527
|
+
);
|
|
528
|
+
return [[settlement.name, recourses] as const];
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
const collectionByObligation = new Map(
|
|
532
|
+
program.settlements.flatMap((settlement) =>
|
|
533
|
+
settlement.archetype === "recurring_collection"
|
|
534
|
+
? [[settlement.obligation.settlement, settlement] as const]
|
|
535
|
+
: [],
|
|
536
|
+
),
|
|
537
|
+
);
|
|
328
538
|
|
|
329
539
|
for (const settlement of program.settlements) {
|
|
330
540
|
let lowered: LoweredNoun | undefined;
|
|
@@ -344,14 +554,85 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
344
554
|
);
|
|
345
555
|
break;
|
|
346
556
|
}
|
|
557
|
+
case "captured_payment": {
|
|
558
|
+
const correction = portFor(
|
|
559
|
+
settlement,
|
|
560
|
+
settlement.correction.port,
|
|
561
|
+
settlement.correction.origin,
|
|
562
|
+
);
|
|
563
|
+
const externalReversal = portFor(
|
|
564
|
+
settlement,
|
|
565
|
+
settlement.externalReversal.port,
|
|
566
|
+
settlement.externalReversal.origin,
|
|
567
|
+
);
|
|
568
|
+
if (!correction || !externalReversal) continue;
|
|
569
|
+
lowered = lowerCaptureReservation(
|
|
570
|
+
settlement,
|
|
571
|
+
correction,
|
|
572
|
+
externalReversal,
|
|
573
|
+
issues,
|
|
574
|
+
);
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
case "settlement_batch": {
|
|
578
|
+
const acknowledgement = portFor(
|
|
579
|
+
settlement,
|
|
580
|
+
settlement.payoutAcknowledgement.port,
|
|
581
|
+
settlement.payoutAcknowledgement.origin,
|
|
582
|
+
);
|
|
583
|
+
if (!acknowledgement) continue;
|
|
584
|
+
lowered = lowerSettlementBatch(settlement, acknowledgement, issues);
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
case "funding_round":
|
|
588
|
+
lowered = lowerFundingRound(settlement);
|
|
589
|
+
break;
|
|
590
|
+
case "weighted_distribution": {
|
|
591
|
+
const snapshot = portFor(
|
|
592
|
+
settlement,
|
|
593
|
+
settlement.snapshot.port,
|
|
594
|
+
settlement.snapshot.origin,
|
|
595
|
+
);
|
|
596
|
+
if (!snapshot) continue;
|
|
597
|
+
lowered = lowerWeightedDistribution(settlement, snapshot);
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
case "credit_facility":
|
|
601
|
+
lowered = lowerCreditFacility(settlement);
|
|
602
|
+
break;
|
|
603
|
+
case "recurring_collection":
|
|
604
|
+
// The referenced scheduled obligation owns the payment nouns, amount
|
|
605
|
+
// allocation, and delinquency. Its lowerer adds the explicit mandate
|
|
606
|
+
// evidence gate, so this declaration mints no second noun or event.
|
|
607
|
+
continue;
|
|
608
|
+
case "conditional_disbursement": {
|
|
609
|
+
const decision = portFor(
|
|
610
|
+
settlement,
|
|
611
|
+
settlement.decision.port,
|
|
612
|
+
settlement.decision.origin,
|
|
613
|
+
);
|
|
614
|
+
if (!decision) continue;
|
|
615
|
+
lowered = lowerConditionalDisbursement(settlement, decision);
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
case "rotating_pool":
|
|
619
|
+
lowered = lowerRotatingPool(settlement);
|
|
620
|
+
break;
|
|
347
621
|
case "premium_forward": {
|
|
348
622
|
const port = portFor(
|
|
349
623
|
settlement,
|
|
350
624
|
settlement.bind.port,
|
|
351
625
|
settlement.bind.origin,
|
|
352
626
|
);
|
|
353
|
-
|
|
354
|
-
|
|
627
|
+
const endorsement = settlement.endorsement
|
|
628
|
+
? portFor(
|
|
629
|
+
settlement,
|
|
630
|
+
settlement.endorsement.port,
|
|
631
|
+
settlement.endorsement.origin,
|
|
632
|
+
)
|
|
633
|
+
: undefined;
|
|
634
|
+
if (!port || (settlement.endorsement && !endorsement)) continue;
|
|
635
|
+
lowered = lowerPremiumForward(settlement, port, endorsement, issues);
|
|
355
636
|
break;
|
|
356
637
|
}
|
|
357
638
|
case "deposit": {
|
|
@@ -373,10 +654,27 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
373
654
|
lowered = lowerInstantTransfer(settlement);
|
|
374
655
|
break;
|
|
375
656
|
case "scheduled":
|
|
376
|
-
lowered =
|
|
657
|
+
lowered =
|
|
658
|
+
settlement.mode === "obligation"
|
|
659
|
+
? lowerScheduledObligation(
|
|
660
|
+
settlement,
|
|
661
|
+
collectionByObligation.get(settlement.name),
|
|
662
|
+
collectionByObligation.has(settlement.name)
|
|
663
|
+
? portFor(
|
|
664
|
+
collectionByObligation.get(settlement.name)!,
|
|
665
|
+
collectionByObligation.get(settlement.name)!.mandate.port,
|
|
666
|
+
collectionByObligation.get(settlement.name)!.mandate
|
|
667
|
+
.origin,
|
|
668
|
+
)
|
|
669
|
+
: undefined,
|
|
670
|
+
)
|
|
671
|
+
: lowerScheduled(settlement);
|
|
377
672
|
break;
|
|
378
673
|
case "advance":
|
|
379
|
-
lowered = lowerAdvance(
|
|
674
|
+
lowered = lowerAdvance(
|
|
675
|
+
settlement,
|
|
676
|
+
recoursesByAdvance.get(settlement.name) ?? [],
|
|
677
|
+
);
|
|
380
678
|
break;
|
|
381
679
|
case "metered":
|
|
382
680
|
lowered = lowerMetered(settlement);
|
|
@@ -403,6 +701,23 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
403
701
|
}
|
|
404
702
|
}
|
|
405
703
|
if (!lowered) continue;
|
|
704
|
+
const derivedAmounts = (program.derivedAmounts ?? []).filter(
|
|
705
|
+
(amount) => amount.settlement === settlement.name,
|
|
706
|
+
);
|
|
707
|
+
if (derivedAmounts.length > 0) {
|
|
708
|
+
lowered = addDerivedAmounts(lowered, settlement, derivedAmounts, issues);
|
|
709
|
+
if (!lowered) continue;
|
|
710
|
+
}
|
|
711
|
+
const localCap =
|
|
712
|
+
ARCHETYPE_DEFINITIONS[settlement.archetype].eventCap +
|
|
713
|
+
derivedAmounts.length;
|
|
714
|
+
if (lowered.moneyEvents.length > localCap) {
|
|
715
|
+
issues.push({
|
|
716
|
+
message: `settlement ${settlement.name} emits ${lowered.moneyEvents.length} money events, but ${settlement.archetype} carries a local cap of ${localCap}`,
|
|
717
|
+
span: settlement.origin,
|
|
718
|
+
});
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
406
721
|
// Event and rule keys concatenate settlement names with generated stems,
|
|
407
722
|
// so two settlements can mint the same key (a + b_service_fee vs a_b +
|
|
408
723
|
// service_fee). The frame schema refuses duplicates wholesale, which
|
|
@@ -419,11 +734,38 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
419
734
|
mintedKeys.set(key, settlement.name);
|
|
420
735
|
}
|
|
421
736
|
settlements.push(lowered.settlement);
|
|
422
|
-
|
|
737
|
+
const loweredNouns = [lowered.noun, ...(lowered.extraNouns ?? [])];
|
|
738
|
+
for (const noun of loweredNouns) {
|
|
739
|
+
const verbs = noun.verbs as Record<string, Json>;
|
|
740
|
+
for (const [verbName, verb] of Object.entries(verbs)) {
|
|
741
|
+
if (
|
|
742
|
+
Object.hasOwn(verb, "due") ||
|
|
743
|
+
Object.hasOwn(verb, "requiresSettlement")
|
|
744
|
+
) {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const publicIntent = callerDrivenPublicIntent(
|
|
748
|
+
noun.id as string,
|
|
749
|
+
verbName,
|
|
750
|
+
);
|
|
751
|
+
if (publicIntent.length <= PUBLIC_INTENT_BUDGET) continue;
|
|
752
|
+
issues.push({
|
|
753
|
+
message: `settlement ${settlement.name} generates public intent "${publicIntent}" with ${publicIntent.length} characters; rename the settlement so each public intent fits the ${PUBLIC_INTENT_BUDGET}-character camelName limit`,
|
|
754
|
+
span: settlement.origin,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
nouns.push(...loweredNouns);
|
|
759
|
+
for (const nounId of lowered.generatedPrefixNounIds ?? []) {
|
|
760
|
+
generatedPrefixNounIds.add(nounId);
|
|
761
|
+
}
|
|
423
762
|
moneyEvents.push(...lowered.moneyEvents);
|
|
424
763
|
rules.push(...lowered.rules);
|
|
425
764
|
design.push(...lowered.design);
|
|
426
765
|
feeLines.push(...lowered.feeLines);
|
|
766
|
+
if (lowered.repeatableCounterparty) {
|
|
767
|
+
repeatableCounterparties.push(lowered.repeatableCounterparty);
|
|
768
|
+
}
|
|
427
769
|
}
|
|
428
770
|
|
|
429
771
|
if (moneyEvents.length > MONEY_EVENT_BUDGET) {
|
|
@@ -432,8 +774,20 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
432
774
|
span: program.settlements[0]?.origin ?? { end: 0, start: 0 },
|
|
433
775
|
});
|
|
434
776
|
}
|
|
777
|
+
issues.push(
|
|
778
|
+
...validateAmountDependencyGraph(
|
|
779
|
+
moneyEvents,
|
|
780
|
+
program.settlements[0]?.origin ?? { end: 0, start: 0 },
|
|
781
|
+
),
|
|
782
|
+
);
|
|
783
|
+
const actorLowering = lowerFrameActors(program, repeatableCounterparties);
|
|
784
|
+
issues.push(...actorLowering.issues);
|
|
435
785
|
if (issues.length > 0) return { issues, ok: false };
|
|
436
786
|
|
|
787
|
+
const publishedNouns = publishCallerDrivenVerbs(
|
|
788
|
+
allocatedGeneratedPrefixes(nouns, generatedPrefixNounIds),
|
|
789
|
+
);
|
|
790
|
+
|
|
437
791
|
const subjects = program.assets.map((asset) => ({
|
|
438
792
|
kind: asset.name,
|
|
439
793
|
title: titleize(asset.name),
|
|
@@ -442,32 +796,14 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
442
796
|
|
|
443
797
|
const document: Json = {
|
|
444
798
|
hsx: HSX_IR_VERSION,
|
|
445
|
-
nouns,
|
|
799
|
+
nouns: publishedNouns,
|
|
446
800
|
product: program.name,
|
|
447
801
|
...(subjects.length > 0 ? { subjects } : {}),
|
|
448
802
|
title: program.title,
|
|
449
803
|
};
|
|
450
804
|
|
|
451
|
-
const roles = partyRoles(program.settlements);
|
|
452
805
|
const frame: Json = {
|
|
453
|
-
actors:
|
|
454
|
-
...program.parties
|
|
455
|
-
.filter((party) => roles.has(party.name))
|
|
456
|
-
.map((party) => ({
|
|
457
|
-
key: party.name,
|
|
458
|
-
label: titleize(party.name),
|
|
459
|
-
maxCount: 1,
|
|
460
|
-
minCount: 1,
|
|
461
|
-
role: roles.get(party.name),
|
|
462
|
-
})),
|
|
463
|
-
{
|
|
464
|
-
key: "platform",
|
|
465
|
-
label: "Platform",
|
|
466
|
-
maxCount: 1,
|
|
467
|
-
minCount: 1,
|
|
468
|
-
role: "platform",
|
|
469
|
-
},
|
|
470
|
-
],
|
|
806
|
+
actors: actorLowering.actors,
|
|
471
807
|
confidence: "high",
|
|
472
808
|
conservationGroups: [],
|
|
473
809
|
design,
|
|
@@ -497,6 +833,293 @@ export function lowerProgram(program: CheckedProgram): LowerResult {
|
|
|
497
833
|
};
|
|
498
834
|
}
|
|
499
835
|
|
|
836
|
+
function publishCallerDrivenVerbs(nouns: readonly Json[]): Json[] {
|
|
837
|
+
return nouns.map((noun) => {
|
|
838
|
+
const verbs = noun.verbs as Record<string, Json>;
|
|
839
|
+
return {
|
|
840
|
+
...noun,
|
|
841
|
+
verbs: Object.fromEntries(
|
|
842
|
+
Object.entries(verbs).map(([verbName, verb]) => [
|
|
843
|
+
verbName,
|
|
844
|
+
Object.hasOwn(verb, "due") ||
|
|
845
|
+
Object.hasOwn(verb, "requiresSettlement")
|
|
846
|
+
? verb
|
|
847
|
+
: {
|
|
848
|
+
...verb,
|
|
849
|
+
publicIntent: callerDrivenPublicIntent(
|
|
850
|
+
noun.id as string,
|
|
851
|
+
verbName,
|
|
852
|
+
),
|
|
853
|
+
},
|
|
854
|
+
]),
|
|
855
|
+
),
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function callerDrivenPublicIntent(nounId: string, verbName: string): string {
|
|
861
|
+
const nounName = camelize(nounId);
|
|
862
|
+
const domainName = nounName.charAt(0).toUpperCase() + nounName.slice(1);
|
|
863
|
+
return `${camelize(verbName)}${domainName}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Add generic on-top amounts after archetype lowering, so no brick owns fee syntax. */
|
|
867
|
+
function addDerivedAmounts(
|
|
868
|
+
lowered: LoweredNoun,
|
|
869
|
+
settlement: CheckedSettlement,
|
|
870
|
+
amounts: readonly CheckedDerivedAmount[],
|
|
871
|
+
issues: LoweringIssue[],
|
|
872
|
+
): LoweredNoun | undefined {
|
|
873
|
+
const noun = lowered.noun;
|
|
874
|
+
const fields = { ...((noun.fields as Json | undefined) ?? {}) };
|
|
875
|
+
const verbs = { ...((noun.verbs as Json | undefined) ?? {}) };
|
|
876
|
+
const create = { ...((verbs.create as Json | undefined) ?? {}) };
|
|
877
|
+
const moves = [...((create.moves as Json[] | undefined) ?? [])];
|
|
878
|
+
const actors = { ...((noun.actors as Json | undefined) ?? {}) };
|
|
879
|
+
const derived: Json[] = [];
|
|
880
|
+
const events = [...lowered.moneyEvents];
|
|
881
|
+
const lines = [...lowered.feeLines];
|
|
882
|
+
for (const amount of amounts) {
|
|
883
|
+
const sourceField = fields[amount.baseField] as Json | undefined;
|
|
884
|
+
if (sourceField === undefined || sourceField.type !== "money") {
|
|
885
|
+
issues.push({
|
|
886
|
+
message: `settlement ${settlement.name} derives ${amount.field} from ${sourceField === undefined ? "unknown " : "non-money "}field ${amount.baseField}; from must name a stored money field on the settlement owner`,
|
|
887
|
+
span: amount.origin,
|
|
888
|
+
});
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
891
|
+
if (fields[amount.field] !== undefined) {
|
|
892
|
+
issues.push({
|
|
893
|
+
message: `settlement ${settlement.name} derives into existing field ${amount.field}; choose a new derived amount field`,
|
|
894
|
+
span: amount.origin,
|
|
895
|
+
});
|
|
896
|
+
return undefined;
|
|
897
|
+
}
|
|
898
|
+
const eventKey = frameKey(`${settlement.name}_derived_amount`);
|
|
899
|
+
fields[amount.field] = moneyFieldSpec(
|
|
900
|
+
`Machine-computed ${formatBps(amount.bps)} of ${amount.baseField}; callers never supply it`,
|
|
901
|
+
);
|
|
902
|
+
if (actors[amount.bearer] === undefined) {
|
|
903
|
+
actors[amount.bearer] = "payer";
|
|
904
|
+
}
|
|
905
|
+
actors.platform = "beneficiary";
|
|
906
|
+
derived.push({
|
|
907
|
+
field: amount.field,
|
|
908
|
+
rounding: "floor",
|
|
909
|
+
rule: { bps: amount.bps, kind: "percentage_of" },
|
|
910
|
+
sourceField: amount.baseField,
|
|
911
|
+
});
|
|
912
|
+
moves.push({
|
|
913
|
+
amount: amount.field,
|
|
914
|
+
from: amount.bearer,
|
|
915
|
+
key: "derived_amount",
|
|
916
|
+
moneyEvent: eventKey,
|
|
917
|
+
operation: "create",
|
|
918
|
+
to: "platform",
|
|
919
|
+
});
|
|
920
|
+
events.push(
|
|
921
|
+
mintEvent({
|
|
922
|
+
amount: `The machine-computed ${amount.field}`,
|
|
923
|
+
fromActor: amount.bearer,
|
|
924
|
+
key: eventKey,
|
|
925
|
+
kind: "charge",
|
|
926
|
+
toActor: "platform",
|
|
927
|
+
trigger: `Collect ${amount.field} with settlement creation`,
|
|
928
|
+
}),
|
|
929
|
+
);
|
|
930
|
+
lines.push({
|
|
931
|
+
label: titleize(amount.field),
|
|
932
|
+
on: `each ${settlement.name.replaceAll("_", " ")}`,
|
|
933
|
+
structure: `${formatBps(amount.bps)} of stored ${amount.baseField}, computed by the runtime`,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
create.moves = moves;
|
|
937
|
+
verbs.create = create;
|
|
938
|
+
return {
|
|
939
|
+
...lowered,
|
|
940
|
+
design: [
|
|
941
|
+
...lowered.design,
|
|
942
|
+
`${settlement.name}: derived amounts are machine-computed from stored source fields before create movements; fixed and tiered rules are refused`,
|
|
943
|
+
],
|
|
944
|
+
feeLines: lines,
|
|
945
|
+
moneyEvents: events,
|
|
946
|
+
noun: {
|
|
947
|
+
...noun,
|
|
948
|
+
actors,
|
|
949
|
+
derivedAmounts: derived,
|
|
950
|
+
fields,
|
|
951
|
+
verbs,
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Lower fixed parties plus future settlement-declared repeating roles. */
|
|
957
|
+
export interface FrameActorLoweringResult {
|
|
958
|
+
readonly actors: readonly Record<string, unknown>[];
|
|
959
|
+
readonly issues: readonly LoweringIssue[];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export function lowerFrameActors(
|
|
963
|
+
program: Pick<CheckedProgram, "parties" | "settlements">,
|
|
964
|
+
repeatableCounterparties: readonly RepeatableCounterpartyRole[] = [],
|
|
965
|
+
): FrameActorLoweringResult {
|
|
966
|
+
const roles = partyRoles(program.settlements);
|
|
967
|
+
const parties = new Set(program.parties.map((party) => party.name));
|
|
968
|
+
const overrides = new Map<string, RepeatableCounterpartyRole>();
|
|
969
|
+
const issues: LoweringIssue[] = [];
|
|
970
|
+
for (const counterparty of repeatableCounterparties) {
|
|
971
|
+
if (!parties.has(counterparty.key)) {
|
|
972
|
+
issues.push({
|
|
973
|
+
message: `repeatable counterparty ${counterparty.key} is not a declared party`,
|
|
974
|
+
span: counterparty.origin,
|
|
975
|
+
});
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const fixedRole = roles.get(counterparty.key);
|
|
979
|
+
if (!fixedRole) {
|
|
980
|
+
issues.push({
|
|
981
|
+
message: `repeatable counterparty ${counterparty.key} is not used by any settlement`,
|
|
982
|
+
span: counterparty.origin,
|
|
983
|
+
});
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (fixedRole !== counterparty.role) {
|
|
987
|
+
issues.push({
|
|
988
|
+
message: `repeatable counterparty ${counterparty.key} declares role ${counterparty.role}, but its settlement uses role ${fixedRole}`,
|
|
989
|
+
span: counterparty.origin,
|
|
990
|
+
});
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (
|
|
994
|
+
counterparty.label.trim().length === 0 ||
|
|
995
|
+
counterparty.label.length > 160
|
|
996
|
+
) {
|
|
997
|
+
issues.push({
|
|
998
|
+
message: `repeatable counterparty ${counterparty.key} label must contain 1 through 160 characters`,
|
|
999
|
+
span: counterparty.origin,
|
|
1000
|
+
});
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
if (
|
|
1004
|
+
!Number.isInteger(counterparty.minCount) ||
|
|
1005
|
+
!Number.isInteger(counterparty.maxCount) ||
|
|
1006
|
+
counterparty.minCount < 1 ||
|
|
1007
|
+
counterparty.maxCount > 10_000
|
|
1008
|
+
) {
|
|
1009
|
+
issues.push({
|
|
1010
|
+
message: `repeatable counterparty ${counterparty.key} counts must be integers from 1 through 10000`,
|
|
1011
|
+
span: counterparty.origin,
|
|
1012
|
+
});
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
if (counterparty.minCount > counterparty.maxCount) {
|
|
1016
|
+
issues.push({
|
|
1017
|
+
message: `repeatable counterparty ${counterparty.key} has minCount ${counterparty.minCount} above maxCount ${counterparty.maxCount}`,
|
|
1018
|
+
span: counterparty.origin,
|
|
1019
|
+
});
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
if (overrides.has(counterparty.key)) {
|
|
1023
|
+
issues.push({
|
|
1024
|
+
message: `repeatable counterparty ${counterparty.key} is declared twice`,
|
|
1025
|
+
span: counterparty.origin,
|
|
1026
|
+
});
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
overrides.set(counterparty.key, counterparty);
|
|
1030
|
+
}
|
|
1031
|
+
const actors = [
|
|
1032
|
+
...program.parties
|
|
1033
|
+
.filter((party) => roles.has(party.name))
|
|
1034
|
+
.map((party) => {
|
|
1035
|
+
const override = overrides.get(party.name);
|
|
1036
|
+
return override
|
|
1037
|
+
? {
|
|
1038
|
+
key: override.key,
|
|
1039
|
+
label: override.label,
|
|
1040
|
+
maxCount: override.maxCount,
|
|
1041
|
+
minCount: override.minCount,
|
|
1042
|
+
role: override.role,
|
|
1043
|
+
}
|
|
1044
|
+
: {
|
|
1045
|
+
key: party.name,
|
|
1046
|
+
label: titleize(party.name),
|
|
1047
|
+
maxCount: 1,
|
|
1048
|
+
minCount: 1,
|
|
1049
|
+
role: roles.get(party.name),
|
|
1050
|
+
};
|
|
1051
|
+
}),
|
|
1052
|
+
{
|
|
1053
|
+
key: "platform",
|
|
1054
|
+
label: "Platform",
|
|
1055
|
+
maxCount: 1,
|
|
1056
|
+
minCount: 1,
|
|
1057
|
+
role: "platform",
|
|
1058
|
+
},
|
|
1059
|
+
];
|
|
1060
|
+
return { actors, issues };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Validate the completed event graph before HSX returns a frame. */
|
|
1064
|
+
export function validateAmountDependencyGraph(
|
|
1065
|
+
events: readonly Record<string, unknown>[],
|
|
1066
|
+
origin: Span,
|
|
1067
|
+
): readonly LoweringIssue[] {
|
|
1068
|
+
const issues: LoweringIssue[] = [];
|
|
1069
|
+
const dependenciesByKey = new Map<string, readonly string[]>();
|
|
1070
|
+
for (const event of events) {
|
|
1071
|
+
if (typeof event.key !== "string") continue;
|
|
1072
|
+
const dependencies = Array.isArray(event.amountDependencies)
|
|
1073
|
+
? event.amountDependencies.filter(
|
|
1074
|
+
(dependency): dependency is string => typeof dependency === "string",
|
|
1075
|
+
)
|
|
1076
|
+
: [];
|
|
1077
|
+
dependenciesByKey.set(event.key, dependencies);
|
|
1078
|
+
}
|
|
1079
|
+
for (const [key, dependencies] of dependenciesByKey) {
|
|
1080
|
+
for (const dependency of dependencies) {
|
|
1081
|
+
if (dependency === key) {
|
|
1082
|
+
issues.push({
|
|
1083
|
+
message: `money event ${key} cannot depend on itself`,
|
|
1084
|
+
span: origin,
|
|
1085
|
+
});
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (!dependenciesByKey.has(dependency)) {
|
|
1089
|
+
issues.push({
|
|
1090
|
+
message: `money event ${key} depends on missing money event ${dependency}`,
|
|
1091
|
+
span: origin,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const visiting = new Set<string>();
|
|
1097
|
+
const visited = new Set<string>();
|
|
1098
|
+
const cyclic = new Set<string>();
|
|
1099
|
+
const visit = (key: string): void => {
|
|
1100
|
+
if (visited.has(key) || cyclic.has(key)) return;
|
|
1101
|
+
if (visiting.has(key)) {
|
|
1102
|
+
cyclic.add(key);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
visiting.add(key);
|
|
1106
|
+
for (const dependency of dependenciesByKey.get(key) ?? []) {
|
|
1107
|
+
if (dependenciesByKey.has(dependency)) visit(dependency);
|
|
1108
|
+
if (cyclic.has(dependency)) cyclic.add(key);
|
|
1109
|
+
}
|
|
1110
|
+
visiting.delete(key);
|
|
1111
|
+
visited.add(key);
|
|
1112
|
+
};
|
|
1113
|
+
for (const key of dependenciesByKey.keys()) visit(key);
|
|
1114
|
+
if (cyclic.size > 0) {
|
|
1115
|
+
issues.push({
|
|
1116
|
+
message: `money event amount dependencies contain a cycle through ${[...cyclic].sort().join(", ")}`,
|
|
1117
|
+
span: origin,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
return issues;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
500
1123
|
/** Frame actor role per party, with a fixed precedence when roles overlap. */
|
|
501
1124
|
function partyRoles(
|
|
502
1125
|
settlements: readonly CheckedSettlement[],
|
|
@@ -508,12 +1131,20 @@ function partyRoles(
|
|
|
508
1131
|
for (const settlement of settlements) {
|
|
509
1132
|
switch (settlement.archetype) {
|
|
510
1133
|
case "held_payment":
|
|
1134
|
+
case "captured_payment":
|
|
511
1135
|
case "instant_transfer":
|
|
512
|
-
case "scheduled":
|
|
513
1136
|
case "metered":
|
|
514
1137
|
payers.add(settlement.payer);
|
|
515
1138
|
beneficiaries.add(settlement.payee);
|
|
516
1139
|
break;
|
|
1140
|
+
case "scheduled":
|
|
1141
|
+
payers.add(settlement.payer);
|
|
1142
|
+
beneficiaries.add(settlement.payee);
|
|
1143
|
+
if (settlement.mode === "obligation") {
|
|
1144
|
+
payers.add(settlement.debtor);
|
|
1145
|
+
if (settlement.advanceTo) beneficiaries.add(settlement.advanceTo);
|
|
1146
|
+
}
|
|
1147
|
+
break;
|
|
517
1148
|
case "premium_forward":
|
|
518
1149
|
payers.add(settlement.payer);
|
|
519
1150
|
providers.add(settlement.carrier);
|
|
@@ -530,6 +1161,36 @@ function partyRoles(
|
|
|
530
1161
|
payers.add(settlement.payer);
|
|
531
1162
|
for (const share of settlement.shares) beneficiaries.add(share.to);
|
|
532
1163
|
break;
|
|
1164
|
+
case "settlement_batch":
|
|
1165
|
+
payers.add(settlement.settlementAccount);
|
|
1166
|
+
beneficiaries.add(settlement.payoutDestination);
|
|
1167
|
+
break;
|
|
1168
|
+
case "funding_round":
|
|
1169
|
+
payers.add(settlement.contributor);
|
|
1170
|
+
beneficiaries.add(settlement.beneficiary);
|
|
1171
|
+
break;
|
|
1172
|
+
case "weighted_distribution":
|
|
1173
|
+
payers.add(settlement.source);
|
|
1174
|
+
beneficiaries.add(settlement.recipient);
|
|
1175
|
+
break;
|
|
1176
|
+
case "credit_facility":
|
|
1177
|
+
payers.add(settlement.lender);
|
|
1178
|
+
beneficiaries.add(settlement.borrower);
|
|
1179
|
+
beneficiaries.add(settlement.drawDestination);
|
|
1180
|
+
break;
|
|
1181
|
+
case "recurring_collection":
|
|
1182
|
+
break;
|
|
1183
|
+
case "conditional_disbursement":
|
|
1184
|
+
payers.add(settlement.source);
|
|
1185
|
+
beneficiaries.add(settlement.destination);
|
|
1186
|
+
break;
|
|
1187
|
+
case "rotating_pool":
|
|
1188
|
+
for (const member of settlement.members) {
|
|
1189
|
+
payers.add(member);
|
|
1190
|
+
beneficiaries.add(member);
|
|
1191
|
+
}
|
|
1192
|
+
if (settlement.guarantor) payers.add(settlement.guarantor);
|
|
1193
|
+
break;
|
|
533
1194
|
case "swap":
|
|
534
1195
|
payers.add(settlement.sides[0].party);
|
|
535
1196
|
beneficiaries.add(settlement.sides[1].party);
|
|
@@ -549,19 +1210,31 @@ function partyRoles(
|
|
|
549
1210
|
|
|
550
1211
|
const ARCHETYPE_MECHANICS: Record<CheckedSettlement["archetype"], string> = {
|
|
551
1212
|
advance: "credit",
|
|
1213
|
+
captured_payment: "escrow",
|
|
1214
|
+
conditional_disbursement: "marketplace",
|
|
1215
|
+
credit_facility: "credit",
|
|
552
1216
|
deposit: "escrow",
|
|
1217
|
+
funding_round: "credit",
|
|
553
1218
|
held_payment: "escrow",
|
|
554
1219
|
instant_transfer: "marketplace",
|
|
555
1220
|
metered: "recurring_billing",
|
|
556
1221
|
pooled_split: "marketplace",
|
|
557
1222
|
premium_forward: "insurance",
|
|
1223
|
+
recurring_collection: "recurring_billing",
|
|
1224
|
+
rotating_pool: "recurring_billing",
|
|
558
1225
|
scheduled: "recurring_billing",
|
|
1226
|
+
settlement_batch: "marketplace",
|
|
559
1227
|
swap: "escrow",
|
|
1228
|
+
weighted_distribution: "marketplace",
|
|
560
1229
|
};
|
|
561
1230
|
|
|
562
1231
|
function mechanicsOf(settlements: readonly CheckedSettlement[]): string[] {
|
|
563
1232
|
const mechanics = new Set(
|
|
564
|
-
settlements.map((settlement) =>
|
|
1233
|
+
settlements.map((settlement) =>
|
|
1234
|
+
settlement.archetype === "scheduled" && settlement.mode === "obligation"
|
|
1235
|
+
? "credit"
|
|
1236
|
+
: ARCHETYPE_MECHANICS[settlement.archetype],
|
|
1237
|
+
),
|
|
565
1238
|
);
|
|
566
1239
|
return mechanics.size > 0 ? [...mechanics] : ["escrow"];
|
|
567
1240
|
}
|
|
@@ -1016,6 +1689,7 @@ function lowerHeldPayment(
|
|
|
1016
1689
|
function lowerPremiumForward(
|
|
1017
1690
|
settlement: CheckedPremiumForward,
|
|
1018
1691
|
port: CheckedPort,
|
|
1692
|
+
endorsement: CheckedPort | undefined,
|
|
1019
1693
|
issues: LoweringIssue[],
|
|
1020
1694
|
): LoweredNoun | undefined {
|
|
1021
1695
|
const held = lowerHeldFamily(
|
|
@@ -1042,10 +1716,64 @@ function lowerPremiumForward(
|
|
|
1042
1716
|
issues,
|
|
1043
1717
|
);
|
|
1044
1718
|
if (!held) return undefined;
|
|
1719
|
+
const baseNoun = held.noun;
|
|
1720
|
+
const fields = { ...((baseNoun.fields as Json | undefined) ?? {}) };
|
|
1721
|
+
const verbs = { ...((baseNoun.verbs as Json | undefined) ?? {}) };
|
|
1722
|
+
const rules = [...held.rules];
|
|
1723
|
+
if (
|
|
1724
|
+
settlement.policyReferenceField &&
|
|
1725
|
+
settlement.renewalDueField &&
|
|
1726
|
+
settlement.endorsement &&
|
|
1727
|
+
endorsement
|
|
1728
|
+
) {
|
|
1729
|
+
fields[settlement.policyReferenceField] = {
|
|
1730
|
+
desc: "Immutable external policy reference recorded with this forward",
|
|
1731
|
+
type: "text",
|
|
1732
|
+
};
|
|
1733
|
+
fields[settlement.renewalDueField] = dateFieldSpec(
|
|
1734
|
+
"Stored renewal due condition for the forwarded policy",
|
|
1735
|
+
);
|
|
1736
|
+
verbs[settlement.endorsement.port] = {
|
|
1737
|
+
captureInput: { endorsementEvidenceReference: "evidenceReference" },
|
|
1738
|
+
from: ["released"],
|
|
1739
|
+
port: {
|
|
1740
|
+
allowed: endorsement.allowed,
|
|
1741
|
+
fields: { evidenceReference: "text" },
|
|
1742
|
+
},
|
|
1743
|
+
summary: "Record one non-money endorsement from external evidence",
|
|
1744
|
+
to: "endorsed",
|
|
1745
|
+
};
|
|
1746
|
+
const lapseRule = frameKey(`${settlement.name}_renewal_due`);
|
|
1747
|
+
verbs.lapse = {
|
|
1748
|
+
due: { field: settlement.renewalDueField, rule: lapseRule },
|
|
1749
|
+
from: ["released", "endorsed"],
|
|
1750
|
+
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
1751
|
+
summary:
|
|
1752
|
+
"Mark the forwarded policy lapsed at its stored renewal due condition",
|
|
1753
|
+
to: "lapsed",
|
|
1754
|
+
};
|
|
1755
|
+
rules.push({
|
|
1756
|
+
allowedActors: [],
|
|
1757
|
+
detail:
|
|
1758
|
+
"The stored renewal due condition changes policy state without moving money",
|
|
1759
|
+
dueDriven: true,
|
|
1760
|
+
enforcement: "platform",
|
|
1761
|
+
gatesEvent: null,
|
|
1762
|
+
key: lapseRule,
|
|
1763
|
+
kind: "deadline",
|
|
1764
|
+
label: "Policy lapses at its stored renewal due condition",
|
|
1765
|
+
tenantTunable: false,
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1045
1768
|
return {
|
|
1046
1769
|
...held,
|
|
1047
1770
|
design: [
|
|
1048
1771
|
`${settlement.name}: premium forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on ${port.name}; ${formatBps(settlement.commissionBps)} commission retained by the platform`,
|
|
1772
|
+
...(settlement.policyReferenceField
|
|
1773
|
+
? [
|
|
1774
|
+
`${settlement.name}: extends premium_forward with stored policy reference, non-money endorsement evidence, and a due-only lapse; renewal creates a new forward`,
|
|
1775
|
+
]
|
|
1776
|
+
: []),
|
|
1049
1777
|
],
|
|
1050
1778
|
feeLines:
|
|
1051
1779
|
settlement.commissionBps > 0
|
|
@@ -1059,9 +1787,12 @@ function lowerPremiumForward(
|
|
|
1059
1787
|
: [],
|
|
1060
1788
|
noun: {
|
|
1061
1789
|
...held.noun,
|
|
1790
|
+
fields,
|
|
1791
|
+
verbs,
|
|
1062
1792
|
desc: `Premium forward: the ${settlement.payer.replaceAll("_", " ")} funds the ${settlement.amount.name} into this settlement's own escrow; binding through ${port.name} forwards it to the ${settlement.carrier.replaceAll("_", " ")} exactly once, minus the platform commission`,
|
|
1063
1793
|
summary: `Premium held for the ${settlement.carrier.replaceAll("_", " ")} until the policy binds`,
|
|
1064
1794
|
},
|
|
1795
|
+
rules,
|
|
1065
1796
|
};
|
|
1066
1797
|
}
|
|
1067
1798
|
|
|
@@ -1410,6 +2141,7 @@ function lowerHeldFamily(
|
|
|
1410
2141
|
}
|
|
1411
2142
|
verbs.abandon = {
|
|
1412
2143
|
from: ["created"],
|
|
2144
|
+
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
1413
2145
|
summary: "Abandon the settlement before any money is held",
|
|
1414
2146
|
to: "abandoned",
|
|
1415
2147
|
};
|
|
@@ -1681,29 +2413,601 @@ function lowerInstantTransfer(settlement: CheckedInstantTransfer): LoweredNoun {
|
|
|
1681
2413
|
// ---------------------------------------------------------------------------
|
|
1682
2414
|
// deposit: a reservation placed, then claimed or returned
|
|
1683
2415
|
|
|
1684
|
-
function
|
|
1685
|
-
settlement:
|
|
1686
|
-
|
|
1687
|
-
|
|
2416
|
+
function lowerCaptureReservation(
|
|
2417
|
+
settlement: CheckedCaptureReservation,
|
|
2418
|
+
correctionPort: CheckedPort,
|
|
2419
|
+
reversalPort: CheckedPort,
|
|
1688
2420
|
issues: LoweringIssue[],
|
|
1689
2421
|
): LoweredNoun | undefined {
|
|
1690
2422
|
const noun = settlement.name;
|
|
1691
2423
|
const amountName = settlement.amount.name;
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
2424
|
+
const reserveRef = "authorize_reservation";
|
|
2425
|
+
const capturedRef = "capturedAmount";
|
|
2426
|
+
const reversalCutoffField = "reversalUntil";
|
|
2427
|
+
const captureVerbs = ["capture", "capture_more"];
|
|
2428
|
+
const verbNames = [
|
|
2429
|
+
"authorize",
|
|
2430
|
+
...captureVerbs,
|
|
2431
|
+
"settle",
|
|
2432
|
+
"void",
|
|
2433
|
+
"expire",
|
|
2434
|
+
"settle_on_expiry",
|
|
2435
|
+
settlement.correction.port,
|
|
2436
|
+
settlement.externalReversal.port,
|
|
2437
|
+
];
|
|
2438
|
+
if (!verbNameIssues(noun, verbNames, settlement.origin, issues)) {
|
|
1700
2439
|
return undefined;
|
|
1701
2440
|
}
|
|
1702
2441
|
|
|
1703
|
-
const
|
|
1704
|
-
const
|
|
1705
|
-
|
|
1706
|
-
|
|
2442
|
+
const reserveEventKey = frameKey(`${noun}_reserve`);
|
|
2443
|
+
const captureEventKey = frameKey(`${noun}_capture`);
|
|
2444
|
+
const correctionEventKey = frameKey(`${noun}_correction`);
|
|
2445
|
+
const reversalEventKey = frameKey(`${noun}_external_reversal`);
|
|
2446
|
+
const expiryRuleKey = frameKey(`${noun}_reservation_expiry`);
|
|
2447
|
+
const captureMove = (partialOnly: boolean): Json => ({
|
|
2448
|
+
amount: "captureAmount",
|
|
2449
|
+
capture: { [capturedRef]: "postedAmount" },
|
|
2450
|
+
key: "post",
|
|
2451
|
+
operation: "post",
|
|
2452
|
+
...(partialOnly ? { partialOnly: true } : {}),
|
|
2453
|
+
reservation: reserveRef,
|
|
2454
|
+
});
|
|
2455
|
+
const reverseMove = (): Json => ({
|
|
2456
|
+
amount: `refs.${capturedRef}`,
|
|
2457
|
+
clawbackOf: reserveRef,
|
|
2458
|
+
from: settlement.payee,
|
|
2459
|
+
key: "transfer",
|
|
2460
|
+
operation: "create",
|
|
2461
|
+
to: settlement.payer,
|
|
2462
|
+
});
|
|
2463
|
+
const verbs: Json = {
|
|
2464
|
+
create: {
|
|
2465
|
+
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
2466
|
+
to: "created",
|
|
2467
|
+
},
|
|
2468
|
+
authorize: {
|
|
2469
|
+
from: ["created"],
|
|
2470
|
+
moneyEvent: reserveEventKey,
|
|
2471
|
+
moves: [
|
|
2472
|
+
{
|
|
2473
|
+
amount: amountName,
|
|
2474
|
+
from: settlement.payer,
|
|
2475
|
+
key: "reservation",
|
|
2476
|
+
operation: "reserve",
|
|
2477
|
+
to: settlement.payee,
|
|
2478
|
+
},
|
|
2479
|
+
],
|
|
2480
|
+
summary: `Reserve the ${amountName} until ${settlement.reserveUntilField}`,
|
|
2481
|
+
to: "authorized",
|
|
2482
|
+
},
|
|
2483
|
+
capture: {
|
|
2484
|
+
deadline: { field: settlement.reserveUntilField },
|
|
2485
|
+
from: ["authorized"],
|
|
2486
|
+
moneyEvent: captureEventKey,
|
|
2487
|
+
moves: [captureMove(true)],
|
|
2488
|
+
summary: "Post one strict partial capture slice",
|
|
2489
|
+
to: "partially_captured",
|
|
2490
|
+
},
|
|
2491
|
+
capture_more: {
|
|
2492
|
+
deadline: { field: settlement.reserveUntilField },
|
|
2493
|
+
from: ["partially_captured"],
|
|
2494
|
+
moneyEvent: captureEventKey,
|
|
2495
|
+
moves: [captureMove(true)],
|
|
2496
|
+
summary: "Post another strict partial capture slice",
|
|
2497
|
+
to: "partially_captured",
|
|
2498
|
+
},
|
|
2499
|
+
settle: {
|
|
2500
|
+
deadline: { field: settlement.reserveUntilField },
|
|
2501
|
+
from: ["authorized", "partially_captured"],
|
|
2502
|
+
moneyEvent: captureEventKey,
|
|
2503
|
+
moves: [
|
|
2504
|
+
{
|
|
2505
|
+
capture: { [capturedRef]: "postedAmount" },
|
|
2506
|
+
key: "post",
|
|
2507
|
+
operation: "post",
|
|
2508
|
+
reservation: reserveRef,
|
|
2509
|
+
},
|
|
2510
|
+
],
|
|
2511
|
+
summary: "Post the full reserved remainder and settle",
|
|
2512
|
+
setsAt: {
|
|
2513
|
+
field: reversalCutoffField,
|
|
2514
|
+
offset: settlement.externalReversal.window.raw,
|
|
2515
|
+
},
|
|
2516
|
+
to: "settled",
|
|
2517
|
+
},
|
|
2518
|
+
void: {
|
|
2519
|
+
from: ["authorized"],
|
|
2520
|
+
moves: [
|
|
2521
|
+
{
|
|
2522
|
+
key: "void",
|
|
2523
|
+
operation: "void",
|
|
2524
|
+
reason: "Reservation voided before any capture",
|
|
2525
|
+
reservation: reserveRef,
|
|
2526
|
+
},
|
|
2527
|
+
],
|
|
2528
|
+
summary: "Release an entirely uncaptured reservation",
|
|
2529
|
+
to: "voided",
|
|
2530
|
+
},
|
|
2531
|
+
expire: {
|
|
2532
|
+
due: { field: settlement.reserveUntilField, rule: expiryRuleKey },
|
|
2533
|
+
from: ["authorized"],
|
|
2534
|
+
moves: [
|
|
2535
|
+
{
|
|
2536
|
+
key: "void",
|
|
2537
|
+
operation: "void",
|
|
2538
|
+
reason: "Uncaptured reservation expired",
|
|
2539
|
+
reservation: reserveRef,
|
|
2540
|
+
},
|
|
2541
|
+
],
|
|
2542
|
+
summary: "Release an uncaptured reservation at expiry",
|
|
2543
|
+
to: "expired",
|
|
2544
|
+
},
|
|
2545
|
+
settle_on_expiry: {
|
|
2546
|
+
due: { field: settlement.reserveUntilField, rule: expiryRuleKey },
|
|
2547
|
+
from: ["partially_captured"],
|
|
2548
|
+
moves: [
|
|
2549
|
+
{
|
|
2550
|
+
key: "void",
|
|
2551
|
+
operation: "void",
|
|
2552
|
+
reason: "Uncaptured remainder released at expiry",
|
|
2553
|
+
reservation: reserveRef,
|
|
2554
|
+
},
|
|
2555
|
+
],
|
|
2556
|
+
summary: "Release the uncaptured remainder and settle captured slices",
|
|
2557
|
+
setsAt: {
|
|
2558
|
+
field: reversalCutoffField,
|
|
2559
|
+
offset: settlement.externalReversal.window.raw,
|
|
2560
|
+
},
|
|
2561
|
+
to: "settled",
|
|
2562
|
+
},
|
|
2563
|
+
[settlement.correction.port]: {
|
|
2564
|
+
from: ["settled"],
|
|
2565
|
+
moneyEvent: correctionEventKey,
|
|
2566
|
+
moves: [reverseMove()],
|
|
2567
|
+
port: { allowed: correctionPort.allowed },
|
|
2568
|
+
summary: "Return the full captured amount on payee correction",
|
|
2569
|
+
to: "corrected",
|
|
2570
|
+
},
|
|
2571
|
+
[settlement.externalReversal.port]: {
|
|
2572
|
+
captureInput: { externalReference: "externalReference" },
|
|
2573
|
+
deadline: { field: reversalCutoffField },
|
|
2574
|
+
from: ["settled"],
|
|
2575
|
+
moneyEvent: reversalEventKey,
|
|
2576
|
+
moves: [reverseMove()],
|
|
2577
|
+
port: {
|
|
2578
|
+
allowed: reversalPort.allowed,
|
|
2579
|
+
fields: { externalReference: "text" },
|
|
2580
|
+
},
|
|
2581
|
+
summary: "Return the full captured amount on an external reversal",
|
|
2582
|
+
to: "reversed",
|
|
2583
|
+
},
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
const events = [
|
|
2587
|
+
mintEvent({
|
|
2588
|
+
amount: `The full ${amountName}`,
|
|
2589
|
+
fromActor: settlement.payer,
|
|
2590
|
+
key: reserveEventKey,
|
|
2591
|
+
kind: "hold",
|
|
2592
|
+
toActor: settlement.payee,
|
|
2593
|
+
trigger: `Reserve ${amountName} until ${settlement.reserveUntilField}`,
|
|
2594
|
+
}),
|
|
2595
|
+
mintEvent({
|
|
2596
|
+
amount: `Each posted slice, never more than the remaining ${amountName}`,
|
|
2597
|
+
amountDependency: {
|
|
2598
|
+
kind: "bounded_by_reference",
|
|
2599
|
+
reference: reserveEventKey,
|
|
2600
|
+
},
|
|
2601
|
+
fromActor: settlement.payer,
|
|
2602
|
+
key: captureEventKey,
|
|
2603
|
+
kind: "payout",
|
|
2604
|
+
occurrence: "repeatable",
|
|
2605
|
+
toActor: settlement.payee,
|
|
2606
|
+
trigger: "Post a capture slice or the final remainder",
|
|
2607
|
+
}),
|
|
2608
|
+
mintEvent({
|
|
2609
|
+
amount: "100% of the cumulative captured amount",
|
|
2610
|
+
amountDependency: {
|
|
2611
|
+
bps: 10_000,
|
|
2612
|
+
kind: "percent_of_reference",
|
|
2613
|
+
reference: captureEventKey,
|
|
2614
|
+
},
|
|
2615
|
+
fromActor: settlement.payee,
|
|
2616
|
+
key: correctionEventKey,
|
|
2617
|
+
kind: "refund",
|
|
2618
|
+
toActor: settlement.payer,
|
|
2619
|
+
trigger: "Apply one full payee correction",
|
|
2620
|
+
}),
|
|
2621
|
+
mintEvent({
|
|
2622
|
+
amount: "100% of the cumulative captured amount",
|
|
2623
|
+
amountDependency: {
|
|
2624
|
+
bps: 10_000,
|
|
2625
|
+
kind: "percent_of_reference",
|
|
2626
|
+
reference: captureEventKey,
|
|
2627
|
+
},
|
|
2628
|
+
fromActor: settlement.payee,
|
|
2629
|
+
key: reversalEventKey,
|
|
2630
|
+
kind: "refund",
|
|
2631
|
+
toActor: settlement.payer,
|
|
2632
|
+
trigger: "Apply one full externally decided reversal",
|
|
2633
|
+
}),
|
|
2634
|
+
];
|
|
2635
|
+
|
|
2636
|
+
return {
|
|
2637
|
+
design: [
|
|
2638
|
+
`${noun}: reserve ${amountName} until ${settlement.reserveUntilField}; capture in strict partial slices; post the remainder to settle; expiry releases only the uncaptured remainder`,
|
|
2639
|
+
`${noun}: correction and external reversal each return the full captured amount once; insufficient payee funds reject the move instead of creating a negative position`,
|
|
2640
|
+
],
|
|
2641
|
+
feeLines: [],
|
|
2642
|
+
moneyEvents: events,
|
|
2643
|
+
noun: {
|
|
2644
|
+
actors: {
|
|
2645
|
+
[settlement.payer]: "payer",
|
|
2646
|
+
[settlement.payee]: "beneficiary",
|
|
2647
|
+
},
|
|
2648
|
+
desc: `Payer reservation captured by the payee in slices within a fixed window`,
|
|
2649
|
+
fields: {
|
|
2650
|
+
[amountName]: moneyFieldSpec(
|
|
2651
|
+
`Maximum captured amount in ${settlement.amount.currency} minor units`,
|
|
2652
|
+
),
|
|
2653
|
+
[settlement.reserveUntilField]: dateFieldSpec(
|
|
2654
|
+
"Reservation expiry that releases any uncaptured remainder",
|
|
2655
|
+
),
|
|
2656
|
+
[reversalCutoffField]: {
|
|
2657
|
+
desc: "Machine-owned external reversal cutoff anchored when settlement completes",
|
|
2658
|
+
type: "date?",
|
|
2659
|
+
},
|
|
2660
|
+
},
|
|
2661
|
+
id: noun,
|
|
2662
|
+
summary: `Capture reservation from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
2663
|
+
title: titleize(noun),
|
|
2664
|
+
verbs,
|
|
2665
|
+
},
|
|
2666
|
+
rules: [
|
|
2667
|
+
{
|
|
2668
|
+
allowedActors: [],
|
|
2669
|
+
detail: `At ${settlement.reserveUntilField}, the platform releases the uncaptured remainder and preserves any posted slices`,
|
|
2670
|
+
dueDriven: true,
|
|
2671
|
+
enforcement: "platform",
|
|
2672
|
+
gatesEvent: null,
|
|
2673
|
+
key: expiryRuleKey,
|
|
2674
|
+
kind: "deadline",
|
|
2675
|
+
label: `Uncaptured remainder releases on ${settlement.reserveUntilField}`,
|
|
2676
|
+
tenantTunable: false,
|
|
2677
|
+
},
|
|
2678
|
+
{
|
|
2679
|
+
allowedActors: [...correctionPort.allowed],
|
|
2680
|
+
detail: "The payee may return the full captured amount once",
|
|
2681
|
+
dueDriven: false,
|
|
2682
|
+
enforcement: "tenant_app",
|
|
2683
|
+
gatesEvent: correctionEventKey,
|
|
2684
|
+
key: frameKey(`${noun}_${settlement.correction.port}_gate`),
|
|
2685
|
+
kind: "release_condition",
|
|
2686
|
+
label: "Full correction confirmed through the tenant backend",
|
|
2687
|
+
tenantTunable: false,
|
|
2688
|
+
},
|
|
2689
|
+
{
|
|
2690
|
+
allowedActors: [...reversalPort.allowed],
|
|
2691
|
+
detail: `A confirmed external decision may reverse the full captured amount within ${settlement.externalReversal.window.raw}; timeout moves nothing`,
|
|
2692
|
+
dueDriven: false,
|
|
2693
|
+
enforcement: "tenant_app",
|
|
2694
|
+
gatesEvent: reversalEventKey,
|
|
2695
|
+
key: frameKey(`${noun}_${settlement.externalReversal.port}_gate`),
|
|
2696
|
+
kind: "release_condition",
|
|
2697
|
+
label: "External reversal confirmed through the tenant backend",
|
|
2698
|
+
tenantTunable: false,
|
|
2699
|
+
},
|
|
2700
|
+
],
|
|
2701
|
+
settlement: { name: noun, pieces: [] },
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
// ---------------------------------------------------------------------------
|
|
2706
|
+
// settlement_batch: immutable close, signed lineage sum, one payout
|
|
2707
|
+
|
|
2708
|
+
function lowerSettlementBatch(
|
|
2709
|
+
settlement: CheckedSettlementBatch,
|
|
2710
|
+
acknowledgementPort: CheckedPort,
|
|
2711
|
+
issues: LoweringIssue[],
|
|
2712
|
+
): LoweredNoun | undefined {
|
|
2713
|
+
const noun = settlement.name;
|
|
2714
|
+
const captureEntry = `${noun}_capture_entry`;
|
|
2715
|
+
const creditAdjustment = `${noun}_credit_adjustment`;
|
|
2716
|
+
const debitAdjustment = `${noun}_debit_adjustment`;
|
|
2717
|
+
const batchIdField = `${noun.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}Id`;
|
|
2718
|
+
const payoutEventKey = frameKey(`${noun}_payout`);
|
|
2719
|
+
const closeRuleKey = frameKey(`${noun}_close`);
|
|
2720
|
+
if (
|
|
2721
|
+
!verbNameIssues(
|
|
2722
|
+
noun,
|
|
2723
|
+
[
|
|
2724
|
+
"close",
|
|
2725
|
+
"calculate",
|
|
2726
|
+
"approve",
|
|
2727
|
+
"instruct",
|
|
2728
|
+
"reconcile",
|
|
2729
|
+
settlement.payoutAcknowledgement.port,
|
|
2730
|
+
],
|
|
2731
|
+
settlement.origin,
|
|
2732
|
+
issues,
|
|
2733
|
+
)
|
|
2734
|
+
) {
|
|
2735
|
+
return undefined;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
const parentRequirement: Json = {
|
|
2739
|
+
[batchIdField]: {
|
|
2740
|
+
match: { "fields.currency": "fields.currency" },
|
|
2741
|
+
statuses: ["open"],
|
|
2742
|
+
},
|
|
2743
|
+
};
|
|
2744
|
+
const captureNoun: Json = {
|
|
2745
|
+
desc: "One gross capture entry linked to an open payout batch",
|
|
2746
|
+
fields: {
|
|
2747
|
+
amount: moneyFieldSpec("Gross captured amount in minor units"),
|
|
2748
|
+
currency: {
|
|
2749
|
+
desc: "ISO 4217 currency shared with the payout batch",
|
|
2750
|
+
type: "currency",
|
|
2751
|
+
},
|
|
2752
|
+
[batchIdField]: {
|
|
2753
|
+
desc: "Open batch this capture entry accrues into",
|
|
2754
|
+
type: `ref:${noun}`,
|
|
2755
|
+
},
|
|
2756
|
+
[settlement.sourceCaptureReferenceField]: {
|
|
2757
|
+
desc: "Immutable source capture reference",
|
|
2758
|
+
type: "text",
|
|
2759
|
+
},
|
|
2760
|
+
},
|
|
2761
|
+
id: captureEntry,
|
|
2762
|
+
summary: "Gross capture lineage entry",
|
|
2763
|
+
title: `${titleize(noun)} Capture Entry`,
|
|
2764
|
+
verbs: {
|
|
2765
|
+
create: {
|
|
2766
|
+
requires: parentRequirement,
|
|
2767
|
+
summary: "Create a capture lineage entry on an open batch",
|
|
2768
|
+
to: "created",
|
|
2769
|
+
},
|
|
2770
|
+
accrue: {
|
|
2771
|
+
from: ["created"],
|
|
2772
|
+
requires: parentRequirement,
|
|
2773
|
+
summary: "Accrue the capture entry into the open batch",
|
|
2774
|
+
to: "accrued",
|
|
2775
|
+
},
|
|
2776
|
+
},
|
|
2777
|
+
};
|
|
2778
|
+
|
|
2779
|
+
const adjustmentNoun = (id: string, direction: "credit" | "debit"): Json => ({
|
|
2780
|
+
desc: `One ${direction} adjustment linked to an open payout batch; closed batches stay unchanged`,
|
|
2781
|
+
fields: {
|
|
2782
|
+
amount: moneyFieldSpec(
|
|
2783
|
+
`${titleize(direction)} adjustment amount in minor units`,
|
|
2784
|
+
),
|
|
2785
|
+
currency: {
|
|
2786
|
+
desc: "ISO 4217 currency shared with the payout batch",
|
|
2787
|
+
type: "currency",
|
|
2788
|
+
},
|
|
2789
|
+
adjustmentReference: {
|
|
2790
|
+
desc: "Immutable explicit adjustment reference",
|
|
2791
|
+
type: "text",
|
|
2792
|
+
},
|
|
2793
|
+
[batchIdField]: {
|
|
2794
|
+
desc: "Open batch this adjustment applies to",
|
|
2795
|
+
type: `ref:${noun}`,
|
|
2796
|
+
},
|
|
2797
|
+
[settlement.externalReversalReferenceField]: {
|
|
2798
|
+
desc: "Optional externally decided reversal reference",
|
|
2799
|
+
type: "text?",
|
|
2800
|
+
},
|
|
2801
|
+
[settlement.feeReferenceField]: {
|
|
2802
|
+
desc: "Optional fee entry reference",
|
|
2803
|
+
type: "text?",
|
|
2804
|
+
},
|
|
2805
|
+
[settlement.sourceCaptureReferenceField]: {
|
|
2806
|
+
desc: "Original capture reference that this adjustment corrects",
|
|
2807
|
+
type: "text",
|
|
2808
|
+
},
|
|
2809
|
+
},
|
|
2810
|
+
id,
|
|
2811
|
+
summary: `${titleize(direction)} adjustment with capture lineage`,
|
|
2812
|
+
title: `${titleize(noun)} ${titleize(direction)} Adjustment`,
|
|
2813
|
+
verbs: {
|
|
2814
|
+
create: {
|
|
2815
|
+
requires: parentRequirement,
|
|
2816
|
+
summary: `Create a ${direction} adjustment on an open batch`,
|
|
2817
|
+
to: "created",
|
|
2818
|
+
},
|
|
2819
|
+
adjust: {
|
|
2820
|
+
from: ["created"],
|
|
2821
|
+
requires: parentRequirement,
|
|
2822
|
+
summary: `Apply the ${direction} adjustment to the open batch`,
|
|
2823
|
+
to: "applied",
|
|
2824
|
+
},
|
|
2825
|
+
correct: {
|
|
2826
|
+
from: ["created"],
|
|
2827
|
+
requires: parentRequirement,
|
|
2828
|
+
summary:
|
|
2829
|
+
"Record a later correction on this open batch instead of changing the closed source batch",
|
|
2830
|
+
to: "applied",
|
|
2831
|
+
},
|
|
2832
|
+
},
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
const verbs: Json = {
|
|
2836
|
+
create: {
|
|
2837
|
+
summary: `Open a ${titleize(noun).toLowerCase()}`,
|
|
2838
|
+
to: "open",
|
|
2839
|
+
},
|
|
2840
|
+
close: {
|
|
2841
|
+
due: { field: settlement.closeTriggerField, rule: closeRuleKey },
|
|
2842
|
+
from: ["open"],
|
|
2843
|
+
summary: "Freeze the batch and stop all new entries",
|
|
2844
|
+
to: "closed",
|
|
2845
|
+
},
|
|
2846
|
+
calculate: {
|
|
2847
|
+
from: ["closed"],
|
|
2848
|
+
signedSum: {
|
|
2849
|
+
amountRef: "netPayable",
|
|
2850
|
+
onNegative: "refuse",
|
|
2851
|
+
onZero: "refuse",
|
|
2852
|
+
sources: [
|
|
2853
|
+
{
|
|
2854
|
+
amountField: "amount",
|
|
2855
|
+
nounId: captureEntry,
|
|
2856
|
+
refField: batchIdField,
|
|
2857
|
+
sign: "add",
|
|
2858
|
+
statuses: ["accrued"],
|
|
2859
|
+
subtotalRef: "grossCaptureAmount",
|
|
2860
|
+
},
|
|
2861
|
+
{
|
|
2862
|
+
amountField: "amount",
|
|
2863
|
+
nounId: creditAdjustment,
|
|
2864
|
+
refField: batchIdField,
|
|
2865
|
+
sign: "add",
|
|
2866
|
+
statuses: ["applied"],
|
|
2867
|
+
subtotalRef: "creditAdjustmentAmount",
|
|
2868
|
+
},
|
|
2869
|
+
{
|
|
2870
|
+
amountField: "amount",
|
|
2871
|
+
nounId: debitAdjustment,
|
|
2872
|
+
refField: batchIdField,
|
|
2873
|
+
sign: "subtract",
|
|
2874
|
+
statuses: ["applied"],
|
|
2875
|
+
subtotalRef: "debitAdjustmentAmount",
|
|
2876
|
+
},
|
|
2877
|
+
],
|
|
2878
|
+
},
|
|
2879
|
+
summary: "Prove and freeze the one signed net payable amount",
|
|
2880
|
+
to: "calculated",
|
|
2881
|
+
},
|
|
2882
|
+
approve: {
|
|
2883
|
+
from: ["calculated"],
|
|
2884
|
+
summary: "Approve the frozen payable without recomputing it",
|
|
2885
|
+
to: "approved",
|
|
2886
|
+
},
|
|
2887
|
+
instruct: {
|
|
2888
|
+
from: ["approved"],
|
|
2889
|
+
moneyEvent: payoutEventKey,
|
|
2890
|
+
payout: {
|
|
2891
|
+
amount: "refs.netPayable",
|
|
2892
|
+
beneficiaryField: settlement.payoutBeneficiaryReferenceField,
|
|
2893
|
+
beneficiaryPartyField: `${camelize(settlement.payoutDestination)}AccountId`,
|
|
2894
|
+
capture: "payoutId",
|
|
2895
|
+
currencyField: "currency",
|
|
2896
|
+
sourceAccountField: `${camelize(settlement.settlementAccount)}AccountId`,
|
|
2897
|
+
speed: "standard",
|
|
2898
|
+
},
|
|
2899
|
+
summary: "Create one idempotent payout from the frozen net payable",
|
|
2900
|
+
to: "instructed",
|
|
2901
|
+
},
|
|
2902
|
+
[settlement.payoutAcknowledgement.port]: {
|
|
2903
|
+
captureInput: {
|
|
2904
|
+
acknowledgementReference: "acknowledgementReference",
|
|
2905
|
+
},
|
|
2906
|
+
from: ["instructed"],
|
|
2907
|
+
port: {
|
|
2908
|
+
allowed: acknowledgementPort.allowed,
|
|
2909
|
+
fields: { acknowledgementReference: "text" },
|
|
2910
|
+
},
|
|
2911
|
+
summary: "Record the tenant's payout acknowledgement in the receipt",
|
|
2912
|
+
to: "acknowledged",
|
|
2913
|
+
},
|
|
2914
|
+
reconcile: {
|
|
2915
|
+
from: ["instructed", "acknowledged"],
|
|
2916
|
+
requiresSettlement: {
|
|
2917
|
+
capture: "settlementEvidenceId",
|
|
2918
|
+
payoutRef: "payoutId",
|
|
2919
|
+
},
|
|
2920
|
+
summary: "Record durable evidence that the payout settled",
|
|
2921
|
+
to: "reconciled",
|
|
2922
|
+
},
|
|
2923
|
+
};
|
|
2924
|
+
|
|
2925
|
+
return {
|
|
2926
|
+
design: [
|
|
2927
|
+
`${noun}: capture entries plus signed adjustments freeze at ${settlement.closeTriggerField}; calculate persists gross, credit, debit, and net refs; negative or zero net refuses`,
|
|
2928
|
+
`${noun}: instruct creates one payout intent for the frozen refs.netPayable; only matched settlement evidence can reconcile it`,
|
|
2929
|
+
],
|
|
2930
|
+
extraNouns: [
|
|
2931
|
+
captureNoun,
|
|
2932
|
+
adjustmentNoun(creditAdjustment, "credit"),
|
|
2933
|
+
adjustmentNoun(debitAdjustment, "debit"),
|
|
2934
|
+
],
|
|
2935
|
+
feeLines: [],
|
|
2936
|
+
moneyEvents: [
|
|
2937
|
+
mintEvent({
|
|
2938
|
+
amount:
|
|
2939
|
+
"The frozen signed sum of gross capture entries plus credit adjustments minus debit adjustments",
|
|
2940
|
+
fromActor: settlement.settlementAccount,
|
|
2941
|
+
key: payoutEventKey,
|
|
2942
|
+
kind: "payout",
|
|
2943
|
+
toActor: settlement.payoutDestination,
|
|
2944
|
+
trigger: "Instruct the approved batch payout exactly once",
|
|
2945
|
+
}),
|
|
2946
|
+
],
|
|
2947
|
+
noun: {
|
|
2948
|
+
actors: {
|
|
2949
|
+
[settlement.payoutDestination]: "beneficiary",
|
|
2950
|
+
[settlement.settlementAccount]: "payer",
|
|
2951
|
+
},
|
|
2952
|
+
desc: "Immutable batch of capture lineage and signed adjustments that creates one payout",
|
|
2953
|
+
fields: {
|
|
2954
|
+
[settlement.closeTriggerField]: dateFieldSpec(
|
|
2955
|
+
"Date the open batch freezes against later entries",
|
|
2956
|
+
),
|
|
2957
|
+
currency: {
|
|
2958
|
+
desc: "ISO 4217 currency shared by the batch and payout instruction",
|
|
2959
|
+
type: "currency",
|
|
2960
|
+
},
|
|
2961
|
+
[settlement.payoutBeneficiaryReferenceField]: {
|
|
2962
|
+
desc: "Beneficiary ID for the payout instruction",
|
|
2963
|
+
type: "beneficiary",
|
|
2964
|
+
},
|
|
2965
|
+
},
|
|
2966
|
+
id: noun,
|
|
2967
|
+
summary: `Payout batch from ${settlement.settlementAccount.replaceAll("_", " ")} to ${settlement.payoutDestination.replaceAll("_", " ")}`,
|
|
2968
|
+
title: titleize(noun),
|
|
2969
|
+
verbs,
|
|
2970
|
+
},
|
|
2971
|
+
rules: [
|
|
2972
|
+
{
|
|
2973
|
+
allowedActors: [],
|
|
2974
|
+
detail: `At ${settlement.closeTriggerField}, the platform closes the batch and every child reference gate refuses later entries`,
|
|
2975
|
+
dueDriven: true,
|
|
2976
|
+
enforcement: "platform",
|
|
2977
|
+
gatesEvent: null,
|
|
2978
|
+
key: closeRuleKey,
|
|
2979
|
+
kind: "deadline",
|
|
2980
|
+
label: `Batch freezes on ${settlement.closeTriggerField}`,
|
|
2981
|
+
tenantTunable: false,
|
|
2982
|
+
},
|
|
2983
|
+
],
|
|
2984
|
+
settlement: { name: noun, pieces: [] },
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
function lowerDeposit(
|
|
2989
|
+
settlement: CheckedDeposit,
|
|
2990
|
+
claim: CheckedPort,
|
|
2991
|
+
giveBack: CheckedPort,
|
|
2992
|
+
issues: LoweringIssue[],
|
|
2993
|
+
): LoweredNoun | undefined {
|
|
2994
|
+
const noun = settlement.name;
|
|
2995
|
+
const amountName = settlement.amount.name;
|
|
2996
|
+
if (
|
|
2997
|
+
!verbNameIssues(
|
|
2998
|
+
noun,
|
|
2999
|
+
["place_deposit", claim.name, giveBack.name],
|
|
3000
|
+
settlement.origin,
|
|
3001
|
+
issues,
|
|
3002
|
+
)
|
|
3003
|
+
) {
|
|
3004
|
+
return undefined;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
const eventKey = `${noun}_hold_1`;
|
|
3008
|
+
const events = [
|
|
3009
|
+
mintEvent({
|
|
3010
|
+
amount: `The full ${amountName}`,
|
|
1707
3011
|
fromActor: settlement.payer,
|
|
1708
3012
|
key: eventKey,
|
|
1709
3013
|
kind: "hold",
|
|
@@ -1714,105 +3018,1536 @@ function lowerDeposit(
|
|
|
1714
3018
|
|
|
1715
3019
|
const verbs: Json = {
|
|
1716
3020
|
create: {
|
|
1717
|
-
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
1718
|
-
to: "created",
|
|
3021
|
+
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
3022
|
+
to: "created",
|
|
3023
|
+
},
|
|
3024
|
+
place_deposit: {
|
|
3025
|
+
from: ["created"],
|
|
3026
|
+
moves: [
|
|
3027
|
+
{
|
|
3028
|
+
key: "reservation",
|
|
3029
|
+
operation: "reserve",
|
|
3030
|
+
amount: amountName,
|
|
3031
|
+
from: settlement.payer,
|
|
3032
|
+
to: settlement.holder,
|
|
3033
|
+
},
|
|
3034
|
+
],
|
|
3035
|
+
moneyEvent: eventKey,
|
|
3036
|
+
summary: `Reserve the ${amountName} against the ${settlement.payer.replaceAll("_", " ")}'s account`,
|
|
3037
|
+
to: "held",
|
|
3038
|
+
},
|
|
3039
|
+
[claim.name]: {
|
|
3040
|
+
from: ["held"],
|
|
3041
|
+
moves: [
|
|
3042
|
+
{
|
|
3043
|
+
key: "post",
|
|
3044
|
+
operation: "post",
|
|
3045
|
+
reservation: "place_deposit_reservation",
|
|
3046
|
+
},
|
|
3047
|
+
],
|
|
3048
|
+
summary: `Claim the deposit for the ${settlement.holder.replaceAll("_", " ")} through ${claim.name}`,
|
|
3049
|
+
to: "claimed",
|
|
3050
|
+
},
|
|
3051
|
+
[giveBack.name]: {
|
|
3052
|
+
from: ["held"],
|
|
3053
|
+
summary: `Return the deposit to the ${settlement.payer.replaceAll("_", " ")} through ${giveBack.name}`,
|
|
3054
|
+
to: "returned",
|
|
3055
|
+
moves: [
|
|
3056
|
+
{
|
|
3057
|
+
key: "void",
|
|
3058
|
+
operation: "void",
|
|
3059
|
+
reason: "Deposit returned in full",
|
|
3060
|
+
reservation: "place_deposit_reservation",
|
|
3061
|
+
},
|
|
3062
|
+
],
|
|
3063
|
+
},
|
|
3064
|
+
};
|
|
3065
|
+
|
|
3066
|
+
const portRule = (port: CheckedPort, verbLabel: string): Json => ({
|
|
3067
|
+
allowedActors: [...port.allowed],
|
|
3068
|
+
detail: `${port.allowed.map(titleize).join(" or ")} decides through the tenant backend`,
|
|
3069
|
+
dueDriven: false,
|
|
3070
|
+
enforcement: "tenant_app",
|
|
3071
|
+
gatesEvent: null,
|
|
3072
|
+
key: frameKey(`${noun}_${port.name}_gate`),
|
|
3073
|
+
kind: "release_condition",
|
|
3074
|
+
label: `${verbLabel} decided through ${port.name}`,
|
|
3075
|
+
tenantTunable: false,
|
|
3076
|
+
});
|
|
3077
|
+
|
|
3078
|
+
return {
|
|
3079
|
+
design: [
|
|
3080
|
+
`${noun}: ${amountName} held as a reservation on the ${settlement.payer.replaceAll("_", " ")}'s account; claimed whole through ${claim.name} or returned whole through ${giveBack.name}`,
|
|
3081
|
+
],
|
|
3082
|
+
feeLines: [],
|
|
3083
|
+
moneyEvents: events,
|
|
3084
|
+
noun: {
|
|
3085
|
+
actors: {
|
|
3086
|
+
[settlement.payer]: "payer",
|
|
3087
|
+
[settlement.holder]: "beneficiary",
|
|
3088
|
+
},
|
|
3089
|
+
desc: `Deposit: the ${amountName} is reserved against the ${settlement.payer.replaceAll("_", " ")}'s account in the ${settlement.holder.replaceAll("_", " ")}'s favor, then claimed or returned in full`,
|
|
3090
|
+
fields: {
|
|
3091
|
+
[amountName]: moneyFieldSpec(
|
|
3092
|
+
`The deposit amount in ${settlement.amount.currency} minor units, reserved in full and fully accounted on claim or return`,
|
|
3093
|
+
),
|
|
3094
|
+
},
|
|
3095
|
+
id: noun,
|
|
3096
|
+
summary: `Refundable deposit from ${settlement.payer.replaceAll("_", " ")} held for ${settlement.holder.replaceAll("_", " ")}`,
|
|
3097
|
+
title: titleize(noun),
|
|
3098
|
+
verbs,
|
|
3099
|
+
},
|
|
3100
|
+
rules: [portRule(claim, "Claim"), portRule(giveBack, "Return")],
|
|
3101
|
+
settlement: { name: noun, pieces: [] },
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
// ---------------------------------------------------------------------------
|
|
3106
|
+
// scheduled and advance: finite due-driven anchors
|
|
3107
|
+
|
|
3108
|
+
/** Equal N-way piece widths in bps; the first anchor absorbs the remainder. */
|
|
3109
|
+
function evenPieceBps(count: number): number[] {
|
|
3110
|
+
const base = Math.floor(Number(TOTAL_BPS) / count);
|
|
3111
|
+
const widths = Array.from({ length: count }, () => base);
|
|
3112
|
+
widths[0] = Number(TOTAL_BPS) - base * (count - 1);
|
|
3113
|
+
return widths;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// ---------------------------------------------------------------------------
|
|
3117
|
+
// funding_round: aggregate commitments with threshold close and whole unwind
|
|
3118
|
+
|
|
3119
|
+
function lowerFundingRound(settlement: CheckedFundingRound): LoweredNoun {
|
|
3120
|
+
const noun = settlement.name;
|
|
3121
|
+
const child = `${noun}_commitment`;
|
|
3122
|
+
const parentRef = `${camelize(noun)}Id`;
|
|
3123
|
+
const commitEvent = frameKey(`${noun}_commit`);
|
|
3124
|
+
const cancelEvent = frameKey(`${noun}_cancel`);
|
|
3125
|
+
const collectEvent = frameKey(`${noun}_collect`);
|
|
3126
|
+
const refundEvent = frameKey(`${noun}_refund`);
|
|
3127
|
+
const closeRule = frameKey(`${noun}_close`);
|
|
3128
|
+
const aggregate = (kind: "sum_at_least" | "sum_below"): Json[] => [
|
|
3129
|
+
{
|
|
3130
|
+
check: {
|
|
3131
|
+
amountField: "amount",
|
|
3132
|
+
kind,
|
|
3133
|
+
targetField: settlement.target.name,
|
|
3134
|
+
},
|
|
3135
|
+
nounId: child,
|
|
3136
|
+
over: "children",
|
|
3137
|
+
refField: parentRef,
|
|
3138
|
+
statuses: ["committed"],
|
|
3139
|
+
},
|
|
3140
|
+
];
|
|
3141
|
+
const parentRequirement = (statuses: readonly string[]): Json => ({
|
|
3142
|
+
[parentRef]: {
|
|
3143
|
+
bind: {
|
|
3144
|
+
currency: "fields.currency",
|
|
3145
|
+
[`${camelize(settlement.beneficiary)}AccountId`]: `fields.${camelize(settlement.beneficiary)}AccountId`,
|
|
3146
|
+
},
|
|
3147
|
+
statuses,
|
|
3148
|
+
},
|
|
3149
|
+
});
|
|
3150
|
+
const transitionRequirement = (statuses: readonly string[]): Json => ({
|
|
3151
|
+
[parentRef]: {
|
|
3152
|
+
match: {
|
|
3153
|
+
"fields.currency": "fields.currency",
|
|
3154
|
+
[`fields.${camelize(settlement.beneficiary)}AccountId`]: `fields.${camelize(settlement.beneficiary)}AccountId`,
|
|
3155
|
+
},
|
|
3156
|
+
statuses,
|
|
3157
|
+
},
|
|
3158
|
+
});
|
|
3159
|
+
|
|
3160
|
+
return {
|
|
3161
|
+
design: [
|
|
3162
|
+
`${noun}: reuses the catalog funding round and commitment mechanism; the parent lock caps committed rows by target and contributor count`,
|
|
3163
|
+
`${noun}: the stored close anchor chooses threshold activation or failure; each commitment then moves whole from its own custody`,
|
|
3164
|
+
],
|
|
3165
|
+
extraNouns: [
|
|
3166
|
+
{
|
|
3167
|
+
actors: {
|
|
3168
|
+
[settlement.beneficiary]: "beneficiary",
|
|
3169
|
+
[settlement.contributor]: "payer",
|
|
3170
|
+
},
|
|
3171
|
+
desc: `One whole commitment linked to ${noun}`,
|
|
3172
|
+
escrow: true,
|
|
3173
|
+
fields: {
|
|
3174
|
+
amount: moneyFieldSpec("One whole commitment amount"),
|
|
3175
|
+
currency: {
|
|
3176
|
+
desc: "Currency derived from the funding round",
|
|
3177
|
+
type: "currency",
|
|
3178
|
+
},
|
|
3179
|
+
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
3180
|
+
},
|
|
3181
|
+
id: child,
|
|
3182
|
+
summary: `Whole commitment to ${noun}`,
|
|
3183
|
+
title: `${titleize(noun)} Commitment`,
|
|
3184
|
+
verbs: {
|
|
3185
|
+
create: {
|
|
3186
|
+
moneyEvent: commitEvent,
|
|
3187
|
+
moves: [
|
|
3188
|
+
{
|
|
3189
|
+
amount: "amount",
|
|
3190
|
+
from: settlement.contributor,
|
|
3191
|
+
key: "commit",
|
|
3192
|
+
operation: "create",
|
|
3193
|
+
to: "escrow",
|
|
3194
|
+
},
|
|
3195
|
+
],
|
|
3196
|
+
requires: parentRequirement(["open"]),
|
|
3197
|
+
requiresExposure: [
|
|
3198
|
+
{
|
|
3199
|
+
amountField: "amount",
|
|
3200
|
+
anchorField: parentRef,
|
|
3201
|
+
capField: settlement.target.name,
|
|
3202
|
+
capOnAnchor: true,
|
|
3203
|
+
childNounId: child,
|
|
3204
|
+
statuses: ["committed"],
|
|
3205
|
+
},
|
|
3206
|
+
],
|
|
3207
|
+
summary:
|
|
3208
|
+
"Store one whole commitment without exceeding the round target",
|
|
3209
|
+
to: "committed",
|
|
3210
|
+
},
|
|
3211
|
+
cancel: {
|
|
3212
|
+
from: ["committed"],
|
|
3213
|
+
moneyEvent: cancelEvent,
|
|
3214
|
+
moves: [
|
|
3215
|
+
{
|
|
3216
|
+
amount: "amount",
|
|
3217
|
+
from: "escrow",
|
|
3218
|
+
key: "cancel",
|
|
3219
|
+
operation: "create",
|
|
3220
|
+
to: settlement.contributor,
|
|
3221
|
+
},
|
|
3222
|
+
],
|
|
3223
|
+
requires: transitionRequirement(["open"]),
|
|
3224
|
+
summary: "Cancel one commitment while the round is open",
|
|
3225
|
+
to: "cancelled",
|
|
3226
|
+
},
|
|
3227
|
+
collect: {
|
|
3228
|
+
from: ["committed"],
|
|
3229
|
+
moneyEvent: collectEvent,
|
|
3230
|
+
moves: [
|
|
3231
|
+
{
|
|
3232
|
+
amount: "amount",
|
|
3233
|
+
from: "escrow",
|
|
3234
|
+
key: "collect",
|
|
3235
|
+
operation: "create",
|
|
3236
|
+
to: settlement.beneficiary,
|
|
3237
|
+
},
|
|
3238
|
+
],
|
|
3239
|
+
requires: transitionRequirement(["active"]),
|
|
3240
|
+
summary: "Collect one successful commitment whole",
|
|
3241
|
+
to: "collected",
|
|
3242
|
+
},
|
|
3243
|
+
refund: {
|
|
3244
|
+
from: ["committed"],
|
|
3245
|
+
moneyEvent: refundEvent,
|
|
3246
|
+
moves: [
|
|
3247
|
+
{
|
|
3248
|
+
amount: "amount",
|
|
3249
|
+
from: "escrow",
|
|
3250
|
+
key: "refund",
|
|
3251
|
+
operation: "create",
|
|
3252
|
+
to: settlement.contributor,
|
|
3253
|
+
},
|
|
3254
|
+
],
|
|
3255
|
+
requires: transitionRequirement(["failed"]),
|
|
3256
|
+
summary: "Refund one failed-round commitment whole",
|
|
3257
|
+
to: "refunded",
|
|
3258
|
+
},
|
|
3259
|
+
},
|
|
3260
|
+
},
|
|
3261
|
+
],
|
|
3262
|
+
generatedPrefixNounIds: [child],
|
|
3263
|
+
feeLines: [],
|
|
3264
|
+
moneyEvents: [
|
|
3265
|
+
mintEvent({
|
|
3266
|
+
amount: "One stored commitment",
|
|
3267
|
+
fromActor: settlement.contributor,
|
|
3268
|
+
key: commitEvent,
|
|
3269
|
+
kind: "charge",
|
|
3270
|
+
occurrence: "repeatable",
|
|
3271
|
+
toActor: "escrow",
|
|
3272
|
+
trigger: "Create one target-capped commitment",
|
|
3273
|
+
}),
|
|
3274
|
+
mintEvent({
|
|
3275
|
+
amount: "One stored commitment whole",
|
|
3276
|
+
fromActor: "escrow",
|
|
3277
|
+
key: cancelEvent,
|
|
3278
|
+
kind: "refund",
|
|
3279
|
+
occurrence: "repeatable",
|
|
3280
|
+
toActor: settlement.contributor,
|
|
3281
|
+
trigger: "Cancel before close",
|
|
3282
|
+
}),
|
|
3283
|
+
mintEvent({
|
|
3284
|
+
amount: "One stored commitment whole",
|
|
3285
|
+
fromActor: "escrow",
|
|
3286
|
+
key: collectEvent,
|
|
3287
|
+
kind: "payout",
|
|
3288
|
+
occurrence: "repeatable",
|
|
3289
|
+
toActor: settlement.beneficiary,
|
|
3290
|
+
trigger: "Collect after threshold close",
|
|
3291
|
+
}),
|
|
3292
|
+
mintEvent({
|
|
3293
|
+
amount: "One stored commitment whole",
|
|
3294
|
+
fromActor: "escrow",
|
|
3295
|
+
key: refundEvent,
|
|
3296
|
+
kind: "refund",
|
|
3297
|
+
occurrence: "repeatable",
|
|
3298
|
+
toActor: settlement.contributor,
|
|
3299
|
+
trigger: "Refund after failed close",
|
|
3300
|
+
}),
|
|
3301
|
+
],
|
|
3302
|
+
noun: {
|
|
3303
|
+
actors: { [settlement.beneficiary]: "beneficiary" },
|
|
3304
|
+
aggregateInvariants: [
|
|
3305
|
+
{
|
|
3306
|
+
childField: "amount",
|
|
3307
|
+
childNounId: child,
|
|
3308
|
+
childRefField: parentRef,
|
|
3309
|
+
childStatuses: ["committed"],
|
|
3310
|
+
parentField: settlement.target.name,
|
|
3311
|
+
},
|
|
3312
|
+
{
|
|
3313
|
+
count: true,
|
|
3314
|
+
childNounId: child,
|
|
3315
|
+
childRefField: parentRef,
|
|
3316
|
+
childStatuses: ["committed"],
|
|
3317
|
+
parentField: "maxContributors",
|
|
3318
|
+
},
|
|
3319
|
+
],
|
|
3320
|
+
desc: "All-or-nothing aggregate funding threshold",
|
|
3321
|
+
fields: {
|
|
3322
|
+
currency: {
|
|
3323
|
+
desc: `Currency fixed to ${settlement.target.currency}`,
|
|
3324
|
+
type: "currency",
|
|
3325
|
+
},
|
|
3326
|
+
[settlement.target.name]: moneyFieldSpec(
|
|
3327
|
+
`Funding target in ${settlement.target.currency} minor units`,
|
|
3328
|
+
),
|
|
3329
|
+
[settlement.closeByField]: dateFieldSpec("Stored close anchor"),
|
|
3330
|
+
maxContributors: {
|
|
3331
|
+
desc: `Exactly ${settlement.maxContributors} admitted contributors`,
|
|
3332
|
+
type: `const:${settlement.maxContributors}`,
|
|
3333
|
+
},
|
|
3334
|
+
},
|
|
3335
|
+
id: noun,
|
|
3336
|
+
summary: `Threshold funding round for ${settlement.beneficiary.replaceAll("_", " ")}`,
|
|
3337
|
+
title: titleize(noun),
|
|
3338
|
+
verbs: {
|
|
3339
|
+
create: { summary: "Open the funding round", to: "open" },
|
|
3340
|
+
activate: {
|
|
3341
|
+
due: { field: settlement.closeByField, rule: closeRule },
|
|
3342
|
+
from: ["open"],
|
|
3343
|
+
requiresAggregate: aggregate("sum_at_least"),
|
|
3344
|
+
summary: "Activate when commitments meet the target",
|
|
3345
|
+
to: "active",
|
|
3346
|
+
},
|
|
3347
|
+
fail: {
|
|
3348
|
+
due: { field: settlement.closeByField, rule: closeRule },
|
|
3349
|
+
from: ["open"],
|
|
3350
|
+
requiresAggregate: aggregate("sum_below"),
|
|
3351
|
+
summary: "Fail when commitments remain below target",
|
|
3352
|
+
to: "failed",
|
|
3353
|
+
},
|
|
3354
|
+
close: {
|
|
3355
|
+
from: ["active"],
|
|
3356
|
+
requiresAggregate: [
|
|
3357
|
+
{
|
|
3358
|
+
check: { kind: "all_in" },
|
|
3359
|
+
nounId: child,
|
|
3360
|
+
over: "children",
|
|
3361
|
+
refField: parentRef,
|
|
3362
|
+
statuses: ["cancelled", "collected"],
|
|
3363
|
+
},
|
|
3364
|
+
],
|
|
3365
|
+
summary:
|
|
3366
|
+
"Settle after every admitted row is collected or was cancelled before activation",
|
|
3367
|
+
to: "settled",
|
|
3368
|
+
},
|
|
3369
|
+
},
|
|
3370
|
+
},
|
|
3371
|
+
rules: [
|
|
3372
|
+
{
|
|
3373
|
+
allowedActors: [],
|
|
3374
|
+
detail:
|
|
3375
|
+
"The stored close anchor compares committed rows with the target",
|
|
3376
|
+
dueDriven: true,
|
|
3377
|
+
enforcement: "platform",
|
|
3378
|
+
gatesEvent: null,
|
|
3379
|
+
key: closeRule,
|
|
3380
|
+
kind: "deadline",
|
|
3381
|
+
label: "Round closes against its stored threshold",
|
|
3382
|
+
tenantTunable: false,
|
|
3383
|
+
},
|
|
3384
|
+
],
|
|
3385
|
+
settlement: { name: noun, pieces: [] },
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// ---------------------------------------------------------------------------
|
|
3390
|
+
// weighted_distribution: frozen weights with deterministic largest remainder
|
|
3391
|
+
|
|
3392
|
+
function lowerWeightedDistribution(
|
|
3393
|
+
settlement: CheckedWeightedDistribution,
|
|
3394
|
+
snapshot: CheckedPort,
|
|
3395
|
+
): LoweredNoun {
|
|
3396
|
+
const noun = settlement.name;
|
|
3397
|
+
const child = `${noun}_entitlement`;
|
|
3398
|
+
const parentRef = `${camelize(noun)}Id`;
|
|
3399
|
+
const payoutEvent = frameKey(`${noun}_payout`);
|
|
3400
|
+
const parentRequirement = (statuses: readonly string[]): Json => ({
|
|
3401
|
+
[parentRef]: {
|
|
3402
|
+
bind: {
|
|
3403
|
+
currency: "fields.currency",
|
|
3404
|
+
[`${camelize(settlement.source)}AccountId`]: `fields.${camelize(settlement.source)}AccountId`,
|
|
3405
|
+
},
|
|
3406
|
+
statuses,
|
|
3407
|
+
},
|
|
3408
|
+
});
|
|
3409
|
+
return {
|
|
3410
|
+
design: [
|
|
3411
|
+
`${noun}: reuses the catalog largest-remainder distribution; the evidence port freezes the claimant set before any payout`,
|
|
3412
|
+
],
|
|
3413
|
+
extraNouns: [
|
|
3414
|
+
{
|
|
3415
|
+
actors: {
|
|
3416
|
+
[settlement.recipient]: "beneficiary",
|
|
3417
|
+
[settlement.source]: "payer",
|
|
3418
|
+
},
|
|
3419
|
+
desc: `One frozen weighted entitlement in ${noun}`,
|
|
3420
|
+
fields: {
|
|
3421
|
+
currency: {
|
|
3422
|
+
desc: "Currency derived from the distribution",
|
|
3423
|
+
type: "currency",
|
|
3424
|
+
},
|
|
3425
|
+
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
3426
|
+
[settlement.weight.name]: moneyFieldSpec(
|
|
3427
|
+
"Stored non-negative entitlement weight",
|
|
3428
|
+
),
|
|
3429
|
+
},
|
|
3430
|
+
id: child,
|
|
3431
|
+
summary: `Frozen entitlement in ${noun}`,
|
|
3432
|
+
title: `${titleize(noun)} Entitlement`,
|
|
3433
|
+
verbs: {
|
|
3434
|
+
create: {
|
|
3435
|
+
requires: parentRequirement(["open"]),
|
|
3436
|
+
summary: "Record one entitlement before snapshot",
|
|
3437
|
+
to: "recorded",
|
|
3438
|
+
},
|
|
3439
|
+
payout: {
|
|
3440
|
+
distribute: {
|
|
3441
|
+
amountRef: "payoutShare",
|
|
3442
|
+
onZero: "skip_steps",
|
|
3443
|
+
pool: {
|
|
3444
|
+
from: "parent",
|
|
3445
|
+
path: `fields.${settlement.amount.name}`,
|
|
3446
|
+
},
|
|
3447
|
+
refField: parentRef,
|
|
3448
|
+
statuses: ["recorded", "paid"],
|
|
3449
|
+
weightField: settlement.weight.name,
|
|
3450
|
+
},
|
|
3451
|
+
from: ["recorded"],
|
|
3452
|
+
moneyEvent: payoutEvent,
|
|
3453
|
+
moves: [
|
|
3454
|
+
{
|
|
3455
|
+
amount: "refs.payoutShare",
|
|
3456
|
+
from: settlement.source,
|
|
3457
|
+
key: "payout",
|
|
3458
|
+
operation: "create",
|
|
3459
|
+
to: settlement.recipient,
|
|
3460
|
+
},
|
|
3461
|
+
],
|
|
3462
|
+
requires: {
|
|
3463
|
+
[parentRef]: {
|
|
3464
|
+
match: {
|
|
3465
|
+
"fields.currency": "fields.currency",
|
|
3466
|
+
[`fields.${camelize(settlement.source)}AccountId`]: `fields.${camelize(settlement.source)}AccountId`,
|
|
3467
|
+
},
|
|
3468
|
+
statuses: ["snapshotted"],
|
|
3469
|
+
},
|
|
3470
|
+
},
|
|
3471
|
+
summary: "Pay the deterministic largest-remainder share once",
|
|
3472
|
+
to: "paid",
|
|
3473
|
+
},
|
|
3474
|
+
},
|
|
3475
|
+
},
|
|
3476
|
+
],
|
|
3477
|
+
generatedPrefixNounIds: [child],
|
|
3478
|
+
feeLines: [],
|
|
3479
|
+
moneyEvents: [
|
|
3480
|
+
mintEvent({
|
|
3481
|
+
amount: "A deterministic largest-remainder share of the stored pool",
|
|
3482
|
+
fromActor: settlement.source,
|
|
3483
|
+
key: payoutEvent,
|
|
3484
|
+
kind: "payout",
|
|
3485
|
+
occurrence: "repeatable",
|
|
3486
|
+
toActor: settlement.recipient,
|
|
3487
|
+
trigger: "Pay one frozen entitlement",
|
|
3488
|
+
}),
|
|
3489
|
+
],
|
|
3490
|
+
noun: {
|
|
3491
|
+
actors: { [settlement.source]: "payer" },
|
|
3492
|
+
aggregateInvariants: [
|
|
3493
|
+
{
|
|
3494
|
+
count: true,
|
|
3495
|
+
childNounId: child,
|
|
3496
|
+
childRefField: parentRef,
|
|
3497
|
+
childStatuses: ["recorded", "paid"],
|
|
3498
|
+
parentField: "maxRecipients",
|
|
3499
|
+
},
|
|
3500
|
+
],
|
|
3501
|
+
desc: "Evidence-frozen weighted distribution",
|
|
3502
|
+
fields: {
|
|
3503
|
+
currency: {
|
|
3504
|
+
desc: `Currency fixed to ${settlement.amount.currency}`,
|
|
3505
|
+
type: "currency",
|
|
3506
|
+
},
|
|
3507
|
+
[settlement.amount.name]: moneyFieldSpec(
|
|
3508
|
+
`Distribution pool in ${settlement.amount.currency} minor units`,
|
|
3509
|
+
),
|
|
3510
|
+
[settlement.recordAtField]: dateFieldSpec("Stored record date"),
|
|
3511
|
+
maxRecipients: {
|
|
3512
|
+
desc: `Exactly ${settlement.maxRecipients} frozen entitlement rows`,
|
|
3513
|
+
type: `const:${settlement.maxRecipients}`,
|
|
3514
|
+
},
|
|
3515
|
+
},
|
|
3516
|
+
id: noun,
|
|
3517
|
+
summary: "Frozen largest-remainder distribution",
|
|
3518
|
+
title: titleize(noun),
|
|
3519
|
+
verbs: {
|
|
3520
|
+
create: { summary: "Open entitlement recording", to: "open" },
|
|
3521
|
+
[settlement.snapshot.port]: {
|
|
3522
|
+
captureInput: { snapshotEvidenceReference: "evidenceReference" },
|
|
3523
|
+
from: ["open"],
|
|
3524
|
+
port: {
|
|
3525
|
+
allowed: snapshot.allowed,
|
|
3526
|
+
fields: { evidenceReference: "text" },
|
|
3527
|
+
},
|
|
3528
|
+
summary: "Freeze the entitlement set from stored evidence",
|
|
3529
|
+
to: "snapshotted",
|
|
3530
|
+
},
|
|
3531
|
+
},
|
|
3532
|
+
},
|
|
3533
|
+
rules: [],
|
|
3534
|
+
settlement: { name: noun, pieces: [] },
|
|
3535
|
+
};
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// ---------------------------------------------------------------------------
|
|
3539
|
+
// credit_facility: draw capacity only, repayment remains on scheduled obligation
|
|
3540
|
+
|
|
3541
|
+
function lowerCreditFacility(settlement: CheckedCreditFacility): LoweredNoun {
|
|
3542
|
+
const noun = settlement.name;
|
|
3543
|
+
const child = `${noun}_draw`;
|
|
3544
|
+
const facilityRef = `${camelize(noun)}Id`;
|
|
3545
|
+
const obligationRef = `${camelize(settlement.obligation.settlement)}Id`;
|
|
3546
|
+
const drawEvent = frameKey(`${noun}_draw`);
|
|
3547
|
+
const expiryRule = frameKey(`${noun}_expiry`);
|
|
3548
|
+
const countedStatuses =
|
|
3549
|
+
settlement.availabilityPolicy === "revolving"
|
|
3550
|
+
? ["drawn"]
|
|
3551
|
+
: ["drawn", "resolved"];
|
|
3552
|
+
return {
|
|
3553
|
+
design: [
|
|
3554
|
+
`${noun}: owns reusable draw capacity only; ${settlement.obligation.settlement} remains the sole repayment and delinquency owner`,
|
|
3555
|
+
],
|
|
3556
|
+
extraNouns: [
|
|
3557
|
+
{
|
|
3558
|
+
actors: {
|
|
3559
|
+
[settlement.drawDestination]: "beneficiary",
|
|
3560
|
+
[settlement.lender]: "payer",
|
|
3561
|
+
},
|
|
3562
|
+
desc: `One capacity-capped draw linked to ${settlement.obligation.settlement}`,
|
|
3563
|
+
fields: {
|
|
3564
|
+
amount: moneyFieldSpec("One draw amount"),
|
|
3565
|
+
currency: {
|
|
3566
|
+
desc: "Currency derived from the facility",
|
|
3567
|
+
type: "currency",
|
|
3568
|
+
},
|
|
3569
|
+
[facilityRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
3570
|
+
[obligationRef]: {
|
|
3571
|
+
desc: "The sole repayment obligation",
|
|
3572
|
+
type: `ref:${settlement.obligation.settlement}`,
|
|
3573
|
+
},
|
|
3574
|
+
},
|
|
3575
|
+
id: child,
|
|
3576
|
+
summary: `Draw from ${noun}`,
|
|
3577
|
+
title: `${titleize(noun)} Draw`,
|
|
3578
|
+
verbs: {
|
|
3579
|
+
create: {
|
|
3580
|
+
moneyEvent: drawEvent,
|
|
3581
|
+
moves: [
|
|
3582
|
+
{
|
|
3583
|
+
amount: "amount",
|
|
3584
|
+
from: settlement.lender,
|
|
3585
|
+
key: "draw",
|
|
3586
|
+
operation: "create",
|
|
3587
|
+
to: settlement.drawDestination,
|
|
3588
|
+
},
|
|
3589
|
+
],
|
|
3590
|
+
requires: {
|
|
3591
|
+
[facilityRef]: {
|
|
3592
|
+
bind: {
|
|
3593
|
+
currency: "fields.currency",
|
|
3594
|
+
[`${camelize(settlement.drawDestination)}AccountId`]: `fields.${camelize(settlement.drawDestination)}AccountId`,
|
|
3595
|
+
[`${camelize(settlement.lender)}AccountId`]: `fields.${camelize(settlement.lender)}AccountId`,
|
|
3596
|
+
},
|
|
3597
|
+
statuses: ["active"],
|
|
3598
|
+
},
|
|
3599
|
+
[obligationRef]: { statuses: ["active"], unique: true },
|
|
3600
|
+
},
|
|
3601
|
+
requiresExposure: [
|
|
3602
|
+
{
|
|
3603
|
+
amountField: "amount",
|
|
3604
|
+
anchorField: facilityRef,
|
|
3605
|
+
capField: settlement.limit.name,
|
|
3606
|
+
capOnAnchor: true,
|
|
3607
|
+
childNounId: child,
|
|
3608
|
+
statuses: countedStatuses,
|
|
3609
|
+
},
|
|
3610
|
+
],
|
|
3611
|
+
summary: "Create one draw under the locked facility capacity",
|
|
3612
|
+
to: "drawn",
|
|
3613
|
+
},
|
|
3614
|
+
resolve: {
|
|
3615
|
+
from: ["drawn"],
|
|
3616
|
+
requires: {
|
|
3617
|
+
[obligationRef]: ["repaid", "written_off"],
|
|
3618
|
+
},
|
|
3619
|
+
summary:
|
|
3620
|
+
"Release revolving capacity only after the linked obligation resolves",
|
|
3621
|
+
to: "resolved",
|
|
3622
|
+
},
|
|
3623
|
+
},
|
|
3624
|
+
},
|
|
3625
|
+
],
|
|
3626
|
+
generatedPrefixNounIds: [child],
|
|
3627
|
+
feeLines: [],
|
|
3628
|
+
moneyEvents: [
|
|
3629
|
+
mintEvent({
|
|
3630
|
+
amount: "One draw under the stored facility limit",
|
|
3631
|
+
fromActor: settlement.lender,
|
|
3632
|
+
key: drawEvent,
|
|
3633
|
+
kind: "payout",
|
|
3634
|
+
occurrence: "repeatable",
|
|
3635
|
+
toActor: settlement.drawDestination,
|
|
3636
|
+
trigger: "Admit one linked draw",
|
|
3637
|
+
}),
|
|
3638
|
+
],
|
|
3639
|
+
noun: {
|
|
3640
|
+
actors: {
|
|
3641
|
+
[settlement.borrower]: "party",
|
|
3642
|
+
[settlement.drawDestination]: "beneficiary",
|
|
3643
|
+
[settlement.lender]: "payer",
|
|
3644
|
+
},
|
|
3645
|
+
desc: "Reusable capacity with repayment delegated to one scheduled obligation",
|
|
3646
|
+
fields: {
|
|
3647
|
+
currency: {
|
|
3648
|
+
desc: `Currency fixed to ${settlement.limit.currency}`,
|
|
3649
|
+
type: "currency",
|
|
3650
|
+
},
|
|
3651
|
+
[settlement.limit.name]: moneyFieldSpec(
|
|
3652
|
+
`Facility limit in ${settlement.limit.currency} minor units`,
|
|
3653
|
+
),
|
|
3654
|
+
[settlement.expiresAtField]: dateFieldSpec("Stored draw expiry"),
|
|
3655
|
+
},
|
|
3656
|
+
id: noun,
|
|
3657
|
+
summary: `Draw capacity for ${settlement.borrower.replaceAll("_", " ")}`,
|
|
3658
|
+
title: titleize(noun),
|
|
3659
|
+
verbs: {
|
|
3660
|
+
create: { summary: "Open the facility", to: "active" },
|
|
3661
|
+
freeze: {
|
|
3662
|
+
due: { field: settlement.expiresAtField, rule: expiryRule },
|
|
3663
|
+
from: ["active"],
|
|
3664
|
+
summary: "Freeze new draws at expiry",
|
|
3665
|
+
to: "frozen",
|
|
3666
|
+
},
|
|
3667
|
+
close: {
|
|
3668
|
+
from: ["active", "frozen"],
|
|
3669
|
+
requiresAggregate: [
|
|
3670
|
+
{
|
|
3671
|
+
check: { kind: "all_in" },
|
|
3672
|
+
nounId: child,
|
|
3673
|
+
over: "children",
|
|
3674
|
+
refField: facilityRef,
|
|
3675
|
+
statuses: ["resolved"],
|
|
3676
|
+
},
|
|
3677
|
+
],
|
|
3678
|
+
summary: "Close only when every admitted draw resolved",
|
|
3679
|
+
to: "closed",
|
|
3680
|
+
},
|
|
3681
|
+
},
|
|
3682
|
+
},
|
|
3683
|
+
rules: [
|
|
3684
|
+
{
|
|
3685
|
+
allowedActors: [],
|
|
3686
|
+
detail:
|
|
3687
|
+
"The stored expiry freezes new draws without changing repayment state",
|
|
3688
|
+
dueDriven: true,
|
|
3689
|
+
enforcement: "platform",
|
|
3690
|
+
gatesEvent: null,
|
|
3691
|
+
key: expiryRule,
|
|
3692
|
+
kind: "deadline",
|
|
3693
|
+
label: "Facility freezes at expiry",
|
|
3694
|
+
tenantTunable: false,
|
|
3695
|
+
},
|
|
3696
|
+
],
|
|
3697
|
+
settlement: { name: noun, pieces: [] },
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
// ---------------------------------------------------------------------------
|
|
3702
|
+
// conditional_disbursement: one evidence-gated amount under a stored cap
|
|
3703
|
+
|
|
3704
|
+
function lowerConditionalDisbursement(
|
|
3705
|
+
settlement: CheckedConditionalDisbursement,
|
|
3706
|
+
decision: CheckedPort,
|
|
3707
|
+
): LoweredNoun {
|
|
3708
|
+
const noun = settlement.name;
|
|
3709
|
+
const child = `${noun}_approved_amount`;
|
|
3710
|
+
const parentRef = `${camelize(noun)}Id`;
|
|
3711
|
+
const payoutEvent = frameKey(`${noun}_payout`);
|
|
3712
|
+
const sourceAccountField = `${camelize(settlement.source)}AccountId`;
|
|
3713
|
+
const destinationAccountField = `${camelize(settlement.destination)}AccountId`;
|
|
3714
|
+
const parentTransitionRequirement = {
|
|
3715
|
+
[parentRef]: {
|
|
3716
|
+
match: {
|
|
3717
|
+
"fields.currency": "fields.currency",
|
|
3718
|
+
[`fields.${destinationAccountField}`]: `fields.${destinationAccountField}`,
|
|
3719
|
+
[`fields.${sourceAccountField}`]: `fields.${sourceAccountField}`,
|
|
3720
|
+
},
|
|
3721
|
+
statuses: ["submitted"],
|
|
3722
|
+
},
|
|
3723
|
+
};
|
|
3724
|
+
return {
|
|
3725
|
+
design: [
|
|
3726
|
+
`${noun}: a stored external decision may approve one amount under the cap; recovery requires a separate transfer`,
|
|
3727
|
+
],
|
|
3728
|
+
extraNouns: [
|
|
3729
|
+
{
|
|
3730
|
+
actors: {
|
|
3731
|
+
[settlement.destination]: "beneficiary",
|
|
3732
|
+
[settlement.source]: "payer",
|
|
3733
|
+
},
|
|
3734
|
+
desc: `One evidence-gated amount under ${noun}`,
|
|
3735
|
+
fields: {
|
|
3736
|
+
amount: moneyFieldSpec("Approved amount under the parent cap"),
|
|
3737
|
+
currency: {
|
|
3738
|
+
desc: "Currency derived from the parent cap",
|
|
3739
|
+
type: "currency",
|
|
3740
|
+
},
|
|
3741
|
+
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
3742
|
+
},
|
|
3743
|
+
id: child,
|
|
3744
|
+
summary: `Approved amount under ${noun}`,
|
|
3745
|
+
title: `${titleize(noun)} Approved Amount`,
|
|
3746
|
+
verbs: {
|
|
3747
|
+
create: {
|
|
3748
|
+
requires: {
|
|
3749
|
+
[parentRef]: {
|
|
3750
|
+
bind: {
|
|
3751
|
+
currency: "fields.currency",
|
|
3752
|
+
[destinationAccountField]: `fields.${destinationAccountField}`,
|
|
3753
|
+
[sourceAccountField]: `fields.${sourceAccountField}`,
|
|
3754
|
+
},
|
|
3755
|
+
statuses: ["submitted"],
|
|
3756
|
+
unique: true,
|
|
3757
|
+
},
|
|
3758
|
+
},
|
|
3759
|
+
summary: "Create one candidate amount under the parent",
|
|
3760
|
+
to: "created",
|
|
3761
|
+
},
|
|
3762
|
+
approve: {
|
|
3763
|
+
captureInput: { decisionEvidenceReference: "evidenceReference" },
|
|
3764
|
+
from: ["created"],
|
|
3765
|
+
port: {
|
|
3766
|
+
allowed: decision.allowed,
|
|
3767
|
+
fields: { evidenceReference: "text" },
|
|
3768
|
+
},
|
|
3769
|
+
requires: parentTransitionRequirement,
|
|
3770
|
+
requiresExposure: [
|
|
3771
|
+
{
|
|
3772
|
+
amountField: "amount",
|
|
3773
|
+
anchorField: parentRef,
|
|
3774
|
+
capField: settlement.cap.name,
|
|
3775
|
+
capOnAnchor: true,
|
|
3776
|
+
childNounId: child,
|
|
3777
|
+
statuses: ["approved", "paid"],
|
|
3778
|
+
},
|
|
3779
|
+
],
|
|
3780
|
+
summary: "Store one externally approved amount under the cap",
|
|
3781
|
+
to: "approved",
|
|
3782
|
+
},
|
|
3783
|
+
pay: {
|
|
3784
|
+
from: ["approved"],
|
|
3785
|
+
moneyEvent: payoutEvent,
|
|
3786
|
+
moves: [
|
|
3787
|
+
{
|
|
3788
|
+
amount: "amount",
|
|
3789
|
+
from: settlement.source,
|
|
3790
|
+
key: "payout",
|
|
3791
|
+
operation: "create",
|
|
3792
|
+
to: settlement.destination,
|
|
3793
|
+
},
|
|
3794
|
+
],
|
|
3795
|
+
requires: parentTransitionRequirement,
|
|
3796
|
+
summary: "Pay the stored approved amount once",
|
|
3797
|
+
to: "paid",
|
|
3798
|
+
},
|
|
3799
|
+
},
|
|
3800
|
+
},
|
|
3801
|
+
],
|
|
3802
|
+
generatedPrefixNounIds: [child],
|
|
3803
|
+
feeLines: [],
|
|
3804
|
+
moneyEvents: [
|
|
3805
|
+
mintEvent({
|
|
3806
|
+
amount: "The stored approved amount under the cap",
|
|
3807
|
+
fromActor: settlement.source,
|
|
3808
|
+
key: payoutEvent,
|
|
3809
|
+
kind: "payout",
|
|
3810
|
+
occurrence: "repeatable",
|
|
3811
|
+
toActor: settlement.destination,
|
|
3812
|
+
trigger: "Pay one approved amount",
|
|
3813
|
+
}),
|
|
3814
|
+
],
|
|
3815
|
+
noun: {
|
|
3816
|
+
actors: {
|
|
3817
|
+
[settlement.destination]: "beneficiary",
|
|
3818
|
+
[settlement.source]: "payer",
|
|
3819
|
+
},
|
|
3820
|
+
desc: "Capped disbursement controlled by stored external evidence",
|
|
3821
|
+
fields: {
|
|
3822
|
+
currency: {
|
|
3823
|
+
desc: `Currency fixed to ${settlement.cap.currency}`,
|
|
3824
|
+
type: "currency",
|
|
3825
|
+
},
|
|
3826
|
+
[settlement.cap.name]: moneyFieldSpec(
|
|
3827
|
+
`Disbursement cap in ${settlement.cap.currency} minor units`,
|
|
3828
|
+
),
|
|
3829
|
+
},
|
|
3830
|
+
id: noun,
|
|
3831
|
+
summary: `Capped disbursement to ${settlement.destination.replaceAll("_", " ")}`,
|
|
3832
|
+
title: titleize(noun),
|
|
3833
|
+
verbs: {
|
|
3834
|
+
create: { summary: "Submit the capped disbursement", to: "submitted" },
|
|
3835
|
+
deny: {
|
|
3836
|
+
captureInput: { decisionEvidenceReference: "evidenceReference" },
|
|
3837
|
+
from: ["submitted"],
|
|
3838
|
+
port: {
|
|
3839
|
+
allowed: decision.allowed,
|
|
3840
|
+
fields: { evidenceReference: "text" },
|
|
3841
|
+
},
|
|
3842
|
+
requiresAggregate: [
|
|
3843
|
+
{
|
|
3844
|
+
check: { kind: "all_in" },
|
|
3845
|
+
nounId: child,
|
|
3846
|
+
over: "children",
|
|
3847
|
+
refField: parentRef,
|
|
3848
|
+
statuses: ["created"],
|
|
3849
|
+
},
|
|
3850
|
+
],
|
|
3851
|
+
summary: "Record a denial without moving money",
|
|
3852
|
+
to: "denied",
|
|
3853
|
+
},
|
|
3854
|
+
},
|
|
3855
|
+
},
|
|
3856
|
+
rules: [],
|
|
3857
|
+
settlement: { name: noun, pieces: [] },
|
|
3858
|
+
};
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
// ---------------------------------------------------------------------------
|
|
3862
|
+
// rotating_pool: fixed roster, one contribution per member and cycle
|
|
3863
|
+
|
|
3864
|
+
function lowerRotatingPool(settlement: CheckedRotatingPool): LoweredNoun {
|
|
3865
|
+
const noun = settlement.name;
|
|
3866
|
+
const parentRef = `${camelize(noun)}Id`;
|
|
3867
|
+
const contributionEvent = frameKey(`${noun}_contribution`);
|
|
3868
|
+
const guaranteeEvent = frameKey(`${noun}_guarantee_contribution`);
|
|
3869
|
+
const payoutEvent = frameKey(`${noun}_payout`);
|
|
3870
|
+
const fixedActors = [
|
|
3871
|
+
...new Set([
|
|
3872
|
+
...settlement.members,
|
|
3873
|
+
...settlement.payoutOrder,
|
|
3874
|
+
...(settlement.guarantor ? [settlement.guarantor] : []),
|
|
3875
|
+
]),
|
|
3876
|
+
];
|
|
3877
|
+
const fixedActorBindings = Object.fromEntries(
|
|
3878
|
+
fixedActors.map((actor) => {
|
|
3879
|
+
const accountField = `${camelize(actor)}AccountId`;
|
|
3880
|
+
return [accountField, `fields.${accountField}`];
|
|
3881
|
+
}),
|
|
3882
|
+
);
|
|
3883
|
+
const childIds = settlement.members.map(
|
|
3884
|
+
(member) => `${noun}_${member}_contribution`,
|
|
3885
|
+
);
|
|
3886
|
+
const childNouns = settlement.members.map((member, memberIndex): Json => {
|
|
3887
|
+
const id = childIds[memberIndex] as string;
|
|
3888
|
+
const verbs: Json = {
|
|
3889
|
+
create: {
|
|
3890
|
+
requires: {
|
|
3891
|
+
[parentRef]: {
|
|
3892
|
+
bind: {
|
|
3893
|
+
currency: "fields.currency",
|
|
3894
|
+
...fixedActorBindings,
|
|
3895
|
+
[settlement.schedule.firstDueField]:
|
|
3896
|
+
`fields.${settlement.schedule.firstDueField}`,
|
|
3897
|
+
[settlement.contribution.name]:
|
|
3898
|
+
`fields.${settlement.contribution.name}`,
|
|
3899
|
+
},
|
|
3900
|
+
statuses: ["forming"],
|
|
3901
|
+
unique: true,
|
|
3902
|
+
},
|
|
3903
|
+
},
|
|
3904
|
+
summary: `Create the fixed contribution row for ${member.replaceAll("_", " ")}`,
|
|
3905
|
+
to: "cycle_1_due",
|
|
3906
|
+
},
|
|
3907
|
+
};
|
|
3908
|
+
for (let index = 0; index < settlement.schedule.count; index += 1) {
|
|
3909
|
+
const cycle = index + 1;
|
|
3910
|
+
const dueState = `cycle_${cycle}_due`;
|
|
3911
|
+
const defaultState = `cycle_${cycle}_defaulted`;
|
|
3912
|
+
const fundedState = `cycle_${cycle}_funded`;
|
|
3913
|
+
const guaranteedState = `cycle_${cycle}_guaranteed`;
|
|
3914
|
+
const nextState =
|
|
3915
|
+
cycle === settlement.schedule.count
|
|
3916
|
+
? "final_paid"
|
|
3917
|
+
: `cycle_${cycle + 1}_due`;
|
|
3918
|
+
const dueRule = frameKey(`${noun}_cycle_${cycle}_due`);
|
|
3919
|
+
const cycleAmount = settlement.contribution.name;
|
|
3920
|
+
const guaranteeAmount = settlement.contribution.name;
|
|
3921
|
+
const due = {
|
|
3922
|
+
field: settlement.schedule.firstDueField,
|
|
3923
|
+
rule: dueRule,
|
|
3924
|
+
...anchorOffset(settlement.schedule, index),
|
|
3925
|
+
};
|
|
3926
|
+
verbs[`contribute_cycle_${cycle}`] = {
|
|
3927
|
+
due,
|
|
3928
|
+
from: [dueState],
|
|
3929
|
+
moneyEvent: contributionEvent,
|
|
3930
|
+
moves: [
|
|
3931
|
+
{
|
|
3932
|
+
amount: cycleAmount,
|
|
3933
|
+
from: member,
|
|
3934
|
+
key: "contribution",
|
|
3935
|
+
operation: "create",
|
|
3936
|
+
to: "escrow",
|
|
3937
|
+
},
|
|
3938
|
+
],
|
|
3939
|
+
requires: { [parentRef]: [`active_cycle_${cycle}`] },
|
|
3940
|
+
summary: `Fund ${member.replaceAll("_", " ")}'s cycle ${cycle} contribution`,
|
|
3941
|
+
to: fundedState,
|
|
3942
|
+
};
|
|
3943
|
+
verbs[`mark_default_cycle_${cycle}`] = {
|
|
3944
|
+
due,
|
|
3945
|
+
from: [dueState],
|
|
3946
|
+
requires: { [parentRef]: [`active_cycle_${cycle}`] },
|
|
3947
|
+
summary: `Mark the stored cycle ${cycle} due condition`,
|
|
3948
|
+
to: defaultState,
|
|
3949
|
+
};
|
|
3950
|
+
if (settlement.guarantor) {
|
|
3951
|
+
verbs[`guarantee_cycle_${cycle}`] = {
|
|
3952
|
+
from: [defaultState],
|
|
3953
|
+
moneyEvent: guaranteeEvent,
|
|
3954
|
+
moves: [
|
|
3955
|
+
{
|
|
3956
|
+
amount: guaranteeAmount,
|
|
3957
|
+
from: settlement.guarantor,
|
|
3958
|
+
key: "guarantee",
|
|
3959
|
+
operation: "create",
|
|
3960
|
+
to: "escrow",
|
|
3961
|
+
},
|
|
3962
|
+
],
|
|
3963
|
+
requires: { [parentRef]: [`active_cycle_${cycle}`] },
|
|
3964
|
+
summary: `Fund the defaulted cycle ${cycle} amount before payout`,
|
|
3965
|
+
to: guaranteedState,
|
|
3966
|
+
};
|
|
3967
|
+
}
|
|
3968
|
+
verbs[`pay_cycle_${cycle}`] = {
|
|
3969
|
+
from: [fundedState],
|
|
3970
|
+
moneyEvent: payoutEvent,
|
|
3971
|
+
moves: [
|
|
3972
|
+
{
|
|
3973
|
+
amount: cycleAmount,
|
|
3974
|
+
from: "escrow",
|
|
3975
|
+
key: "payout",
|
|
3976
|
+
operation: "create",
|
|
3977
|
+
to: settlement.payoutOrder[index],
|
|
3978
|
+
},
|
|
3979
|
+
],
|
|
3980
|
+
requires: { [parentRef]: [`cycle_${cycle}_ready`] },
|
|
3981
|
+
summary: `Pay this member's stored contribution into cycle ${cycle}'s shared pot recipient`,
|
|
3982
|
+
to: nextState,
|
|
3983
|
+
};
|
|
3984
|
+
if (settlement.guarantor) {
|
|
3985
|
+
verbs[`pay_guaranteed_cycle_${cycle}`] = {
|
|
3986
|
+
from: [guaranteedState],
|
|
3987
|
+
moneyEvent: payoutEvent,
|
|
3988
|
+
moves: [
|
|
3989
|
+
{
|
|
3990
|
+
amount: guaranteeAmount,
|
|
3991
|
+
from: "escrow",
|
|
3992
|
+
key: "payout",
|
|
3993
|
+
operation: "create",
|
|
3994
|
+
to: settlement.payoutOrder[index],
|
|
3995
|
+
},
|
|
3996
|
+
],
|
|
3997
|
+
requires: { [parentRef]: [`cycle_${cycle}_ready`] },
|
|
3998
|
+
summary: `Pay the funded default into cycle ${cycle}'s stored recipient`,
|
|
3999
|
+
to: nextState,
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
verbs.close = {
|
|
4004
|
+
from: ["final_paid"],
|
|
4005
|
+
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
4006
|
+
summary: "Complete after the final payout drains this member custody",
|
|
4007
|
+
to: "completed",
|
|
4008
|
+
};
|
|
4009
|
+
return {
|
|
4010
|
+
actors: Object.fromEntries([
|
|
4011
|
+
[member, "payer"],
|
|
4012
|
+
...(settlement.guarantor ? [[settlement.guarantor, "payer"]] : []),
|
|
4013
|
+
...settlement.payoutOrder.map((recipient) => [
|
|
4014
|
+
recipient,
|
|
4015
|
+
"beneficiary",
|
|
4016
|
+
]),
|
|
4017
|
+
]),
|
|
4018
|
+
desc: `Fixed contribution row for ${member.replaceAll("_", " ")}`,
|
|
4019
|
+
escrow: true,
|
|
4020
|
+
fields: {
|
|
4021
|
+
[settlement.contribution.name]: moneyFieldSpec(
|
|
4022
|
+
"Exact contribution amount shared by every cycle",
|
|
4023
|
+
),
|
|
4024
|
+
currency: { desc: "Currency derived from the pool", type: "currency" },
|
|
4025
|
+
[settlement.schedule.firstDueField]: dateFieldSpec(
|
|
4026
|
+
"First due anchor derived from the pool",
|
|
4027
|
+
),
|
|
4028
|
+
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
4029
|
+
},
|
|
4030
|
+
id,
|
|
4031
|
+
summary: `${member.replaceAll("_", " ")} contribution row`,
|
|
4032
|
+
title: `${titleize(noun)} ${titleize(member)} Contribution`,
|
|
4033
|
+
verbs,
|
|
4034
|
+
};
|
|
4035
|
+
});
|
|
4036
|
+
const parentVerbs: Json = {
|
|
4037
|
+
create: {
|
|
4038
|
+
summary: "Create the fixed roster before activation",
|
|
4039
|
+
to: "forming",
|
|
4040
|
+
},
|
|
4041
|
+
cancel: {
|
|
4042
|
+
from: ["forming"],
|
|
4043
|
+
summary: "Cancel before activation without moving money",
|
|
4044
|
+
to: "cancelled",
|
|
4045
|
+
},
|
|
4046
|
+
activate: {
|
|
4047
|
+
from: ["forming"],
|
|
4048
|
+
requiresAggregate: childIds.map((childId) => ({
|
|
4049
|
+
check: { kind: "count_equals_field", field: "one" },
|
|
4050
|
+
nounId: childId,
|
|
4051
|
+
over: "children",
|
|
4052
|
+
refField: parentRef,
|
|
4053
|
+
statuses: ["cycle_1_due"],
|
|
4054
|
+
})),
|
|
4055
|
+
summary: "Activate only after every fixed member row exists once",
|
|
4056
|
+
to: "active_cycle_1",
|
|
4057
|
+
},
|
|
4058
|
+
};
|
|
4059
|
+
for (let index = 0; index < settlement.schedule.count; index += 1) {
|
|
4060
|
+
const cycle = index + 1;
|
|
4061
|
+
parentVerbs[`ready_cycle_${cycle}`] = {
|
|
4062
|
+
from: [`active_cycle_${cycle}`],
|
|
4063
|
+
requiresAggregate: childIds.map((childId) => ({
|
|
4064
|
+
check: { kind: "all_in" },
|
|
4065
|
+
nounId: childId,
|
|
4066
|
+
over: "children",
|
|
4067
|
+
refField: parentRef,
|
|
4068
|
+
statuses: [
|
|
4069
|
+
`cycle_${cycle}_funded`,
|
|
4070
|
+
...(settlement.guarantor ? [`cycle_${cycle}_guaranteed`] : []),
|
|
4071
|
+
],
|
|
4072
|
+
})),
|
|
4073
|
+
summary: `Lock cycle ${cycle} only after every member row is funded or guaranteed`,
|
|
4074
|
+
to: `cycle_${cycle}_ready`,
|
|
4075
|
+
};
|
|
4076
|
+
parentVerbs[`advance_cycle_${cycle}`] = {
|
|
4077
|
+
from: [`cycle_${cycle}_ready`],
|
|
4078
|
+
requiresAggregate: childIds.map((childId) => ({
|
|
4079
|
+
check: { kind: "all_in" },
|
|
4080
|
+
nounId: childId,
|
|
4081
|
+
over: "children",
|
|
4082
|
+
refField: parentRef,
|
|
4083
|
+
statuses: [
|
|
4084
|
+
cycle === settlement.schedule.count
|
|
4085
|
+
? "completed"
|
|
4086
|
+
: `cycle_${cycle + 1}_due`,
|
|
4087
|
+
],
|
|
4088
|
+
})),
|
|
4089
|
+
summary:
|
|
4090
|
+
cycle === settlement.schedule.count
|
|
4091
|
+
? "Complete after the final shared pot pays"
|
|
4092
|
+
: `Advance after every cycle ${cycle} contribution pays`,
|
|
4093
|
+
to:
|
|
4094
|
+
cycle === settlement.schedule.count
|
|
4095
|
+
? "completed"
|
|
4096
|
+
: `active_cycle_${cycle + 1}`,
|
|
4097
|
+
};
|
|
4098
|
+
}
|
|
4099
|
+
return {
|
|
4100
|
+
design: [
|
|
4101
|
+
`${noun}: fixed roster and stored payout order; one member-specific row per member avoids tuple identity and keeps each cycle idempotent`,
|
|
4102
|
+
],
|
|
4103
|
+
extraNouns: childNouns,
|
|
4104
|
+
generatedPrefixNounIds: childIds,
|
|
4105
|
+
feeLines: [],
|
|
4106
|
+
moneyEvents: [
|
|
4107
|
+
mintEvent({
|
|
4108
|
+
amount: "One exact member contribution",
|
|
4109
|
+
fromActor: settlement.members[0] as string,
|
|
4110
|
+
key: contributionEvent,
|
|
4111
|
+
kind: "charge",
|
|
4112
|
+
occurrence: "repeatable",
|
|
4113
|
+
toActor: "escrow",
|
|
4114
|
+
trigger: "Fund one member and cycle",
|
|
4115
|
+
}),
|
|
4116
|
+
...(settlement.guarantor
|
|
4117
|
+
? [
|
|
4118
|
+
mintEvent({
|
|
4119
|
+
amount: "One exact defaulted contribution",
|
|
4120
|
+
fromActor: settlement.guarantor,
|
|
4121
|
+
key: guaranteeEvent,
|
|
4122
|
+
kind: "charge",
|
|
4123
|
+
occurrence: "repeatable",
|
|
4124
|
+
toActor: "escrow",
|
|
4125
|
+
trigger: "Fund one defaulted member and cycle",
|
|
4126
|
+
}),
|
|
4127
|
+
]
|
|
4128
|
+
: []),
|
|
4129
|
+
mintEvent({
|
|
4130
|
+
amount: "One exact member contribution from the cycle pot",
|
|
4131
|
+
fromActor: "escrow",
|
|
4132
|
+
key: payoutEvent,
|
|
4133
|
+
kind: "payout",
|
|
4134
|
+
occurrence: "repeatable",
|
|
4135
|
+
toActor: settlement.payoutOrder[0] as string,
|
|
4136
|
+
trigger: "Pay the stored cycle recipient",
|
|
4137
|
+
}),
|
|
4138
|
+
],
|
|
4139
|
+
noun: {
|
|
4140
|
+
actors: Object.fromEntries([
|
|
4141
|
+
...settlement.members.map((member) => [member, "party"]),
|
|
4142
|
+
...(settlement.guarantor ? [[settlement.guarantor, "payer"]] : []),
|
|
4143
|
+
]),
|
|
4144
|
+
desc: "Fixed rotating contribution and payout order",
|
|
4145
|
+
fields: {
|
|
4146
|
+
currency: {
|
|
4147
|
+
desc: `Currency fixed to ${settlement.contribution.currency}`,
|
|
4148
|
+
type: "currency",
|
|
4149
|
+
},
|
|
4150
|
+
[settlement.contribution.name]: moneyFieldSpec(
|
|
4151
|
+
`Exact contribution in ${settlement.contribution.currency} minor units`,
|
|
4152
|
+
),
|
|
4153
|
+
[settlement.schedule.firstDueField]: dateFieldSpec(
|
|
4154
|
+
"Stored first contribution due date",
|
|
4155
|
+
),
|
|
4156
|
+
one: { desc: "Exact fixed member-row count", type: "const:1" },
|
|
4157
|
+
},
|
|
4158
|
+
id: noun,
|
|
4159
|
+
summary: `${settlement.members.length}-member rotating pool`,
|
|
4160
|
+
title: titleize(noun),
|
|
4161
|
+
verbs: parentVerbs,
|
|
4162
|
+
},
|
|
4163
|
+
rules: Array.from({ length: settlement.schedule.count }, (_, index) => ({
|
|
4164
|
+
allowedActors: [],
|
|
4165
|
+
detail: `Cycle ${index + 1} default follows its stored due condition`,
|
|
4166
|
+
dueDriven: true,
|
|
4167
|
+
enforcement: "platform",
|
|
4168
|
+
gatesEvent: null,
|
|
4169
|
+
key: frameKey(`${noun}_cycle_${index + 1}_due`),
|
|
4170
|
+
kind: "deadline",
|
|
4171
|
+
label: `Cycle ${index + 1} due condition`,
|
|
4172
|
+
tenantTunable: false,
|
|
4173
|
+
})),
|
|
4174
|
+
settlement: { name: noun, pieces: [] },
|
|
4175
|
+
};
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
function anchorOffset(schedule: ScheduleTerms, index: number): Json {
|
|
4179
|
+
return index === 0 ? {} : { offset: `P${schedule.every.days * index}D` };
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
/**
|
|
4183
|
+
* Obligation mode extends the existing schedule instead of minting a second
|
|
4184
|
+
* repayment archetype. The parent stores every anchor amount and date. One
|
|
4185
|
+
* generated payment noun per anchor makes matching exact at the operation
|
|
4186
|
+
* boundary and lets the generic aggregate lock cap concurrent partial pays.
|
|
4187
|
+
*/
|
|
4188
|
+
function lowerScheduledObligation(
|
|
4189
|
+
settlement: CheckedScheduledObligation,
|
|
4190
|
+
collection?: CheckedRecurringCollection,
|
|
4191
|
+
collectionMandate?: CheckedPort,
|
|
4192
|
+
): LoweredNoun {
|
|
4193
|
+
const noun = settlement.name;
|
|
4194
|
+
const amountName = settlement.amount.name;
|
|
4195
|
+
const obligationIdField = `${noun.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}Id`;
|
|
4196
|
+
const widths = evenPieceBps(settlement.schedule.count);
|
|
4197
|
+
const installmentFields = widths.map(
|
|
4198
|
+
(_, index) => `installment${index + 1}Amount`,
|
|
4199
|
+
);
|
|
4200
|
+
const paymentNouns = widths.map(
|
|
4201
|
+
(_, index) => `${noun}_installment_${index + 1}_payment`,
|
|
4202
|
+
);
|
|
4203
|
+
const activeState = "active";
|
|
4204
|
+
const delinquentState = (index: number) =>
|
|
4205
|
+
`installment_${index + 1}_delinquent`;
|
|
4206
|
+
const delinquentStates = widths.map((_, index) => delinquentState(index));
|
|
4207
|
+
const liveStates = [activeState, ...delinquentStates];
|
|
4208
|
+
const repaymentEvent = frameKey(`${noun}_repayment`);
|
|
4209
|
+
const refundEvent = frameKey(`${noun}_refund`);
|
|
4210
|
+
const advanceEvent = frameKey(`${noun}_advance`);
|
|
4211
|
+
|
|
4212
|
+
const fields: Json = {
|
|
4213
|
+
currency: {
|
|
4214
|
+
desc: `Currency fixed to ${settlement.amount.currency}`,
|
|
4215
|
+
type: "currency",
|
|
4216
|
+
},
|
|
4217
|
+
[amountName]: moneyFieldSpec(
|
|
4218
|
+
`The principal in ${settlement.amount.currency} minor units; the stored installment anchors partition it exactly`,
|
|
4219
|
+
),
|
|
4220
|
+
[settlement.schedule.firstDueField]: dateFieldSpec(
|
|
4221
|
+
`Due date of the first installment; later anchors use fixed offsets of ${settlement.schedule.every.raw}`,
|
|
4222
|
+
),
|
|
4223
|
+
};
|
|
4224
|
+
for (const [index, field] of installmentFields.entries()) {
|
|
4225
|
+
fields[field] = moneyFieldSpec(
|
|
4226
|
+
`Stored amount for installment ${index + 1} of ${settlement.schedule.count}${index === 0 ? " (carries the integer-division remainder)" : ""}`,
|
|
4227
|
+
);
|
|
4228
|
+
fields[`installment${index + 1}DelinquentAfter`] = optionalDateFieldSpec(
|
|
4229
|
+
`Machine-set marker proving installment ${index + 1} reached its stored due date while unpaid`,
|
|
4230
|
+
);
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
const aggregateInvariants = paymentNouns.map((paymentNoun, index) => ({
|
|
4234
|
+
childField: "amount",
|
|
4235
|
+
childNounId: paymentNoun,
|
|
4236
|
+
childRefField: obligationIdField,
|
|
4237
|
+
childStatuses: ["paid"],
|
|
4238
|
+
parentField: installmentFields[index],
|
|
4239
|
+
}));
|
|
4240
|
+
|
|
4241
|
+
const verbs: Json = {
|
|
4242
|
+
create: {
|
|
4243
|
+
summary: `Create a ${titleize(noun).toLowerCase()} obligation`,
|
|
4244
|
+
to: "draft",
|
|
1719
4245
|
},
|
|
1720
|
-
|
|
1721
|
-
from: ["
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
key: "reservation",
|
|
1725
|
-
operation: "reserve",
|
|
1726
|
-
amount: amountName,
|
|
1727
|
-
from: settlement.payer,
|
|
1728
|
-
to: settlement.holder,
|
|
1729
|
-
},
|
|
1730
|
-
],
|
|
1731
|
-
moneyEvent: eventKey,
|
|
1732
|
-
summary: `Reserve the ${amountName} against the ${settlement.payer.replaceAll("_", " ")}'s account`,
|
|
1733
|
-
to: "held",
|
|
4246
|
+
approve: {
|
|
4247
|
+
from: ["draft"],
|
|
4248
|
+
summary: "Approve the immutable principal partition and stored anchors",
|
|
4249
|
+
to: settlement.advanceTo ? "approved" : activeState,
|
|
1734
4250
|
},
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
4251
|
+
...(settlement.advanceTo
|
|
4252
|
+
? {
|
|
4253
|
+
advance: {
|
|
4254
|
+
from: ["approved"],
|
|
4255
|
+
moneyEvent: advanceEvent,
|
|
4256
|
+
moves: [
|
|
4257
|
+
{
|
|
4258
|
+
amount: amountName,
|
|
4259
|
+
from: settlement.payee,
|
|
4260
|
+
key: "advance",
|
|
4261
|
+
operation: "create",
|
|
4262
|
+
to: settlement.advanceTo,
|
|
4263
|
+
},
|
|
4264
|
+
],
|
|
4265
|
+
summary: `Advance the principal to the ${settlement.advanceTo.replaceAll("_", " ")}; the internal ledger receipt is the confirmation`,
|
|
4266
|
+
to: activeState,
|
|
4267
|
+
},
|
|
4268
|
+
}
|
|
4269
|
+
: {}),
|
|
4270
|
+
write_off: {
|
|
4271
|
+
from: [
|
|
4272
|
+
"draft",
|
|
4273
|
+
...(settlement.advanceTo ? ["approved"] : []),
|
|
4274
|
+
...liveStates,
|
|
1743
4275
|
],
|
|
1744
|
-
summary:
|
|
1745
|
-
to: "
|
|
4276
|
+
summary: "Write off the remaining exposure without moving money",
|
|
4277
|
+
to: "written_off",
|
|
1746
4278
|
},
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
4279
|
+
};
|
|
4280
|
+
|
|
4281
|
+
const rules: Json[] = [];
|
|
4282
|
+
for (const [index, paymentNoun] of paymentNouns.entries()) {
|
|
4283
|
+
const anchor = index + 1;
|
|
4284
|
+
const ruleKey = frameKey(`${noun}_installment_${anchor}_due`);
|
|
4285
|
+
const due = {
|
|
4286
|
+
field: settlement.schedule.firstDueField,
|
|
4287
|
+
rule: ruleKey,
|
|
4288
|
+
...anchorOffset(settlement.schedule, index),
|
|
4289
|
+
};
|
|
4290
|
+
const aggregate = (kind: "sum_below" | "sum_exactly"): Json[] => [
|
|
4291
|
+
{
|
|
4292
|
+
check: {
|
|
4293
|
+
amountField: "amount",
|
|
4294
|
+
kind,
|
|
4295
|
+
targetField: installmentFields[index],
|
|
1757
4296
|
},
|
|
1758
|
-
|
|
4297
|
+
nounId: paymentNoun,
|
|
4298
|
+
over: "children",
|
|
4299
|
+
refField: obligationIdField,
|
|
4300
|
+
statuses: ["paid"],
|
|
4301
|
+
},
|
|
4302
|
+
];
|
|
4303
|
+
verbs[`mark_installment_${anchor}_delinquent`] = {
|
|
4304
|
+
due,
|
|
4305
|
+
from: liveStates.filter((state) => state !== delinquentState(index)),
|
|
4306
|
+
requiresAggregate: aggregate("sum_below"),
|
|
4307
|
+
setsAt: {
|
|
4308
|
+
field: `installment${anchor}DelinquentAfter`,
|
|
4309
|
+
marker: true,
|
|
4310
|
+
offset: "PT1S",
|
|
4311
|
+
},
|
|
4312
|
+
summary: `Mark installment ${anchor} delinquent only when its due anchor is unmet`,
|
|
4313
|
+
to: delinquentState(index),
|
|
4314
|
+
};
|
|
4315
|
+
verbs[`collect_installment_${anchor}`] = {
|
|
4316
|
+
due,
|
|
4317
|
+
from: delinquentStates,
|
|
4318
|
+
requiresAggregate: aggregate("sum_exactly"),
|
|
4319
|
+
summary: `Close delinquent installment ${anchor} after linked payments reach its stored amount`,
|
|
4320
|
+
to: activeState,
|
|
4321
|
+
};
|
|
4322
|
+
rules.push({
|
|
4323
|
+
allowedActors: [],
|
|
4324
|
+
detail: `At stored anchor ${anchor}, the platform compares paid child rows with ${installmentFields[index]} and chooses paid or delinquent`,
|
|
4325
|
+
dueDriven: true,
|
|
4326
|
+
enforcement: "platform",
|
|
4327
|
+
gatesEvent: null,
|
|
4328
|
+
key: ruleKey,
|
|
4329
|
+
kind: "deadline",
|
|
4330
|
+
label: `Installment ${anchor} resolves from its stored due condition`,
|
|
4331
|
+
tenantTunable: false,
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
const completionRuleKey = frameKey(`${noun}_completion_due`);
|
|
4336
|
+
verbs.complete = {
|
|
4337
|
+
due: {
|
|
4338
|
+
field: settlement.schedule.firstDueField,
|
|
4339
|
+
rule: completionRuleKey,
|
|
4340
|
+
...anchorOffset(settlement.schedule, widths.length - 1),
|
|
1759
4341
|
},
|
|
4342
|
+
from: liveStates,
|
|
4343
|
+
requiresAggregate: paymentNouns.map((paymentNoun, index) => ({
|
|
4344
|
+
check: {
|
|
4345
|
+
amountField: "amount",
|
|
4346
|
+
kind: "sum_exactly",
|
|
4347
|
+
targetField: installmentFields[index],
|
|
4348
|
+
},
|
|
4349
|
+
nounId: paymentNoun,
|
|
4350
|
+
over: "children",
|
|
4351
|
+
refField: obligationIdField,
|
|
4352
|
+
statuses: ["paid"],
|
|
4353
|
+
})),
|
|
4354
|
+
summary:
|
|
4355
|
+
"Close the obligation only after every stored anchor is paid exactly",
|
|
4356
|
+
to: "repaid",
|
|
1760
4357
|
};
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
dueDriven:
|
|
1766
|
-
enforcement: "
|
|
4358
|
+
rules.push({
|
|
4359
|
+
allowedActors: [],
|
|
4360
|
+
detail:
|
|
4361
|
+
"After the final stored anchor, the platform closes only when every anchor is paid exactly",
|
|
4362
|
+
dueDriven: true,
|
|
4363
|
+
enforcement: "platform",
|
|
1767
4364
|
gatesEvent: null,
|
|
1768
|
-
key:
|
|
1769
|
-
kind: "
|
|
1770
|
-
label:
|
|
4365
|
+
key: completionRuleKey,
|
|
4366
|
+
kind: "deadline",
|
|
4367
|
+
label: "Obligation completion follows exact aggregate repayment",
|
|
1771
4368
|
tenantTunable: false,
|
|
1772
4369
|
});
|
|
1773
4370
|
|
|
4371
|
+
const paymentNoun = (index: number): Json => {
|
|
4372
|
+
const anchor = index + 1;
|
|
4373
|
+
const id = paymentNouns[index] as string;
|
|
4374
|
+
const permittedParentStates = liveStates;
|
|
4375
|
+
const payerAccountField = `${settlement.payer.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}AccountId`;
|
|
4376
|
+
const payeeAccountField = `${settlement.payee.replaceAll(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}AccountId`;
|
|
4377
|
+
const createRequirement = {
|
|
4378
|
+
[obligationIdField]: {
|
|
4379
|
+
bind: {
|
|
4380
|
+
currency: "fields.currency",
|
|
4381
|
+
[payerAccountField]: `fields.${payerAccountField}`,
|
|
4382
|
+
[payeeAccountField]: `fields.${payeeAccountField}`,
|
|
4383
|
+
},
|
|
4384
|
+
statuses: permittedParentStates,
|
|
4385
|
+
},
|
|
4386
|
+
};
|
|
4387
|
+
const transitionRequirement = {
|
|
4388
|
+
[obligationIdField]: {
|
|
4389
|
+
match: {
|
|
4390
|
+
"fields.currency": "fields.currency",
|
|
4391
|
+
[`fields.${payerAccountField}`]: `fields.${payerAccountField}`,
|
|
4392
|
+
[`fields.${payeeAccountField}`]: `fields.${payeeAccountField}`,
|
|
4393
|
+
},
|
|
4394
|
+
statuses: permittedParentStates,
|
|
4395
|
+
},
|
|
4396
|
+
};
|
|
4397
|
+
return {
|
|
4398
|
+
actors: {
|
|
4399
|
+
[settlement.payee]: "beneficiary",
|
|
4400
|
+
[settlement.payer]: "payer",
|
|
4401
|
+
},
|
|
4402
|
+
desc: `One partial or full payment bound to installment ${anchor} of ${noun}; the operation name fixes the anchor and ${obligationIdField} fixes the obligation`,
|
|
4403
|
+
fields: {
|
|
4404
|
+
amount: moneyFieldSpec(
|
|
4405
|
+
`Positive payment amount capped with its paid siblings at ${installmentFields[index]}`,
|
|
4406
|
+
),
|
|
4407
|
+
currency: {
|
|
4408
|
+
desc: "ISO 4217 currency derived from the obligation",
|
|
4409
|
+
type: "currency",
|
|
4410
|
+
},
|
|
4411
|
+
[obligationIdField]: {
|
|
4412
|
+
desc: `The exact ${noun.replaceAll("_", " ")} this payment belongs to`,
|
|
4413
|
+
type: `ref:${noun}`,
|
|
4414
|
+
},
|
|
4415
|
+
},
|
|
4416
|
+
id,
|
|
4417
|
+
summary: `Anchor-bound payment for installment ${anchor}`,
|
|
4418
|
+
title: `${titleize(noun)} Installment ${anchor} Payment`,
|
|
4419
|
+
verbs: {
|
|
4420
|
+
create: {
|
|
4421
|
+
requires: createRequirement,
|
|
4422
|
+
summary: `Create a payment record for installment ${anchor}`,
|
|
4423
|
+
to: "created",
|
|
4424
|
+
},
|
|
4425
|
+
repay: {
|
|
4426
|
+
...(collection && collectionMandate
|
|
4427
|
+
? {
|
|
4428
|
+
captureInput: {
|
|
4429
|
+
mandateEvidenceReference: "evidenceReference",
|
|
4430
|
+
},
|
|
4431
|
+
port: {
|
|
4432
|
+
allowed: collectionMandate.allowed,
|
|
4433
|
+
fields: { evidenceReference: "text" },
|
|
4434
|
+
},
|
|
4435
|
+
}
|
|
4436
|
+
: {}),
|
|
4437
|
+
from: ["created"],
|
|
4438
|
+
moneyEvent: repaymentEvent,
|
|
4439
|
+
moves: [
|
|
4440
|
+
{
|
|
4441
|
+
amount: "amount",
|
|
4442
|
+
from: settlement.payer,
|
|
4443
|
+
key: "repayment",
|
|
4444
|
+
operation: "create",
|
|
4445
|
+
to: settlement.payee,
|
|
4446
|
+
},
|
|
4447
|
+
],
|
|
4448
|
+
requires: transitionRequirement,
|
|
4449
|
+
requiresExposure: [
|
|
4450
|
+
{
|
|
4451
|
+
amountField: "amount",
|
|
4452
|
+
anchorField: obligationIdField,
|
|
4453
|
+
capField: installmentFields[index],
|
|
4454
|
+
capOnAnchor: true,
|
|
4455
|
+
childNounId: id,
|
|
4456
|
+
statuses: ["paid"],
|
|
4457
|
+
},
|
|
4458
|
+
],
|
|
4459
|
+
summary: `Pay a partial or full amount against installment ${anchor}`,
|
|
4460
|
+
to: "paid",
|
|
4461
|
+
},
|
|
4462
|
+
refund: {
|
|
4463
|
+
from: ["paid"],
|
|
4464
|
+
moneyEvent: refundEvent,
|
|
4465
|
+
moves: [
|
|
4466
|
+
{
|
|
4467
|
+
amount: "amount",
|
|
4468
|
+
from: settlement.payee,
|
|
4469
|
+
key: "refund",
|
|
4470
|
+
operation: "create",
|
|
4471
|
+
to: settlement.payer,
|
|
4472
|
+
},
|
|
4473
|
+
],
|
|
4474
|
+
requires: transitionRequirement,
|
|
4475
|
+
summary: `Refund this one stored installment ${anchor} payment whole`,
|
|
4476
|
+
to: "refunded",
|
|
4477
|
+
},
|
|
4478
|
+
},
|
|
4479
|
+
};
|
|
4480
|
+
};
|
|
4481
|
+
|
|
1774
4482
|
return {
|
|
1775
4483
|
design: [
|
|
1776
|
-
`${noun}:
|
|
4484
|
+
`${noun}: existing scheduled mechanism in obligation mode; principal partitions into ${settlement.schedule.count} stored anchors; each payment operation names one anchor and one obligation`,
|
|
4485
|
+
`${noun}: partial and early payments serialize under per-anchor aggregate caps; each refund reverses one paid row whole; rescheduling is refused by the checker`,
|
|
4486
|
+
`${noun}: due-only delinquency and write-off change state without money; ${settlement.advanceTo ? "advance is an internal ledger movement with no provider claim" : "no advance is emitted"}`,
|
|
4487
|
+
...(collection
|
|
4488
|
+
? [
|
|
4489
|
+
`${collection.name}: explicit collection attempts reuse ${noun}'s anchor-bound repayment verbs; mandate evidence is captured per attempt; failures remain receipted failures and delinquency stays on ${noun}`,
|
|
4490
|
+
]
|
|
4491
|
+
: []),
|
|
1777
4492
|
],
|
|
4493
|
+
extraNouns: paymentNouns.map((_, index) => paymentNoun(index)),
|
|
4494
|
+
generatedPrefixNounIds: paymentNouns,
|
|
1778
4495
|
feeLines: [],
|
|
1779
|
-
moneyEvents:
|
|
4496
|
+
moneyEvents: [
|
|
4497
|
+
...(settlement.advanceTo
|
|
4498
|
+
? [
|
|
4499
|
+
mintEvent({
|
|
4500
|
+
amount: `The full ${amountName}`,
|
|
4501
|
+
fromActor: settlement.payee,
|
|
4502
|
+
key: advanceEvent,
|
|
4503
|
+
kind: "payout",
|
|
4504
|
+
toActor: settlement.advanceTo,
|
|
4505
|
+
trigger: "Advance the approved principal once",
|
|
4506
|
+
}),
|
|
4507
|
+
]
|
|
4508
|
+
: []),
|
|
4509
|
+
mintEvent({
|
|
4510
|
+
amount: "A positive amount capped by its stored installment anchor",
|
|
4511
|
+
fromActor: settlement.payer,
|
|
4512
|
+
key: repaymentEvent,
|
|
4513
|
+
kind: "installment",
|
|
4514
|
+
occurrence: "repeatable",
|
|
4515
|
+
toActor: settlement.payee,
|
|
4516
|
+
trigger: "Pay one anchor-bound partial or full installment amount",
|
|
4517
|
+
}),
|
|
4518
|
+
mintEvent({
|
|
4519
|
+
amount: "Exactly one stored paid installment payment",
|
|
4520
|
+
fromActor: settlement.payee,
|
|
4521
|
+
key: refundEvent,
|
|
4522
|
+
kind: "refund",
|
|
4523
|
+
occurrence: "repeatable",
|
|
4524
|
+
toActor: settlement.payer,
|
|
4525
|
+
trigger: "Refund one linked paid installment payment whole",
|
|
4526
|
+
}),
|
|
4527
|
+
],
|
|
1780
4528
|
noun: {
|
|
1781
4529
|
actors: {
|
|
4530
|
+
...(settlement.advanceTo
|
|
4531
|
+
? { [settlement.advanceTo]: "beneficiary" }
|
|
4532
|
+
: {}),
|
|
4533
|
+
[settlement.payee]: "beneficiary",
|
|
1782
4534
|
[settlement.payer]: "payer",
|
|
1783
|
-
[settlement.
|
|
1784
|
-
},
|
|
1785
|
-
desc: `Deposit: the ${amountName} is reserved against the ${settlement.payer.replaceAll("_", " ")}'s account in the ${settlement.holder.replaceAll("_", " ")}'s favor, then claimed or returned in full`,
|
|
1786
|
-
fields: {
|
|
1787
|
-
[amountName]: moneyFieldSpec(
|
|
1788
|
-
`The deposit amount in ${settlement.amount.currency} minor units, reserved in full and fully accounted on claim or return`,
|
|
1789
|
-
),
|
|
4535
|
+
[settlement.debtor]: "party",
|
|
1790
4536
|
},
|
|
4537
|
+
aggregateInvariants,
|
|
4538
|
+
desc: `Installment obligation for ${settlement.debtor.replaceAll("_", " ")}; ${settlement.payer.replaceAll("_", " ")} pays ${settlement.payee.replaceAll("_", " ")} against exact stored anchors`,
|
|
4539
|
+
fields,
|
|
1791
4540
|
id: noun,
|
|
1792
|
-
|
|
4541
|
+
...partitionsSpread(partitionClause(amountName, installmentFields)),
|
|
4542
|
+
summary: `${settlement.schedule.count}-anchor obligation for ${settlement.debtor.replaceAll("_", " ")}`,
|
|
1793
4543
|
title: titleize(noun),
|
|
1794
4544
|
verbs,
|
|
1795
4545
|
},
|
|
1796
|
-
rules
|
|
4546
|
+
rules,
|
|
1797
4547
|
settlement: { name: noun, pieces: [] },
|
|
1798
4548
|
};
|
|
1799
4549
|
}
|
|
1800
4550
|
|
|
1801
|
-
// ---------------------------------------------------------------------------
|
|
1802
|
-
// scheduled and advance: finite due-driven anchors
|
|
1803
|
-
|
|
1804
|
-
/** Equal N-way piece widths in bps; the first anchor absorbs the remainder. */
|
|
1805
|
-
function evenPieceBps(count: number): number[] {
|
|
1806
|
-
const base = Math.floor(Number(TOTAL_BPS) / count);
|
|
1807
|
-
const widths = Array.from({ length: count }, () => base);
|
|
1808
|
-
widths[0] = Number(TOTAL_BPS) - base * (count - 1);
|
|
1809
|
-
return widths;
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
function anchorOffset(schedule: ScheduleTerms, index: number): Json {
|
|
1813
|
-
return index === 0 ? {} : { offset: `P${schedule.every.days * index}D` };
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
4551
|
function lowerScheduled(settlement: CheckedScheduled): LoweredNoun {
|
|
1817
4552
|
const noun = settlement.name;
|
|
1818
4553
|
const amountName = settlement.amount.name;
|
|
@@ -1921,9 +4656,12 @@ function lowerScheduled(settlement: CheckedScheduled): LoweredNoun {
|
|
|
1921
4656
|
};
|
|
1922
4657
|
}
|
|
1923
4658
|
|
|
1924
|
-
function lowerAdvance(
|
|
4659
|
+
function lowerAdvance(
|
|
4660
|
+
settlement: CheckedAdvance,
|
|
4661
|
+
recourses: readonly CheckedScheduled[],
|
|
4662
|
+
): LoweredNoun {
|
|
1925
4663
|
return settlement.source.kind === "carve"
|
|
1926
|
-
? lowerCarvedAdvance(settlement, settlement.source.settlement)
|
|
4664
|
+
? lowerCarvedAdvance(settlement, settlement.source.settlement, recourses)
|
|
1927
4665
|
: lowerScheduledAdvance(settlement, settlement.source.schedule);
|
|
1928
4666
|
}
|
|
1929
4667
|
|
|
@@ -1937,6 +4675,7 @@ function lowerAdvance(settlement: CheckedAdvance): LoweredNoun {
|
|
|
1937
4675
|
function lowerCarvedAdvance(
|
|
1938
4676
|
settlement: CheckedAdvance,
|
|
1939
4677
|
hold: string,
|
|
4678
|
+
recourses: readonly CheckedScheduled[],
|
|
1940
4679
|
): LoweredNoun {
|
|
1941
4680
|
const noun = settlement.name;
|
|
1942
4681
|
const amountName = settlement.amount.name;
|
|
@@ -1944,11 +4683,29 @@ function lowerCarvedAdvance(
|
|
|
1944
4683
|
const advancedWords = settlement.advanced.replaceAll("_", " ");
|
|
1945
4684
|
const funderWords = settlement.funder.replaceAll("_", " ");
|
|
1946
4685
|
const holdWords = hold.replaceAll("_", " ");
|
|
4686
|
+
const holdRefField = "carveHoldId";
|
|
4687
|
+
const referenceBindings = [
|
|
4688
|
+
{ field: holdRefField, statuses: ["funded"], target: hold },
|
|
4689
|
+
...recourses.map((recourse, index) => ({
|
|
4690
|
+
field: `carveRecourse${index + 1}Id`,
|
|
4691
|
+
statuses: ["active"],
|
|
4692
|
+
target: recourse.name,
|
|
4693
|
+
})),
|
|
4694
|
+
];
|
|
1947
4695
|
|
|
1948
4696
|
const fields: Json = {
|
|
1949
4697
|
[amountName]: moneyFieldSpec(
|
|
1950
4698
|
`The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${advancedWords} up front`,
|
|
1951
4699
|
),
|
|
4700
|
+
...Object.fromEntries(
|
|
4701
|
+
referenceBindings.map((binding) => [
|
|
4702
|
+
binding.field,
|
|
4703
|
+
{
|
|
4704
|
+
desc: `The ${binding.target.replaceAll("_", " ")} bound to this advance`,
|
|
4705
|
+
type: `ref:${binding.target}`,
|
|
4706
|
+
},
|
|
4707
|
+
]),
|
|
4708
|
+
),
|
|
1952
4709
|
...(hasFee
|
|
1953
4710
|
? {
|
|
1954
4711
|
feeAmount: moneyFieldSpec(
|
|
@@ -2016,6 +4773,18 @@ function lowerCarvedAdvance(
|
|
|
2016
4773
|
to: settlement.advanced,
|
|
2017
4774
|
},
|
|
2018
4775
|
],
|
|
4776
|
+
requires: Object.fromEntries(
|
|
4777
|
+
referenceBindings.map((binding) => [
|
|
4778
|
+
binding.field,
|
|
4779
|
+
{
|
|
4780
|
+
match: {
|
|
4781
|
+
[`fields.${amountName}`]: `fields.${amountName}`,
|
|
4782
|
+
"fields.currency": "fields.currency",
|
|
4783
|
+
},
|
|
4784
|
+
statuses: binding.statuses,
|
|
4785
|
+
},
|
|
4786
|
+
]),
|
|
4787
|
+
),
|
|
2019
4788
|
summary: `Disburse the ${amountName} to the ${advancedWords}`,
|
|
2020
4789
|
to: "advanced",
|
|
2021
4790
|
},
|
|
@@ -2582,6 +5351,8 @@ function summarize(program: CheckedProgram): string {
|
|
|
2582
5351
|
: `the ${settlement.payee.replaceAll("_", " ")} is paid on confirmed release`;
|
|
2583
5352
|
return `The ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into escrow and ${paid}${cancel}`;
|
|
2584
5353
|
}
|
|
5354
|
+
case "captured_payment":
|
|
5355
|
+
return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved until ${settlement.reserveUntilField}, captured by the ${settlement.payee.replaceAll("_", " ")} in strict partial slices, then settled or released`;
|
|
2585
5356
|
case "instant_transfer":
|
|
2586
5357
|
return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} straight through to the ${settlement.payee.replaceAll("_", " ")}`;
|
|
2587
5358
|
case "premium_forward":
|
|
@@ -2589,7 +5360,9 @@ function summarize(program: CheckedProgram): string {
|
|
|
2589
5360
|
case "deposit":
|
|
2590
5361
|
return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved for the ${settlement.holder.replaceAll("_", " ")} until claimed or returned`;
|
|
2591
5362
|
case "scheduled":
|
|
2592
|
-
return
|
|
5363
|
+
return settlement.mode === "obligation"
|
|
5364
|
+
? `The ${settlement.debtor.replaceAll("_", " ")} owes ${settlement.amount.name}; ${settlement.advanceTo ? `the ${settlement.payee.replaceAll("_", " ")} advances it to the ${settlement.advanceTo.replaceAll("_", " ")}, then ` : ""}the ${settlement.payer.replaceAll("_", " ")} repays the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} anchor-bound installments`
|
|
5365
|
+
: `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} to the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} scheduled installments`;
|
|
2593
5366
|
case "advance":
|
|
2594
5367
|
return settlement.source.kind === "carve"
|
|
2595
5368
|
? `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid out of the ${settlement.source.settlement.replaceAll("_", " ")} release`
|
|
@@ -2598,6 +5371,20 @@ function summarize(program: CheckedProgram): string {
|
|
|
2598
5371
|
return `The ${settlement.payer.replaceAll("_", " ")} is charged per metered unit at a committed rate card until the period closes`;
|
|
2599
5372
|
case "pooled_split":
|
|
2600
5373
|
return `The ${settlement.payer.replaceAll("_", " ")} pools ${settlement.amount.name} and it distributes ${settlement.shares.length} ways on the payout date`;
|
|
5374
|
+
case "settlement_batch":
|
|
5375
|
+
return `The ${settlement.settlementAccount.replaceAll("_", " ")} freezes capture lineage and pays one signed net amount to the ${settlement.payoutDestination.replaceAll("_", " ")}`;
|
|
5376
|
+
case "funding_round":
|
|
5377
|
+
return `The ${settlement.contributor.replaceAll("_", " ")} commits under ${settlement.target.name} until the stored close anchor activates or fails the round`;
|
|
5378
|
+
case "weighted_distribution":
|
|
5379
|
+
return `The ${settlement.source.replaceAll("_", " ")} pays a frozen claimant set by deterministic largest remainder`;
|
|
5380
|
+
case "credit_facility":
|
|
5381
|
+
return `The ${settlement.lender.replaceAll("_", " ")} admits draws under ${settlement.limit.name}; ${settlement.obligation.settlement.replaceAll("_", " ")} owns repayment`;
|
|
5382
|
+
case "recurring_collection":
|
|
5383
|
+
return `${settlement.name.replaceAll("_", " ")} adds explicit mandate evidence to ${settlement.obligation.settlement.replaceAll("_", " ")} repayment attempts`;
|
|
5384
|
+
case "conditional_disbursement":
|
|
5385
|
+
return `The ${settlement.source.replaceAll("_", " ")} pays one evidence-approved amount under ${settlement.cap.name} to the ${settlement.destination.replaceAll("_", " ")}`;
|
|
5386
|
+
case "rotating_pool":
|
|
5387
|
+
return `${settlement.members.length} fixed members contribute one exact amount per cycle in a stored payout order`;
|
|
2601
5388
|
case "swap":
|
|
2602
5389
|
return `The ${settlement.sides[0].party.replaceAll("_", " ")} and ${settlement.sides[1].party.replaceAll("_", " ")} fund one shared escrow and the entire two-sided trade releases or reverses together`;
|
|
2603
5390
|
}
|