@hyperscale0/hsx 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORS +8 -0
- package/CHANGELOG.md +59 -0
- package/LICENSE +661 -0
- package/LICENSING.md +52 -0
- package/README.md +170 -0
- package/SECURITY.md +47 -0
- package/TRADEMARKS.md +35 -0
- package/bin/hsx.ts +15 -0
- package/dist/bin/hsx.d.ts +7 -0
- package/dist/bin/hsx.d.ts.map +1 -0
- package/dist/bin/hsx.js +14 -0
- package/dist/bin/hsx.js.map +1 -0
- package/dist/src/ast.d.ts +172 -0
- package/dist/src/ast.d.ts.map +1 -0
- package/dist/src/ast.js +22 -0
- package/dist/src/ast.js.map +1 -0
- package/dist/src/check.d.ts +11 -0
- package/dist/src/check.d.ts.map +1 -0
- package/dist/src/check.js +1214 -0
- package/dist/src/check.js.map +1 -0
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +137 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/compile.d.ts +39 -0
- package/dist/src/compile.d.ts.map +1 -0
- package/dist/src/compile.js +59 -0
- package/dist/src/compile.js.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lex.d.ts +23 -0
- package/dist/src/lex.d.ts.map +1 -0
- package/dist/src/lex.js +125 -0
- package/dist/src/lex.js.map +1 -0
- package/dist/src/lower.d.ts +93 -0
- package/dist/src/lower.d.ts.map +1 -0
- package/dist/src/lower.js +2081 -0
- package/dist/src/lower.js.map +1 -0
- package/dist/src/model.d.ts +307 -0
- package/dist/src/model.d.ts.map +1 -0
- package/dist/src/model.js +15 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/parse.d.ts +19 -0
- package/dist/src/parse.d.ts.map +1 -0
- package/dist/src/parse.js +484 -0
- package/dist/src/parse.js.map +1 -0
- package/dist/src/version.d.ts +16 -0
- package/dist/src/version.d.ts.map +1 -0
- package/dist/src/version.js +16 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +79 -0
- package/spec/hsx-ir.schema.json +522 -0
- package/src/ast.ts +231 -0
- package/src/check.ts +1699 -0
- package/src/cli.ts +173 -0
- package/src/compile.ts +98 -0
- package/src/index.ts +16 -0
- package/src/lex.ts +161 -0
- package/src/lower.ts +2619 -0
- package/src/model.ts +340 -0
- package/src/parse.ts +580 -0
- package/src/version.ts +17 -0
|
@@ -0,0 +1,2081 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lowering: checked program model -> HSX-JSON IR + congruent Business
|
|
3
|
+
* Frame. This is where the piece choreography the Architect used to hand-author
|
|
4
|
+
* is EMITTED deterministically instead.
|
|
5
|
+
*
|
|
6
|
+
* Semantics fixed here, once, for every archetype:
|
|
7
|
+
*
|
|
8
|
+
* - Fees: a payer-side fee is a service charge ON TOP of the amount, moving
|
|
9
|
+
* payer -> platform directly and never entering custody; a payee-side fee
|
|
10
|
+
* (or a premium commission) is CARVED FROM the amount at release.
|
|
11
|
+
* - Partitions: whenever an amount splits, it is partitioned into the finest
|
|
12
|
+
* common refinement of every exit, each piece its own required money field
|
|
13
|
+
* funded and debited under that exact name, the shape the independent
|
|
14
|
+
* checker's terminal-escrow analysis can prove conserving. Every partition
|
|
15
|
+
* is also declared on the noun, so create admission refuses pieces that do
|
|
16
|
+
* not sum to their total.
|
|
17
|
+
* - Integer minor-unit arithmetic: each piece is floor(amount * bps / 10000);
|
|
18
|
+
* the division remainder goes to the FIRST piece unless a split names its
|
|
19
|
+
* `remainder_to` recipient.
|
|
20
|
+
* - Schedules are finite by construction: a literal anchor count unrolls into
|
|
21
|
+
* one due-driven verb per anchor, each its own idempotent lifecycle step.
|
|
22
|
+
* - Metered usage never accrues custody: each usage charge IS the ledger
|
|
23
|
+
* transfer, so emission and ledger cannot diverge; the period close makes
|
|
24
|
+
* further charges unreachable.
|
|
25
|
+
* - Deposits are reservations: placed as a hold, then posted to the holder
|
|
26
|
+
* (claim) or voided back to the payer (return); the hold pairing law
|
|
27
|
+
* accounts for the full amount on both exits.
|
|
28
|
+
*
|
|
29
|
+
* The lowering never shares code with the checker that verifies its output;
|
|
30
|
+
* that independence is the safety argument of the whole compiler.
|
|
31
|
+
*/
|
|
32
|
+
import { HSX_IR_VERSION } from "./version.js";
|
|
33
|
+
/**
|
|
34
|
+
* The most money events one program may mint. The Business Frame contract caps
|
|
35
|
+
* its moneyEvents array at the same number, and a runtime spec pins the two
|
|
36
|
+
* against each other, so neither can drift alone. Every installment anchor,
|
|
37
|
+
* fee leg, cancellation leg, abandonment refund, and forward counts one.
|
|
38
|
+
*/
|
|
39
|
+
export const MONEY_EVENT_BUDGET = 14;
|
|
40
|
+
const TOTAL_BPS = 10000n;
|
|
41
|
+
/**
|
|
42
|
+
* Split a minor-unit amount across pieces by exact basis points. Floors every
|
|
43
|
+
* piece and gives the division remainder to the piece at `remainderIndex`
|
|
44
|
+
* (the first by default), so the piece amounts always sum exactly.
|
|
45
|
+
*/
|
|
46
|
+
export function pieceAmounts(pieces, amountMinor, remainderIndex = 0) {
|
|
47
|
+
if (amountMinor < 0n)
|
|
48
|
+
throw new Error("amount must be non-negative");
|
|
49
|
+
const floors = pieces.map((piece) => (amountMinor * BigInt(piece.bps)) / TOTAL_BPS);
|
|
50
|
+
const distributed = floors.reduce((sum, value) => sum + value, 0n);
|
|
51
|
+
if (floors.length > 0) {
|
|
52
|
+
const target = remainderIndex < floors.length ? remainderIndex : 0;
|
|
53
|
+
floors[target] = floors[target] + (amountMinor - distributed);
|
|
54
|
+
}
|
|
55
|
+
return floors;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Frame keys carry a 40-char snake_case budget, set by the Business Frame
|
|
59
|
+
* contract's key text. Composed keys include model-authored names (ports,
|
|
60
|
+
* parties, meters) with no length bound of their own, so an overlong
|
|
61
|
+
* composition clamps to a 33-char prefix plus a stable 6-char hash of the
|
|
62
|
+
* full name. Deterministic and idempotent: equal compositions stay equal, so
|
|
63
|
+
* a rule's gatesEvent keeps matching its money event's key.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* Frame prose fields (headline, summary, design lines, event labels and
|
|
67
|
+
* triggers, rule details, amounts) share a 160-char schema budget, and
|
|
68
|
+
* their compositions embed model-authored names with no length bound of
|
|
69
|
+
* their own. One walker over the assembled frame clamps every prose string
|
|
70
|
+
* so the compiler can never emit a frame the platform schema rejects as an
|
|
71
|
+
* internal fault. Keys are snake_case identities, not prose: they clamp
|
|
72
|
+
* separately via frameKey and are never touched here.
|
|
73
|
+
*/
|
|
74
|
+
const PROSE_BUDGET = 160;
|
|
75
|
+
const FRAME_PROSE_FIELDS = new Set([
|
|
76
|
+
"amount",
|
|
77
|
+
"design",
|
|
78
|
+
"detail",
|
|
79
|
+
"headline",
|
|
80
|
+
"label",
|
|
81
|
+
"summary",
|
|
82
|
+
"title",
|
|
83
|
+
"trigger",
|
|
84
|
+
"why",
|
|
85
|
+
]);
|
|
86
|
+
function clampProseValue(value, field) {
|
|
87
|
+
if (typeof value === "string") {
|
|
88
|
+
return field !== undefined &&
|
|
89
|
+
FRAME_PROSE_FIELDS.has(field) &&
|
|
90
|
+
value.length > PROSE_BUDGET
|
|
91
|
+
? `${value.slice(0, PROSE_BUDGET - 3)}...`
|
|
92
|
+
: value;
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
return value.map((item) => clampProseValue(item, field));
|
|
96
|
+
}
|
|
97
|
+
if (value !== null && typeof value === "object") {
|
|
98
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
99
|
+
key,
|
|
100
|
+
clampProseValue(child, key),
|
|
101
|
+
]));
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
function clampFrameProse(frame) {
|
|
106
|
+
return clampProseValue(frame);
|
|
107
|
+
}
|
|
108
|
+
function frameKey(key) {
|
|
109
|
+
if (key.length <= 40)
|
|
110
|
+
return key;
|
|
111
|
+
let hash = 2166136261;
|
|
112
|
+
for (let index = 0; index < key.length; index += 1) {
|
|
113
|
+
hash = Math.imul(hash ^ key.charCodeAt(index), 16777619);
|
|
114
|
+
}
|
|
115
|
+
return `${key.slice(0, 33)}_${(hash >>> 0).toString(36).slice(0, 6)}`;
|
|
116
|
+
}
|
|
117
|
+
function mintEvent(spec) {
|
|
118
|
+
return {
|
|
119
|
+
allocationTotalBps: 0,
|
|
120
|
+
amount: spec.amount,
|
|
121
|
+
amountDependencies: [],
|
|
122
|
+
amountMode: "fixed",
|
|
123
|
+
amountSchedule: [],
|
|
124
|
+
distribution: "single",
|
|
125
|
+
fromActor: spec.fromActor,
|
|
126
|
+
key: frameKey(spec.key),
|
|
127
|
+
kind: spec.kind,
|
|
128
|
+
label: spec.trigger,
|
|
129
|
+
occurrence: spec.occurrence ?? "once",
|
|
130
|
+
timing: spec.timing ?? "on_lifecycle",
|
|
131
|
+
toActor: spec.toActor,
|
|
132
|
+
trigger: spec.trigger,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** One lifecycle edge per verb, threading `from -> stem_1 -> ... -> to`. */
|
|
136
|
+
function chain(names, from, to, stateStem) {
|
|
137
|
+
return names.map((_, index) => ({
|
|
138
|
+
from: index === 0 ? from : `${stateStem}_${index}`,
|
|
139
|
+
to: index === names.length - 1 ? to : `${stateStem}_${index + 1}`,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
/** Refuse verb-name collisions before they silently overwrite each other. */
|
|
143
|
+
function verbNameIssues(settlementName, names, origin, issues) {
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
for (const name of names) {
|
|
146
|
+
if (name === "create" || seen.has(name)) {
|
|
147
|
+
issues.push({
|
|
148
|
+
message: `settlement ${settlementName} generates two verbs named "${name}"; rename the colliding port`,
|
|
149
|
+
span: origin,
|
|
150
|
+
});
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
seen.add(name);
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The partition clauses declared on the noun: create admission proves each
|
|
159
|
+
* sum exactly. Spread into the noun literal; empty when nothing partitions.
|
|
160
|
+
*/
|
|
161
|
+
function partitionClause(total, pieces) {
|
|
162
|
+
return pieces.length >= 2 ? [{ pieces: [...pieces], total }] : [];
|
|
163
|
+
}
|
|
164
|
+
function partitionsSpread(clauses) {
|
|
165
|
+
return clauses.length > 0 ? { partitions: [...clauses] } : {};
|
|
166
|
+
}
|
|
167
|
+
function moneyFieldSpec(desc) {
|
|
168
|
+
return { desc, type: "money" };
|
|
169
|
+
}
|
|
170
|
+
function dateFieldSpec(desc) {
|
|
171
|
+
return { desc, type: "date" };
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// The whole-program lowering
|
|
175
|
+
export function lowerProgram(program) {
|
|
176
|
+
const issues = [];
|
|
177
|
+
const settlements = [];
|
|
178
|
+
const nouns = [];
|
|
179
|
+
const moneyEvents = [];
|
|
180
|
+
const rules = [];
|
|
181
|
+
const design = [];
|
|
182
|
+
const feeLines = [];
|
|
183
|
+
const mintedKeys = new Map();
|
|
184
|
+
const portsByName = new Map(program.ports.map((port) => [port.name, port]));
|
|
185
|
+
const portFor = (settlement, portName, origin) => {
|
|
186
|
+
const port = portsByName.get(portName);
|
|
187
|
+
if (!port) {
|
|
188
|
+
issues.push({
|
|
189
|
+
message: `settlement ${settlement.name} decides through an unknown port; the checker should have refused this program`,
|
|
190
|
+
span: origin,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return port;
|
|
194
|
+
};
|
|
195
|
+
// `advance { against: <hold>.release }` carves the hold's release, so the
|
|
196
|
+
// hold must know the funder's name before it lowers. The checker already
|
|
197
|
+
// proved each target is a held payment releasing to the financed party, and
|
|
198
|
+
// that no two advances draw against the same one.
|
|
199
|
+
const carveFunderByHold = new Map(program.settlements.flatMap((settlement) => settlement.archetype === "advance" && settlement.source.kind === "carve"
|
|
200
|
+
? [[settlement.source.settlement, settlement.funder]]
|
|
201
|
+
: []));
|
|
202
|
+
for (const settlement of program.settlements) {
|
|
203
|
+
let lowered;
|
|
204
|
+
switch (settlement.archetype) {
|
|
205
|
+
case "held_payment": {
|
|
206
|
+
const port = portFor(settlement, settlement.release.port, settlement.release.origin);
|
|
207
|
+
if (!port)
|
|
208
|
+
continue;
|
|
209
|
+
lowered = lowerHeldPayment(settlement, port, carveFunderByHold.get(settlement.name), issues);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "premium_forward": {
|
|
213
|
+
const port = portFor(settlement, settlement.bind.port, settlement.bind.origin);
|
|
214
|
+
if (!port)
|
|
215
|
+
continue;
|
|
216
|
+
lowered = lowerPremiumForward(settlement, port, issues);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "deposit": {
|
|
220
|
+
const claim = portFor(settlement, settlement.claim.port, settlement.claim.origin);
|
|
221
|
+
const giveBack = portFor(settlement, settlement.return.port, settlement.return.origin);
|
|
222
|
+
if (!claim || !giveBack)
|
|
223
|
+
continue;
|
|
224
|
+
lowered = lowerDeposit(settlement, claim, giveBack, issues);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "instant_transfer":
|
|
228
|
+
lowered = lowerInstantTransfer(settlement);
|
|
229
|
+
break;
|
|
230
|
+
case "scheduled":
|
|
231
|
+
lowered = lowerScheduled(settlement);
|
|
232
|
+
break;
|
|
233
|
+
case "advance":
|
|
234
|
+
lowered = lowerAdvance(settlement);
|
|
235
|
+
break;
|
|
236
|
+
case "metered":
|
|
237
|
+
lowered = lowerMetered(settlement);
|
|
238
|
+
break;
|
|
239
|
+
case "pooled_split":
|
|
240
|
+
lowered = lowerPooledSplit(settlement);
|
|
241
|
+
break;
|
|
242
|
+
case "swap": {
|
|
243
|
+
const release = portFor(settlement, settlement.release.port, settlement.release.origin);
|
|
244
|
+
const dispute = settlement.dispute
|
|
245
|
+
? portFor(settlement, settlement.dispute.port, settlement.dispute.origin)
|
|
246
|
+
: undefined;
|
|
247
|
+
if (!release || (settlement.dispute && !dispute))
|
|
248
|
+
continue;
|
|
249
|
+
lowered = lowerSwap(settlement, release, dispute);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!lowered)
|
|
254
|
+
continue;
|
|
255
|
+
// Event and rule keys concatenate settlement names with generated stems,
|
|
256
|
+
// so two settlements can mint the same key (a + b_service_fee vs a_b +
|
|
257
|
+
// service_fee). The frame schema refuses duplicates wholesale, which
|
|
258
|
+
// would surface as an internal fault; refuse here at the source instead.
|
|
259
|
+
for (const minted of [...lowered.moneyEvents, ...lowered.rules]) {
|
|
260
|
+
const key = minted.key;
|
|
261
|
+
const owner = mintedKeys.get(key);
|
|
262
|
+
if (owner) {
|
|
263
|
+
issues.push({
|
|
264
|
+
message: `settlements ${owner} and ${settlement.name} both generate the internal key ${key}; rename one settlement (or its port or meter) so the generated keys stay distinct`,
|
|
265
|
+
span: settlement.origin,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
mintedKeys.set(key, settlement.name);
|
|
269
|
+
}
|
|
270
|
+
settlements.push(lowered.settlement);
|
|
271
|
+
nouns.push(lowered.noun);
|
|
272
|
+
moneyEvents.push(...lowered.moneyEvents);
|
|
273
|
+
rules.push(...lowered.rules);
|
|
274
|
+
design.push(...lowered.design);
|
|
275
|
+
feeLines.push(...lowered.feeLines);
|
|
276
|
+
}
|
|
277
|
+
if (moneyEvents.length > MONEY_EVENT_BUDGET) {
|
|
278
|
+
issues.push({
|
|
279
|
+
message: `this program needs ${moneyEvents.length} money events but a Business Frame carries at most ${MONEY_EVENT_BUDGET}; simplify the fee or cancellation terms, or drop a settlement`,
|
|
280
|
+
span: program.settlements[0]?.origin ?? { end: 0, start: 0 },
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (issues.length > 0)
|
|
284
|
+
return { issues, ok: false };
|
|
285
|
+
const subjects = program.assets.map((asset) => ({
|
|
286
|
+
kind: asset.name,
|
|
287
|
+
title: titleize(asset.name),
|
|
288
|
+
value: "optional",
|
|
289
|
+
}));
|
|
290
|
+
const document = {
|
|
291
|
+
hsx: HSX_IR_VERSION,
|
|
292
|
+
nouns,
|
|
293
|
+
product: program.name,
|
|
294
|
+
...(subjects.length > 0 ? { subjects } : {}),
|
|
295
|
+
title: program.title,
|
|
296
|
+
};
|
|
297
|
+
const roles = partyRoles(program.settlements);
|
|
298
|
+
const frame = {
|
|
299
|
+
actors: [
|
|
300
|
+
...program.parties
|
|
301
|
+
.filter((party) => roles.has(party.name))
|
|
302
|
+
.map((party) => ({
|
|
303
|
+
key: party.name,
|
|
304
|
+
label: titleize(party.name),
|
|
305
|
+
maxCount: 1,
|
|
306
|
+
minCount: 1,
|
|
307
|
+
role: roles.get(party.name),
|
|
308
|
+
})),
|
|
309
|
+
{
|
|
310
|
+
key: "platform",
|
|
311
|
+
label: "Platform",
|
|
312
|
+
maxCount: 1,
|
|
313
|
+
minCount: 1,
|
|
314
|
+
role: "platform",
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
confidence: "high",
|
|
318
|
+
conservationGroups: [],
|
|
319
|
+
design,
|
|
320
|
+
feePolicy: feeLines.length > 0 ? "defined" : "none",
|
|
321
|
+
fees: feeLines,
|
|
322
|
+
headline: program.title,
|
|
323
|
+
mechanics: mechanicsOf(program.settlements),
|
|
324
|
+
moneyEvents,
|
|
325
|
+
offPlatform: program.assets
|
|
326
|
+
.filter((asset) => asset.titleTransfer === "off_platform")
|
|
327
|
+
.map((asset) => ({
|
|
328
|
+
label: `${titleize(asset.name)} title transfer`,
|
|
329
|
+
why: `Ownership of the ${asset.name.replaceAll("_", " ")} changes hands outside the platform`,
|
|
330
|
+
})),
|
|
331
|
+
openQuestions: [],
|
|
332
|
+
rules,
|
|
333
|
+
subjects: program.assets.map((asset) => ({
|
|
334
|
+
kind: asset.name,
|
|
335
|
+
title: titleize(asset.name),
|
|
336
|
+
})),
|
|
337
|
+
summary: summarize(program),
|
|
338
|
+
};
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
value: { document, frame: clampFrameProse(frame), settlements },
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** Frame actor role per party, with a fixed precedence when roles overlap. */
|
|
345
|
+
function partyRoles(settlements) {
|
|
346
|
+
const payers = new Set();
|
|
347
|
+
const beneficiaries = new Set();
|
|
348
|
+
const providers = new Set();
|
|
349
|
+
const holders = new Set();
|
|
350
|
+
for (const settlement of settlements) {
|
|
351
|
+
switch (settlement.archetype) {
|
|
352
|
+
case "held_payment":
|
|
353
|
+
case "instant_transfer":
|
|
354
|
+
case "scheduled":
|
|
355
|
+
case "metered":
|
|
356
|
+
payers.add(settlement.payer);
|
|
357
|
+
beneficiaries.add(settlement.payee);
|
|
358
|
+
break;
|
|
359
|
+
case "premium_forward":
|
|
360
|
+
payers.add(settlement.payer);
|
|
361
|
+
providers.add(settlement.carrier);
|
|
362
|
+
break;
|
|
363
|
+
case "deposit":
|
|
364
|
+
payers.add(settlement.payer);
|
|
365
|
+
holders.add(settlement.holder);
|
|
366
|
+
break;
|
|
367
|
+
case "advance":
|
|
368
|
+
payers.add(settlement.funder);
|
|
369
|
+
beneficiaries.add(settlement.advanced);
|
|
370
|
+
break;
|
|
371
|
+
case "pooled_split":
|
|
372
|
+
payers.add(settlement.payer);
|
|
373
|
+
for (const share of settlement.shares)
|
|
374
|
+
beneficiaries.add(share.to);
|
|
375
|
+
break;
|
|
376
|
+
case "swap":
|
|
377
|
+
payers.add(settlement.sides[0].party);
|
|
378
|
+
beneficiaries.add(settlement.sides[1].party);
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const roles = new Map();
|
|
383
|
+
const assign = (names, role) => {
|
|
384
|
+
for (const name of names)
|
|
385
|
+
if (!roles.has(name))
|
|
386
|
+
roles.set(name, role);
|
|
387
|
+
};
|
|
388
|
+
assign(payers, "payer");
|
|
389
|
+
assign(providers, "provider");
|
|
390
|
+
assign(holders, "holder");
|
|
391
|
+
assign(beneficiaries, "beneficiary");
|
|
392
|
+
return roles;
|
|
393
|
+
}
|
|
394
|
+
const ARCHETYPE_MECHANICS = {
|
|
395
|
+
advance: "credit",
|
|
396
|
+
deposit: "escrow",
|
|
397
|
+
held_payment: "escrow",
|
|
398
|
+
instant_transfer: "marketplace",
|
|
399
|
+
metered: "recurring_billing",
|
|
400
|
+
pooled_split: "marketplace",
|
|
401
|
+
premium_forward: "insurance",
|
|
402
|
+
scheduled: "recurring_billing",
|
|
403
|
+
swap: "escrow",
|
|
404
|
+
};
|
|
405
|
+
function mechanicsOf(settlements) {
|
|
406
|
+
const mechanics = new Set(settlements.map((settlement) => ARCHETYPE_MECHANICS[settlement.archetype]));
|
|
407
|
+
return mechanics.size > 0 ? [...mechanics] : ["escrow"];
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// swap: strict two-party, two-leg atomic custody
|
|
411
|
+
function lowerSwap(settlement, releasePort, disputePort) {
|
|
412
|
+
const noun = settlement.name;
|
|
413
|
+
const [sideA, sideB] = settlement.sides;
|
|
414
|
+
const window = settlement.dispute?.window;
|
|
415
|
+
const hasClawback = window !== undefined && window.days > 0;
|
|
416
|
+
const postRuleKey = frameKey(`${noun}_clawback_maturity`);
|
|
417
|
+
const events = [];
|
|
418
|
+
const event = (stem, kind, amount, fromActor, toActor, trigger) => {
|
|
419
|
+
const key = frameKey(`${noun}_${stem}`);
|
|
420
|
+
events.push(mintEvent({ amount, fromActor, key, kind, toActor, trigger }));
|
|
421
|
+
return key;
|
|
422
|
+
};
|
|
423
|
+
const sideAFundEvent = event("side_a_fund", "charge", `The full ${sideA.amount.name}`, sideA.party, "escrow", `Fund ${sideA.amount.name} into the shared trade escrow`);
|
|
424
|
+
const sideBFundEvent = event("side_b_fund", "charge", `The full ${sideB.amount.name}`, sideB.party, "escrow", `Fund ${sideB.amount.name} into the shared trade escrow`);
|
|
425
|
+
const sideAReleaseEvent = event("side_a_release", "payout", `The full ${sideA.amount.name}`, "escrow", sideB.party, `Release ${sideA.amount.name} across to ${sideB.party.replaceAll("_", " ")}`);
|
|
426
|
+
const sideBReleaseEvent = event("side_b_release", "payout", `The full ${sideB.amount.name}`, "escrow", sideA.party, `Release ${sideB.amount.name} across to ${sideA.party.replaceAll("_", " ")}`);
|
|
427
|
+
const sideACancelEvent = event("side_a_cancel", "refund", `The full ${sideA.amount.name}`, "escrow", sideA.party, `Return ${sideA.amount.name} to its original funder on cancellation`);
|
|
428
|
+
const sideBCancelEvent = event("side_b_cancel", "refund", `The full ${sideB.amount.name}`, "escrow", sideB.party, `Return ${sideB.amount.name} to its original funder on cancellation`);
|
|
429
|
+
const sideAClawbackEvent = hasClawback
|
|
430
|
+
? event("side_a_clawback", "refund", `The full ${sideA.amount.name}`, "escrow", sideA.party, `Return ${sideA.amount.name} after the whole trade is disputed`)
|
|
431
|
+
: undefined;
|
|
432
|
+
const sideBClawbackEvent = hasClawback
|
|
433
|
+
? event("side_b_clawback", "refund", `The full ${sideB.amount.name}`, "escrow", sideB.party, `Return ${sideB.amount.name} after the whole trade is disputed`)
|
|
434
|
+
: undefined;
|
|
435
|
+
const fields = {
|
|
436
|
+
[sideA.amount.name]: moneyFieldSpec(`Side A amount in ${sideA.amount.currency} minor units, held against the whole trade`),
|
|
437
|
+
[sideB.amount.name]: moneyFieldSpec(`Side B amount in ${sideB.amount.currency} minor units, held against the whole trade`),
|
|
438
|
+
...(hasClawback
|
|
439
|
+
? {
|
|
440
|
+
clawbackAt: {
|
|
441
|
+
type: "date?",
|
|
442
|
+
desc: `Machine-owned end of the ${window?.raw ?? "fixed"} whole-trade dispute window`,
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
: {}),
|
|
446
|
+
};
|
|
447
|
+
const fundMoves = [
|
|
448
|
+
{
|
|
449
|
+
amount: sideA.amount.name,
|
|
450
|
+
from: sideA.party,
|
|
451
|
+
key: "side_a_principal",
|
|
452
|
+
moneyEvent: sideAFundEvent,
|
|
453
|
+
operation: "create",
|
|
454
|
+
to: "escrow",
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
amount: sideB.amount.name,
|
|
458
|
+
from: sideB.party,
|
|
459
|
+
key: "side_b_principal",
|
|
460
|
+
moneyEvent: sideBFundEvent,
|
|
461
|
+
operation: "create",
|
|
462
|
+
to: "escrow",
|
|
463
|
+
},
|
|
464
|
+
];
|
|
465
|
+
const feeLines = [];
|
|
466
|
+
for (const [index, side] of settlement.sides.entries()) {
|
|
467
|
+
if (!side.fee)
|
|
468
|
+
continue;
|
|
469
|
+
const field = side.fee.amount.name;
|
|
470
|
+
fields[field] = moneyFieldSpec(`Exact ${side.party.replaceAll("_", " ")} service fee in ${side.fee.amount.currency} minor units, charged on top and never held`);
|
|
471
|
+
const feeEvent = event(index === 0 ? "side_a_service_fee" : "side_b_service_fee", "charge", `The exact ${field}, on top`, side.party, "platform", `Collect the ${side.party.replaceAll("_", " ")} custody fee at funding`);
|
|
472
|
+
fundMoves.push({
|
|
473
|
+
amount: field,
|
|
474
|
+
from: side.party,
|
|
475
|
+
key: index === 0 ? "side_a_service_fee" : "side_b_service_fee",
|
|
476
|
+
moneyEvent: feeEvent,
|
|
477
|
+
operation: "create",
|
|
478
|
+
to: "platform",
|
|
479
|
+
});
|
|
480
|
+
feeLines.push({
|
|
481
|
+
label: `${titleize(side.party)} custody fee`,
|
|
482
|
+
on: `each funded ${noun.replaceAll("_", " ")}`,
|
|
483
|
+
structure: `Exact ${field}, on top`,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const releaseMoves = [
|
|
487
|
+
{
|
|
488
|
+
amount: sideA.amount.name,
|
|
489
|
+
from: "escrow",
|
|
490
|
+
key: "side_a",
|
|
491
|
+
moneyEvent: sideAReleaseEvent,
|
|
492
|
+
operation: hasClawback ? "reserve" : "create",
|
|
493
|
+
to: sideB.party,
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
amount: sideB.amount.name,
|
|
497
|
+
from: "escrow",
|
|
498
|
+
key: "side_b",
|
|
499
|
+
moneyEvent: sideBReleaseEvent,
|
|
500
|
+
operation: hasClawback ? "reserve" : "create",
|
|
501
|
+
to: sideA.party,
|
|
502
|
+
},
|
|
503
|
+
];
|
|
504
|
+
const verbs = {
|
|
505
|
+
abandon: {
|
|
506
|
+
from: ["created"],
|
|
507
|
+
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
508
|
+
summary: "Abandon the trade before its atomic funding batch",
|
|
509
|
+
to: "abandoned",
|
|
510
|
+
},
|
|
511
|
+
cancel: {
|
|
512
|
+
from: ["funded"],
|
|
513
|
+
moves: [
|
|
514
|
+
{
|
|
515
|
+
amount: sideA.amount.name,
|
|
516
|
+
from: "escrow",
|
|
517
|
+
key: "side_a_refund",
|
|
518
|
+
moneyEvent: sideACancelEvent,
|
|
519
|
+
operation: "create",
|
|
520
|
+
to: sideA.party,
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
amount: sideB.amount.name,
|
|
524
|
+
from: "escrow",
|
|
525
|
+
key: "side_b_refund",
|
|
526
|
+
moneyEvent: sideBCancelEvent,
|
|
527
|
+
operation: "create",
|
|
528
|
+
to: sideB.party,
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
summary: "Cancel and return both trade principals atomically",
|
|
532
|
+
to: "cancelled",
|
|
533
|
+
},
|
|
534
|
+
create: {
|
|
535
|
+
summary: `Create a ${titleize(noun).toLowerCase()} atomic trade`,
|
|
536
|
+
to: "created",
|
|
537
|
+
},
|
|
538
|
+
fund: {
|
|
539
|
+
from: ["created"],
|
|
540
|
+
moves: fundMoves,
|
|
541
|
+
summary: "Fund both trade sides and collect on-top fees atomically",
|
|
542
|
+
to: "funded",
|
|
543
|
+
},
|
|
544
|
+
release: {
|
|
545
|
+
from: ["funded"],
|
|
546
|
+
moves: releaseMoves,
|
|
547
|
+
port: { allowed: [...releasePort.allowed] },
|
|
548
|
+
...(hasClawback
|
|
549
|
+
? { setsAt: { field: "clawbackAt", offset: window?.raw } }
|
|
550
|
+
: {}),
|
|
551
|
+
summary: hasClawback
|
|
552
|
+
? "Reserve both cross-payments for the whole-trade clawback window"
|
|
553
|
+
: "Post both cross-payments atomically",
|
|
554
|
+
to: hasClawback ? "released" : "settled",
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
if (hasClawback &&
|
|
558
|
+
settlement.dispute &&
|
|
559
|
+
disputePort &&
|
|
560
|
+
sideAClawbackEvent &&
|
|
561
|
+
sideBClawbackEvent) {
|
|
562
|
+
verbs.post = {
|
|
563
|
+
due: { field: "clawbackAt", rule: postRuleKey },
|
|
564
|
+
from: ["released"],
|
|
565
|
+
moves: [
|
|
566
|
+
{ key: "side_a", operation: "post", reservation: "release_side_a" },
|
|
567
|
+
{ key: "side_b", operation: "post", reservation: "release_side_b" },
|
|
568
|
+
],
|
|
569
|
+
summary: "Post both trade reservations when the clawback window matures",
|
|
570
|
+
to: "settled",
|
|
571
|
+
};
|
|
572
|
+
verbs.dispute = {
|
|
573
|
+
deadline: { field: "clawbackAt" },
|
|
574
|
+
from: ["released"],
|
|
575
|
+
moves: [
|
|
576
|
+
{
|
|
577
|
+
key: "side_a_void",
|
|
578
|
+
operation: "void",
|
|
579
|
+
reason: "Whole trade disputed inside the clawback window",
|
|
580
|
+
reservation: "release_side_a",
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
key: "side_b_void",
|
|
584
|
+
operation: "void",
|
|
585
|
+
reason: "Whole trade disputed inside the clawback window",
|
|
586
|
+
reservation: "release_side_b",
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
amount: sideA.amount.name,
|
|
590
|
+
clawbackOf: "release_side_a",
|
|
591
|
+
from: "escrow",
|
|
592
|
+
key: "side_a_refund",
|
|
593
|
+
moneyEvent: sideAClawbackEvent,
|
|
594
|
+
operation: "create",
|
|
595
|
+
to: sideA.party,
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
amount: sideB.amount.name,
|
|
599
|
+
clawbackOf: "release_side_b",
|
|
600
|
+
from: "escrow",
|
|
601
|
+
key: "side_b_refund",
|
|
602
|
+
moneyEvent: sideBClawbackEvent,
|
|
603
|
+
operation: "create",
|
|
604
|
+
to: sideB.party,
|
|
605
|
+
},
|
|
606
|
+
],
|
|
607
|
+
port: { allowed: [...disputePort.allowed] },
|
|
608
|
+
summary: "Void both reservations, then refund both principals atomically",
|
|
609
|
+
to: "clawed_back",
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const rules = [
|
|
613
|
+
{
|
|
614
|
+
allowedActors: [...releasePort.allowed],
|
|
615
|
+
detail: `${releasePort.allowed.map(titleize).join(" or ")} confirms the whole exchange through ${releasePort.name}`,
|
|
616
|
+
dueDriven: false,
|
|
617
|
+
enforcement: "tenant_app",
|
|
618
|
+
gatesEvent: sideAReleaseEvent,
|
|
619
|
+
key: frameKey(`${noun}_${releasePort.name}_gate`),
|
|
620
|
+
kind: "release_condition",
|
|
621
|
+
label: `Whole trade released through ${releasePort.name}`,
|
|
622
|
+
tenantTunable: false,
|
|
623
|
+
},
|
|
624
|
+
];
|
|
625
|
+
if (hasClawback && settlement.dispute && disputePort) {
|
|
626
|
+
rules.push({
|
|
627
|
+
allowedActors: [],
|
|
628
|
+
detail: `Both pending trade payouts post together at the immutable ${settlement.dispute.window.raw} cutoff`,
|
|
629
|
+
dueDriven: true,
|
|
630
|
+
enforcement: "platform",
|
|
631
|
+
gatesEvent: null,
|
|
632
|
+
key: postRuleKey,
|
|
633
|
+
kind: "deadline",
|
|
634
|
+
label: "Whole trade posts when its clawback window matures",
|
|
635
|
+
tenantTunable: false,
|
|
636
|
+
}, {
|
|
637
|
+
allowedActors: [...disputePort.allowed],
|
|
638
|
+
detail: `${disputePort.allowed.map(titleize).join(" or ")} may dispute only before the immutable cutoff`,
|
|
639
|
+
dueDriven: false,
|
|
640
|
+
enforcement: "tenant_app",
|
|
641
|
+
gatesEvent: sideAClawbackEvent ?? null,
|
|
642
|
+
key: frameKey(`${noun}_${disputePort.name}_gate`),
|
|
643
|
+
kind: "release_condition",
|
|
644
|
+
label: `Whole trade disputed through ${disputePort.name}`,
|
|
645
|
+
tenantTunable: false,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
design: [
|
|
650
|
+
`${noun}: exactly two parties, two same-currency principals, one escrow, and one linked batch per phase`,
|
|
651
|
+
hasClawback
|
|
652
|
+
? `${noun}: ${window?.raw} whole-trade clawback; release reserves both legs, then exactly one grouped post or grouped void-and-refund wins`
|
|
653
|
+
: `${noun}: no clawback window; release posts both legs directly and exposes no pending or dispute surface`,
|
|
654
|
+
],
|
|
655
|
+
feeLines,
|
|
656
|
+
moneyEvents: events,
|
|
657
|
+
noun: {
|
|
658
|
+
actors: {
|
|
659
|
+
[sideA.party]: "payer",
|
|
660
|
+
[sideB.party]: "beneficiary",
|
|
661
|
+
...(feeLines.length > 0 ? { platform: "party" } : {}),
|
|
662
|
+
},
|
|
663
|
+
desc: `Atomic swap between ${sideA.party.replaceAll("_", " ")} and ${sideB.party.replaceAll("_", " ")}; half-funded and half-released states do not exist`,
|
|
664
|
+
distinctParties: true,
|
|
665
|
+
escrow: true,
|
|
666
|
+
fields,
|
|
667
|
+
id: noun,
|
|
668
|
+
summary: `Two-party atomic trade between ${sideA.party.replaceAll("_", " ")} and ${sideB.party.replaceAll("_", " ")}`,
|
|
669
|
+
title: titleize(noun),
|
|
670
|
+
verbs,
|
|
671
|
+
},
|
|
672
|
+
rules,
|
|
673
|
+
settlement: {
|
|
674
|
+
name: noun,
|
|
675
|
+
pieces: [
|
|
676
|
+
{
|
|
677
|
+
bps: 10_000,
|
|
678
|
+
cancelTo: sideA.party,
|
|
679
|
+
field: sideA.amount.name,
|
|
680
|
+
origin: sideA.amount.origin,
|
|
681
|
+
releaseTo: sideB.party,
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
bps: 10_000,
|
|
685
|
+
cancelTo: sideB.party,
|
|
686
|
+
field: sideB.amount.name,
|
|
687
|
+
origin: sideB.amount.origin,
|
|
688
|
+
releaseTo: sideA.party,
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// held_payment and premium_forward: the escrow-held family
|
|
696
|
+
function lowerHeldPayment(settlement, port,
|
|
697
|
+
/** The funder of the advance carving this hold's release, when one does. */
|
|
698
|
+
carveTo, issues) {
|
|
699
|
+
const payerFee = settlement.fees.find((fee) => fee.bearer === settlement.payer);
|
|
700
|
+
const payeeFee = settlement.fees.find((fee) => fee.bearer === settlement.payee);
|
|
701
|
+
const held = lowerHeldFamily({
|
|
702
|
+
amount: settlement.amount,
|
|
703
|
+
carveTo,
|
|
704
|
+
deadlineField: settlement.releaseDeadlineField,
|
|
705
|
+
fundEventKind: "charge",
|
|
706
|
+
fundTrigger: (index, total) => `Fund piece ${index + 1} of ${total} into escrow`,
|
|
707
|
+
name: settlement.name,
|
|
708
|
+
onCancel: settlement.onCancel,
|
|
709
|
+
payee: settlement.payee,
|
|
710
|
+
payeeFeeBps: payeeFee?.bps ?? 0,
|
|
711
|
+
payer: settlement.payer,
|
|
712
|
+
payerFeeBps: payerFee?.bps,
|
|
713
|
+
port,
|
|
714
|
+
releaseWord: "release",
|
|
715
|
+
}, issues);
|
|
716
|
+
if (!held)
|
|
717
|
+
return undefined;
|
|
718
|
+
return {
|
|
719
|
+
...held,
|
|
720
|
+
feeLines: [
|
|
721
|
+
...(payerFee
|
|
722
|
+
? [
|
|
723
|
+
{
|
|
724
|
+
label: `${titleize(settlement.payer)} service fee`,
|
|
725
|
+
on: `each funded ${settlement.name.replaceAll("_", " ")}`,
|
|
726
|
+
structure: `${formatBps(payerFee.bps)} of the ${settlement.amount.name}, on top`,
|
|
727
|
+
},
|
|
728
|
+
]
|
|
729
|
+
: []),
|
|
730
|
+
...(payeeFee
|
|
731
|
+
? [
|
|
732
|
+
{
|
|
733
|
+
label: `${titleize(settlement.payee)} fee`,
|
|
734
|
+
on: `each released ${settlement.name.replaceAll("_", " ")}`,
|
|
735
|
+
structure: `${formatBps(payeeFee.bps)} of the ${settlement.amount.name}, deducted from the payout`,
|
|
736
|
+
},
|
|
737
|
+
]
|
|
738
|
+
: []),
|
|
739
|
+
],
|
|
740
|
+
noun: {
|
|
741
|
+
...held.noun,
|
|
742
|
+
desc: `Held payment: the ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into this settlement's own escrow; ${port.allowed
|
|
743
|
+
.map((party) => party.replaceAll("_", " "))
|
|
744
|
+
.join(" or ")} confirms through ${port.name} to release${carveTo ? ` to the ${carveTo.replaceAll("_", " ")}, whose advance the ${settlement.payee.replaceAll("_", " ")} repays out of it` : ""}`,
|
|
745
|
+
summary: `Escrow-held payment from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function lowerPremiumForward(settlement, port, issues) {
|
|
750
|
+
const held = lowerHeldFamily({
|
|
751
|
+
amount: settlement.amount,
|
|
752
|
+
// A premium is the carrier's, never the payer's receivable, so there is
|
|
753
|
+
// nothing here for an advance to draw against.
|
|
754
|
+
carveTo: undefined,
|
|
755
|
+
deadlineField: undefined,
|
|
756
|
+
fundEventKind: "premium",
|
|
757
|
+
fundTrigger: (index, total) => total === 1
|
|
758
|
+
? "Collect the premium into escrow"
|
|
759
|
+
: `Collect premium piece ${index + 1} of ${total} into escrow`,
|
|
760
|
+
name: settlement.name,
|
|
761
|
+
onCancel: settlement.onCancel,
|
|
762
|
+
payee: settlement.carrier,
|
|
763
|
+
payeeFeeBps: settlement.commissionBps,
|
|
764
|
+
payer: settlement.payer,
|
|
765
|
+
payerFeeBps: undefined,
|
|
766
|
+
port,
|
|
767
|
+
releaseWord: "forward",
|
|
768
|
+
}, issues);
|
|
769
|
+
if (!held)
|
|
770
|
+
return undefined;
|
|
771
|
+
return {
|
|
772
|
+
...held,
|
|
773
|
+
design: [
|
|
774
|
+
`${settlement.name}: premium forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on ${port.name}; ${formatBps(settlement.commissionBps)} commission retained by the platform`,
|
|
775
|
+
],
|
|
776
|
+
feeLines: settlement.commissionBps > 0
|
|
777
|
+
? [
|
|
778
|
+
{
|
|
779
|
+
label: "Platform commission",
|
|
780
|
+
on: `each bound ${settlement.name.replaceAll("_", " ")}`,
|
|
781
|
+
structure: `${formatBps(settlement.commissionBps)} of the ${settlement.amount.name}, deducted at forwarding`,
|
|
782
|
+
},
|
|
783
|
+
]
|
|
784
|
+
: [],
|
|
785
|
+
noun: {
|
|
786
|
+
...held.noun,
|
|
787
|
+
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`,
|
|
788
|
+
summary: `Premium held for the ${settlement.carrier.replaceAll("_", " ")} until the policy binds`,
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function lowerHeldFamily(params, issues) {
|
|
793
|
+
const amountName = params.amount.name;
|
|
794
|
+
// A single-piece partition would mint a piece field nothing ties to the
|
|
795
|
+
// gross amount (no partition clause is declarable over one piece), letting
|
|
796
|
+
// an instance store one gross and move another. When the amount never
|
|
797
|
+
// splits, the choreography moves the amount field ITSELF.
|
|
798
|
+
const rawPieces = partitionPieces(params);
|
|
799
|
+
const pieces = rawPieces.length === 1
|
|
800
|
+
? rawPieces.map((piece) => ({ ...piece, field: amountName }))
|
|
801
|
+
: rawPieces;
|
|
802
|
+
const noun = params.name;
|
|
803
|
+
// Who the payee's share actually lands on. Every sentence about the release
|
|
804
|
+
// has to say this name, not the payee's, or the program would describe a
|
|
805
|
+
// payout it does not make.
|
|
806
|
+
const releaseTo = params.carveTo ?? params.payee;
|
|
807
|
+
const releaseToWords = releaseTo.replaceAll("_", " ");
|
|
808
|
+
const fields = {
|
|
809
|
+
[amountName]: moneyFieldSpec(pieces.length === 1
|
|
810
|
+
? `The held amount in ${params.amount.currency} minor units, funded and paid out whole`
|
|
811
|
+
: `The gross held amount in ${params.amount.currency} minor units; the piece fields below partition it exactly`),
|
|
812
|
+
};
|
|
813
|
+
for (const [index, piece] of pieces.entries()) {
|
|
814
|
+
if (piece.field === amountName)
|
|
815
|
+
continue;
|
|
816
|
+
fields[piece.field] = moneyFieldSpec(pieceDescription(piece, index, amountName, params.amount.currency));
|
|
817
|
+
}
|
|
818
|
+
if (params.deadlineField) {
|
|
819
|
+
fields[params.deadlineField] = dateFieldSpec(`The date an undecided hold releases to the ${releaseToWords} on; ${params.port.name} and cancellation decide only before it`);
|
|
820
|
+
}
|
|
821
|
+
if (params.payerFeeBps !== undefined) {
|
|
822
|
+
fields.serviceFeeAmount = moneyFieldSpec(`${formatBps(params.payerFeeBps)} of ${amountName}, the ${params.payer.replaceAll("_", " ")}-side service fee charged on top at funding; non-refundable`);
|
|
823
|
+
}
|
|
824
|
+
const fundVerbs = pieces.map((_, index) => `fund_piece_${index + 1}`);
|
|
825
|
+
if (params.payerFeeBps !== undefined)
|
|
826
|
+
fundVerbs.push("collect_service_fee");
|
|
827
|
+
const releaseVerbs = pieces.map((_, index) => index === 0 ? params.port.name : `${params.releaseWord}_piece_${index + 1}`);
|
|
828
|
+
const cancelVerbs = params.onCancel
|
|
829
|
+
? pieces.map((_, index) => index === 0 ? "cancel" : `refund_piece_${index + 1}`)
|
|
830
|
+
: [];
|
|
831
|
+
// The anchor is the DEFAULT exit, not a second decider. It mints one more
|
|
832
|
+
// entry into the SAME release chain, so every piece drains through the
|
|
833
|
+
// verbs the port path already proves, and the port and the cancel keep
|
|
834
|
+
// their veto only until the date. Acting before it IS the veto.
|
|
835
|
+
const deadlineVerb = params.deadlineField
|
|
836
|
+
? `${params.releaseWord}_on_deadline`
|
|
837
|
+
: undefined;
|
|
838
|
+
const deadlineRuleKey = frameKey(`${noun}_${params.releaseWord}_deadline`);
|
|
839
|
+
// Abandonment: the pre-funded exit. Custody exists only while the deal is
|
|
840
|
+
// still forming, so each intermediate funding state (funding_k holds pieces
|
|
841
|
+
// 1..k) unwinds piece by piece. Every unfund verb returns exactly the piece
|
|
842
|
+
// its funding verb moved, back to the payer, and `created` closes directly.
|
|
843
|
+
// The chain runs through its own abandoning_* states (never back into
|
|
844
|
+
// funding states) so the lifecycle stays acyclic and the terminal-escrow
|
|
845
|
+
// analysis keeps its exact custody tokens. The service fee moves only on the
|
|
846
|
+
// transition INTO funded (a completed collection), so abandonment never owes
|
|
847
|
+
// it, the on_cancel policy stays the sole exit from funded.
|
|
848
|
+
const fundingStateCount = fundVerbs.length - 1;
|
|
849
|
+
const unfundVerbs = Array.from({ length: fundingStateCount }, (_, index) => `unfund_piece_${index + 1}`);
|
|
850
|
+
if (!verbNameIssues(params.name, [
|
|
851
|
+
...fundVerbs,
|
|
852
|
+
...releaseVerbs,
|
|
853
|
+
...(deadlineVerb ? [deadlineVerb] : []),
|
|
854
|
+
...cancelVerbs,
|
|
855
|
+
"abandon",
|
|
856
|
+
...unfundVerbs,
|
|
857
|
+
], params.port.origin, issues)) {
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
const fundStates = chain(fundVerbs, "created", "funded", "funding");
|
|
861
|
+
const releaseStates = chain(releaseVerbs, "funded", "released", "releasing");
|
|
862
|
+
const cancelStates = chain(cancelVerbs, "funded", "cancelled", "cancelling");
|
|
863
|
+
const events = [];
|
|
864
|
+
const verbs = {};
|
|
865
|
+
// The budget counts money BEHAVIORS, not pieces: every piece verb sharing a
|
|
866
|
+
// phase and endpoint pair implements ONE frame event (occurrence repeatable
|
|
867
|
+
// when several piece verbs share it), so fee carving and cancellation splits
|
|
868
|
+
// never crowd a composite program out of the frame's event budget.
|
|
869
|
+
const fundEventKey = `${noun}_fund`;
|
|
870
|
+
events.push(mintEvent({
|
|
871
|
+
amount: pieces.length === 1
|
|
872
|
+
? `The full ${amountName}`
|
|
873
|
+
: `The ${amountName}, funded piece by piece`,
|
|
874
|
+
fromActor: params.payer,
|
|
875
|
+
key: fundEventKey,
|
|
876
|
+
kind: params.fundEventKind,
|
|
877
|
+
...(pieces.length > 1 ? { occurrence: "repeatable" } : {}),
|
|
878
|
+
toActor: "escrow",
|
|
879
|
+
trigger: params.fundTrigger(0, pieces.length),
|
|
880
|
+
}));
|
|
881
|
+
for (const [index, piece] of pieces.entries()) {
|
|
882
|
+
verbs[fundVerbs[index]] = {
|
|
883
|
+
from: [fundStates[index]?.from],
|
|
884
|
+
moneyEvent: fundEventKey,
|
|
885
|
+
moves: [
|
|
886
|
+
{
|
|
887
|
+
key: "transfer",
|
|
888
|
+
operation: "create",
|
|
889
|
+
amount: piece.field,
|
|
890
|
+
from: params.payer,
|
|
891
|
+
to: "escrow",
|
|
892
|
+
},
|
|
893
|
+
],
|
|
894
|
+
summary: `Fund piece ${index + 1} of the held amount into escrow`,
|
|
895
|
+
to: fundStates[index]?.to,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
if (params.payerFeeBps !== undefined) {
|
|
899
|
+
const index = fundVerbs.length - 1;
|
|
900
|
+
const eventKey = `${noun}_service_fee`;
|
|
901
|
+
events.push(mintEvent({
|
|
902
|
+
amount: `${formatBps(params.payerFeeBps)} of the ${amountName}, on top`,
|
|
903
|
+
fromActor: params.payer,
|
|
904
|
+
key: eventKey,
|
|
905
|
+
kind: "charge",
|
|
906
|
+
toActor: "platform",
|
|
907
|
+
trigger: "Collect the service fee at funding",
|
|
908
|
+
}));
|
|
909
|
+
verbs.collect_service_fee = {
|
|
910
|
+
from: [fundStates[index]?.from],
|
|
911
|
+
moneyEvent: eventKey,
|
|
912
|
+
moves: [
|
|
913
|
+
{
|
|
914
|
+
key: "transfer",
|
|
915
|
+
operation: "create",
|
|
916
|
+
amount: "serviceFeeAmount",
|
|
917
|
+
from: params.payer,
|
|
918
|
+
to: "platform",
|
|
919
|
+
},
|
|
920
|
+
],
|
|
921
|
+
summary: "Collect the payer-side service fee",
|
|
922
|
+
to: fundStates[index]?.to,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
// Release and cancel pieces group by recipient: one frame event per
|
|
926
|
+
// distinct endpoint (an event's toActor is fixed), shared by every piece
|
|
927
|
+
// verb paying that recipient.
|
|
928
|
+
const releaseGroups = new Map();
|
|
929
|
+
for (const piece of pieces) {
|
|
930
|
+
releaseGroups.set(piece.releaseTo, (releaseGroups.get(piece.releaseTo) ?? 0) + 1);
|
|
931
|
+
}
|
|
932
|
+
for (const [releaseTo, pieceCount] of releaseGroups) {
|
|
933
|
+
const totalBps = pieces
|
|
934
|
+
.filter((piece) => piece.releaseTo === releaseTo)
|
|
935
|
+
.reduce((sum, piece) => sum + piece.bps, 0);
|
|
936
|
+
events.push(mintEvent({
|
|
937
|
+
amount: `${formatBps(totalBps)} of the ${amountName}`,
|
|
938
|
+
fromActor: "escrow",
|
|
939
|
+
key: `${noun}_release_${releaseTo}`,
|
|
940
|
+
kind: "payout",
|
|
941
|
+
...(pieceCount > 1 ? { occurrence: "repeatable" } : {}),
|
|
942
|
+
toActor: releaseTo,
|
|
943
|
+
trigger: `Release to the ${releaseTo.replaceAll("_", " ")}`,
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
for (const [index, piece] of pieces.entries()) {
|
|
947
|
+
verbs[releaseVerbs[index]] = {
|
|
948
|
+
...(index === 0 && params.deadlineField
|
|
949
|
+
? { deadline: { field: params.deadlineField } }
|
|
950
|
+
: {}),
|
|
951
|
+
from: [releaseStates[index]?.from],
|
|
952
|
+
moneyEvent: frameKey(`${noun}_release_${piece.releaseTo}`),
|
|
953
|
+
moves: [
|
|
954
|
+
{
|
|
955
|
+
key: "transfer",
|
|
956
|
+
operation: "create",
|
|
957
|
+
amount: piece.field,
|
|
958
|
+
from: "escrow",
|
|
959
|
+
to: piece.releaseTo,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
summary: index === 0
|
|
963
|
+
? `Confirm through ${params.port.name} and start the ${params.releaseWord} payout`
|
|
964
|
+
: `${titleize(params.releaseWord)} piece ${index + 1} of the held amount`,
|
|
965
|
+
to: releaseStates[index]?.to,
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
if (deadlineVerb && params.deadlineField) {
|
|
969
|
+
const first = pieces[0];
|
|
970
|
+
verbs[deadlineVerb] = {
|
|
971
|
+
due: { field: params.deadlineField, rule: deadlineRuleKey },
|
|
972
|
+
from: [releaseStates[0]?.from],
|
|
973
|
+
moneyEvent: frameKey(`${noun}_release_${first.releaseTo}`),
|
|
974
|
+
moves: [
|
|
975
|
+
{
|
|
976
|
+
key: "transfer",
|
|
977
|
+
operation: "create",
|
|
978
|
+
amount: first.field,
|
|
979
|
+
from: "escrow",
|
|
980
|
+
to: first.releaseTo,
|
|
981
|
+
},
|
|
982
|
+
],
|
|
983
|
+
summary: `Release to the ${first.releaseTo.replaceAll("_", " ")} when ${params.deadlineField} arrives undecided`,
|
|
984
|
+
to: releaseStates[0]?.to,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
if (params.onCancel) {
|
|
988
|
+
const cancelGroups = new Map();
|
|
989
|
+
for (const piece of pieces) {
|
|
990
|
+
const cancelTo = piece.cancelTo;
|
|
991
|
+
cancelGroups.set(cancelTo, (cancelGroups.get(cancelTo) ?? 0) + 1);
|
|
992
|
+
}
|
|
993
|
+
for (const [cancelTo, pieceCount] of cancelGroups) {
|
|
994
|
+
const totalBps = pieces
|
|
995
|
+
.filter((piece) => piece.cancelTo === cancelTo)
|
|
996
|
+
.reduce((sum, piece) => sum + piece.bps, 0);
|
|
997
|
+
events.push(mintEvent({
|
|
998
|
+
amount: `${formatBps(totalBps)} of the ${amountName}`,
|
|
999
|
+
fromActor: "escrow",
|
|
1000
|
+
key: `${noun}_cancel_${cancelTo}`,
|
|
1001
|
+
kind: cancelTo === params.payer ? "refund" : "penalty",
|
|
1002
|
+
...(pieceCount > 1 ? { occurrence: "repeatable" } : {}),
|
|
1003
|
+
toActor: cancelTo,
|
|
1004
|
+
trigger: `Return to the ${cancelTo.replaceAll("_", " ")} on cancellation`,
|
|
1005
|
+
}));
|
|
1006
|
+
}
|
|
1007
|
+
for (const [index, piece] of pieces.entries()) {
|
|
1008
|
+
verbs[cancelVerbs[index]] = {
|
|
1009
|
+
...(index === 0 && params.deadlineField
|
|
1010
|
+
? { deadline: { field: params.deadlineField } }
|
|
1011
|
+
: {}),
|
|
1012
|
+
from: [cancelStates[index]?.from],
|
|
1013
|
+
moneyEvent: frameKey(`${noun}_cancel_${piece.cancelTo}`),
|
|
1014
|
+
moves: [
|
|
1015
|
+
{
|
|
1016
|
+
key: "transfer",
|
|
1017
|
+
operation: "create",
|
|
1018
|
+
amount: piece.field,
|
|
1019
|
+
from: "escrow",
|
|
1020
|
+
to: piece.cancelTo,
|
|
1021
|
+
},
|
|
1022
|
+
],
|
|
1023
|
+
summary: index === 0
|
|
1024
|
+
? "Cancel the settlement and start the unwind"
|
|
1025
|
+
: `Return piece ${index + 1} on cancellation`,
|
|
1026
|
+
to: cancelStates[index]?.to,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (unfundVerbs.length > 0) {
|
|
1031
|
+
const eventKey = `${noun}_abandon`;
|
|
1032
|
+
events.push(mintEvent({
|
|
1033
|
+
amount: `The funded pieces of the ${amountName}, returned exactly`,
|
|
1034
|
+
fromActor: "escrow",
|
|
1035
|
+
key: eventKey,
|
|
1036
|
+
kind: "refund",
|
|
1037
|
+
...(unfundVerbs.length > 1
|
|
1038
|
+
? { occurrence: "repeatable" }
|
|
1039
|
+
: {}),
|
|
1040
|
+
toActor: params.payer,
|
|
1041
|
+
trigger: `Return the held pieces to the ${params.payer.replaceAll("_", " ")} on abandonment`,
|
|
1042
|
+
}));
|
|
1043
|
+
for (const [index, verbName] of unfundVerbs.entries()) {
|
|
1044
|
+
const step = index + 1;
|
|
1045
|
+
verbs[verbName] = {
|
|
1046
|
+
from: [
|
|
1047
|
+
`funding_${step}`,
|
|
1048
|
+
...(step < fundingStateCount ? [`abandoning_${step}`] : []),
|
|
1049
|
+
],
|
|
1050
|
+
moneyEvent: frameKey(eventKey),
|
|
1051
|
+
moves: [
|
|
1052
|
+
{
|
|
1053
|
+
key: "transfer",
|
|
1054
|
+
operation: "create",
|
|
1055
|
+
amount: pieces[index].field,
|
|
1056
|
+
from: "escrow",
|
|
1057
|
+
to: params.payer,
|
|
1058
|
+
},
|
|
1059
|
+
],
|
|
1060
|
+
summary: `Return piece ${step} to the ${params.payer.replaceAll("_", " ")} on abandonment`,
|
|
1061
|
+
to: step === 1 ? "abandoned" : `abandoning_${step - 1}`,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
verbs.abandon = {
|
|
1066
|
+
from: ["created"],
|
|
1067
|
+
summary: "Abandon the settlement before any money is held",
|
|
1068
|
+
to: "abandoned",
|
|
1069
|
+
};
|
|
1070
|
+
verbs.create = {
|
|
1071
|
+
summary: `Create a ${titleize(params.name).toLowerCase()} settlement`,
|
|
1072
|
+
to: "created",
|
|
1073
|
+
};
|
|
1074
|
+
const rules = [
|
|
1075
|
+
{
|
|
1076
|
+
allowedActors: [...params.port.allowed],
|
|
1077
|
+
detail: `${params.port.allowed.map(titleize).join(" or ")} confirms through the tenant backend before ${params.deadlineField ? `${params.deadlineField}, to decide ahead of it` : "any payout"}`,
|
|
1078
|
+
dueDriven: false,
|
|
1079
|
+
enforcement: "tenant_app",
|
|
1080
|
+
gatesEvent: frameKey(`${noun}_release_${pieces[0].releaseTo}`),
|
|
1081
|
+
key: frameKey(`${noun}_${params.port.name}_gate`),
|
|
1082
|
+
kind: "release_condition",
|
|
1083
|
+
label: `${titleize(params.releaseWord)} decided through ${params.port.name}`,
|
|
1084
|
+
tenantTunable: false,
|
|
1085
|
+
},
|
|
1086
|
+
];
|
|
1087
|
+
if (params.deadlineField) {
|
|
1088
|
+
rules.push({
|
|
1089
|
+
allowedActors: [],
|
|
1090
|
+
detail: `A hold nobody decided releases to the ${releaseToWords} on its stored ${params.deadlineField}, exactly once`,
|
|
1091
|
+
dueDriven: true,
|
|
1092
|
+
enforcement: "platform",
|
|
1093
|
+
gatesEvent: null,
|
|
1094
|
+
key: deadlineRuleKey,
|
|
1095
|
+
kind: "deadline",
|
|
1096
|
+
label: `Undecided holds release on ${params.deadlineField}`,
|
|
1097
|
+
tenantTunable: false,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
return {
|
|
1101
|
+
design: [
|
|
1102
|
+
`${noun}: own escrow; ${pieces.length}-piece partition of ${amountName} (${pieces
|
|
1103
|
+
.map((piece) => formatBps(piece.bps))
|
|
1104
|
+
.join(" + ")}); every exit drains every piece; abandonable before funded (created closes directly, funding states unwind piece by piece to the ${params.payer})`,
|
|
1105
|
+
...(params.deadlineField
|
|
1106
|
+
? [
|
|
1107
|
+
`${noun}: undecided holds release to the ${releaseTo} on ${params.deadlineField}; the port and the cancel decide only before that anchor`,
|
|
1108
|
+
]
|
|
1109
|
+
: []),
|
|
1110
|
+
...(params.carveTo
|
|
1111
|
+
? [
|
|
1112
|
+
`${noun}: the ${params.payee}'s whole release share is carved to the ${params.carveTo}, who financed it; the platform fee and the cancellation split are untouched`,
|
|
1113
|
+
]
|
|
1114
|
+
: []),
|
|
1115
|
+
...(params.payerFeeBps !== undefined
|
|
1116
|
+
? [
|
|
1117
|
+
`${noun}: ${formatBps(params.payerFeeBps)} ${params.payer} service fee on top, straight to platform at funding`,
|
|
1118
|
+
]
|
|
1119
|
+
: []),
|
|
1120
|
+
],
|
|
1121
|
+
feeLines: [],
|
|
1122
|
+
moneyEvents: events,
|
|
1123
|
+
noun: {
|
|
1124
|
+
actors: {
|
|
1125
|
+
[params.payer]: "payer",
|
|
1126
|
+
// The payee stays the beneficiary under a carve, it is their
|
|
1127
|
+
// receivable, while the funder joins as the endpoint the release
|
|
1128
|
+
// pays. Only one beneficiary, so the frame's parties stay unambiguous.
|
|
1129
|
+
[params.payee]: "beneficiary",
|
|
1130
|
+
...(params.carveTo ? { [params.carveTo]: "party" } : {}),
|
|
1131
|
+
platform: "party",
|
|
1132
|
+
},
|
|
1133
|
+
desc: `Held amount from ${params.payer.replaceAll("_", " ")} to ${params.payee.replaceAll("_", " ")}${params.carveTo ? `, released to the ${releaseToWords} against the advance it secures` : ""}`,
|
|
1134
|
+
escrow: true,
|
|
1135
|
+
fields,
|
|
1136
|
+
id: params.name,
|
|
1137
|
+
...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
|
|
1138
|
+
summary: `Escrow-held amount from ${params.payer.replaceAll("_", " ")}`,
|
|
1139
|
+
title: titleize(params.name),
|
|
1140
|
+
verbs,
|
|
1141
|
+
},
|
|
1142
|
+
rules,
|
|
1143
|
+
settlement: {
|
|
1144
|
+
name: params.name,
|
|
1145
|
+
pieces,
|
|
1146
|
+
...(params.payerFeeBps !== undefined
|
|
1147
|
+
? { serviceFee: { bps: params.payerFeeBps, field: "serviceFeeAmount" } }
|
|
1148
|
+
: {}),
|
|
1149
|
+
},
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
// ---------------------------------------------------------------------------
|
|
1153
|
+
// instant_transfer: straight-through partitioned payment, no custody
|
|
1154
|
+
function lowerInstantTransfer(settlement) {
|
|
1155
|
+
const payerFee = settlement.fees.find((fee) => fee.bearer === settlement.payer);
|
|
1156
|
+
const payeeFee = settlement.fees.find((fee) => fee.bearer === settlement.payee);
|
|
1157
|
+
const amountName = settlement.amount.name;
|
|
1158
|
+
// Same single-piece law as the held family: a fee-free transfer moves the
|
|
1159
|
+
// amount field itself, so nothing untied to the gross can be admitted.
|
|
1160
|
+
const rawPieces = partitionPieces({
|
|
1161
|
+
amount: settlement.amount,
|
|
1162
|
+
payee: settlement.payee,
|
|
1163
|
+
payeeFeeBps: payeeFee?.bps ?? 0,
|
|
1164
|
+
});
|
|
1165
|
+
const pieces = rawPieces.length === 1
|
|
1166
|
+
? rawPieces.map((piece) => ({ ...piece, field: amountName }))
|
|
1167
|
+
: rawPieces;
|
|
1168
|
+
const noun = settlement.name;
|
|
1169
|
+
const fields = {
|
|
1170
|
+
[amountName]: moneyFieldSpec(pieces.length === 1
|
|
1171
|
+
? `The amount in ${settlement.amount.currency} minor units, paid through whole`
|
|
1172
|
+
: `The gross amount in ${settlement.amount.currency} minor units; the piece fields below partition it exactly`),
|
|
1173
|
+
};
|
|
1174
|
+
for (const [index, piece] of pieces.entries()) {
|
|
1175
|
+
if (piece.field === amountName)
|
|
1176
|
+
continue;
|
|
1177
|
+
fields[piece.field] = moneyFieldSpec(pieceDescription(piece, index, amountName, settlement.amount.currency));
|
|
1178
|
+
}
|
|
1179
|
+
if (payerFee) {
|
|
1180
|
+
fields.serviceFeeAmount = moneyFieldSpec(`${formatBps(payerFee.bps)} of ${amountName}, the ${settlement.payer.replaceAll("_", " ")}-side service fee charged on top; non-refundable`);
|
|
1181
|
+
}
|
|
1182
|
+
const payVerbs = pieces.map((_, index) => `pay_piece_${index + 1}`);
|
|
1183
|
+
if (payerFee)
|
|
1184
|
+
payVerbs.push("collect_service_fee");
|
|
1185
|
+
const payStates = chain(payVerbs, "created", "paid", "paying");
|
|
1186
|
+
const events = [];
|
|
1187
|
+
const verbs = {
|
|
1188
|
+
create: {
|
|
1189
|
+
summary: `Create a ${titleize(noun).toLowerCase()} payment`,
|
|
1190
|
+
to: "created",
|
|
1191
|
+
},
|
|
1192
|
+
};
|
|
1193
|
+
for (const [index, piece] of pieces.entries()) {
|
|
1194
|
+
const eventKey = `${noun}_pay_${index + 1}`;
|
|
1195
|
+
events.push(mintEvent({
|
|
1196
|
+
amount: `${formatBps(piece.bps)} of the ${amountName}`,
|
|
1197
|
+
fromActor: settlement.payer,
|
|
1198
|
+
key: eventKey,
|
|
1199
|
+
kind: "charge",
|
|
1200
|
+
toActor: piece.releaseTo,
|
|
1201
|
+
trigger: `Pay piece ${index + 1} straight to the ${piece.releaseTo.replaceAll("_", " ")}`,
|
|
1202
|
+
}));
|
|
1203
|
+
verbs[payVerbs[index]] = {
|
|
1204
|
+
from: [payStates[index]?.from],
|
|
1205
|
+
moneyEvent: eventKey,
|
|
1206
|
+
moves: [
|
|
1207
|
+
{
|
|
1208
|
+
key: "transfer",
|
|
1209
|
+
operation: "create",
|
|
1210
|
+
amount: piece.field,
|
|
1211
|
+
from: settlement.payer,
|
|
1212
|
+
to: piece.releaseTo,
|
|
1213
|
+
},
|
|
1214
|
+
],
|
|
1215
|
+
summary: `Pay piece ${index + 1} of the amount through`,
|
|
1216
|
+
to: payStates[index]?.to,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
if (payerFee) {
|
|
1220
|
+
const index = payVerbs.length - 1;
|
|
1221
|
+
const eventKey = `${noun}_service_fee`;
|
|
1222
|
+
events.push(mintEvent({
|
|
1223
|
+
amount: `${formatBps(payerFee.bps)} of the ${amountName}, on top`,
|
|
1224
|
+
fromActor: settlement.payer,
|
|
1225
|
+
key: eventKey,
|
|
1226
|
+
kind: "charge",
|
|
1227
|
+
toActor: "platform",
|
|
1228
|
+
trigger: "Collect the service fee with the payment",
|
|
1229
|
+
}));
|
|
1230
|
+
verbs.collect_service_fee = {
|
|
1231
|
+
from: [payStates[index]?.from],
|
|
1232
|
+
moneyEvent: eventKey,
|
|
1233
|
+
moves: [
|
|
1234
|
+
{
|
|
1235
|
+
key: "transfer",
|
|
1236
|
+
operation: "create",
|
|
1237
|
+
amount: "serviceFeeAmount",
|
|
1238
|
+
from: settlement.payer,
|
|
1239
|
+
to: "platform",
|
|
1240
|
+
},
|
|
1241
|
+
],
|
|
1242
|
+
summary: "Collect the payer-side service fee",
|
|
1243
|
+
to: payStates[index]?.to,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
const touchesPlatform = payerFee !== undefined ||
|
|
1247
|
+
pieces.some((piece) => piece.releaseTo === "platform");
|
|
1248
|
+
return {
|
|
1249
|
+
design: [
|
|
1250
|
+
`${noun}: instant pass-through; ${pieces.length}-piece partition of ${amountName} (${pieces
|
|
1251
|
+
.map((piece) => formatBps(piece.bps))
|
|
1252
|
+
.join(" + ")}); no custody`,
|
|
1253
|
+
],
|
|
1254
|
+
feeLines: [
|
|
1255
|
+
...(payerFee
|
|
1256
|
+
? [
|
|
1257
|
+
{
|
|
1258
|
+
label: `${titleize(settlement.payer)} service fee`,
|
|
1259
|
+
on: `each ${noun.replaceAll("_", " ")}`,
|
|
1260
|
+
structure: `${formatBps(payerFee.bps)} of the ${amountName}, on top`,
|
|
1261
|
+
},
|
|
1262
|
+
]
|
|
1263
|
+
: []),
|
|
1264
|
+
...(payeeFee
|
|
1265
|
+
? [
|
|
1266
|
+
{
|
|
1267
|
+
label: `${titleize(settlement.payee)} fee`,
|
|
1268
|
+
on: `each ${noun.replaceAll("_", " ")}`,
|
|
1269
|
+
structure: `${formatBps(payeeFee.bps)} of the ${amountName}, deducted from the payout`,
|
|
1270
|
+
},
|
|
1271
|
+
]
|
|
1272
|
+
: []),
|
|
1273
|
+
],
|
|
1274
|
+
moneyEvents: events,
|
|
1275
|
+
noun: {
|
|
1276
|
+
actors: {
|
|
1277
|
+
[settlement.payer]: "payer",
|
|
1278
|
+
[settlement.payee]: "beneficiary",
|
|
1279
|
+
...(touchesPlatform ? { platform: "party" } : {}),
|
|
1280
|
+
},
|
|
1281
|
+
desc: `Instant transfer: the ${settlement.payer.replaceAll("_", " ")} pays ${amountName} straight through to the ${settlement.payee.replaceAll("_", " ")}, no custody`,
|
|
1282
|
+
fields,
|
|
1283
|
+
id: noun,
|
|
1284
|
+
...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
|
|
1285
|
+
summary: `Instant payment from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
1286
|
+
title: titleize(noun),
|
|
1287
|
+
verbs,
|
|
1288
|
+
},
|
|
1289
|
+
rules: [],
|
|
1290
|
+
settlement: {
|
|
1291
|
+
name: noun,
|
|
1292
|
+
pieces,
|
|
1293
|
+
...(payerFee
|
|
1294
|
+
? { serviceFee: { bps: payerFee.bps, field: "serviceFeeAmount" } }
|
|
1295
|
+
: {}),
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
// ---------------------------------------------------------------------------
|
|
1300
|
+
// deposit: a reservation placed, then claimed or returned
|
|
1301
|
+
function lowerDeposit(settlement, claim, giveBack, issues) {
|
|
1302
|
+
const noun = settlement.name;
|
|
1303
|
+
const amountName = settlement.amount.name;
|
|
1304
|
+
if (!verbNameIssues(noun, ["place_deposit", claim.name, giveBack.name], settlement.origin, issues)) {
|
|
1305
|
+
return undefined;
|
|
1306
|
+
}
|
|
1307
|
+
const eventKey = `${noun}_hold_1`;
|
|
1308
|
+
const events = [
|
|
1309
|
+
mintEvent({
|
|
1310
|
+
amount: `The full ${amountName}`,
|
|
1311
|
+
fromActor: settlement.payer,
|
|
1312
|
+
key: eventKey,
|
|
1313
|
+
kind: "hold",
|
|
1314
|
+
toActor: settlement.holder,
|
|
1315
|
+
trigger: `Reserve the ${amountName} in the ${settlement.holder.replaceAll("_", " ")}'s favor`,
|
|
1316
|
+
}),
|
|
1317
|
+
];
|
|
1318
|
+
const verbs = {
|
|
1319
|
+
create: {
|
|
1320
|
+
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
1321
|
+
to: "created",
|
|
1322
|
+
},
|
|
1323
|
+
place_deposit: {
|
|
1324
|
+
from: ["created"],
|
|
1325
|
+
moves: [
|
|
1326
|
+
{
|
|
1327
|
+
key: "reservation",
|
|
1328
|
+
operation: "reserve",
|
|
1329
|
+
amount: amountName,
|
|
1330
|
+
from: settlement.payer,
|
|
1331
|
+
to: settlement.holder,
|
|
1332
|
+
},
|
|
1333
|
+
],
|
|
1334
|
+
moneyEvent: eventKey,
|
|
1335
|
+
summary: `Reserve the ${amountName} against the ${settlement.payer.replaceAll("_", " ")}'s account`,
|
|
1336
|
+
to: "held",
|
|
1337
|
+
},
|
|
1338
|
+
[claim.name]: {
|
|
1339
|
+
from: ["held"],
|
|
1340
|
+
moves: [
|
|
1341
|
+
{
|
|
1342
|
+
key: "post",
|
|
1343
|
+
operation: "post",
|
|
1344
|
+
reservation: "place_deposit_reservation",
|
|
1345
|
+
},
|
|
1346
|
+
],
|
|
1347
|
+
summary: `Claim the deposit for the ${settlement.holder.replaceAll("_", " ")} through ${claim.name}`,
|
|
1348
|
+
to: "claimed",
|
|
1349
|
+
},
|
|
1350
|
+
[giveBack.name]: {
|
|
1351
|
+
from: ["held"],
|
|
1352
|
+
summary: `Return the deposit to the ${settlement.payer.replaceAll("_", " ")} through ${giveBack.name}`,
|
|
1353
|
+
to: "returned",
|
|
1354
|
+
moves: [
|
|
1355
|
+
{
|
|
1356
|
+
key: "void",
|
|
1357
|
+
operation: "void",
|
|
1358
|
+
reason: "Deposit returned in full",
|
|
1359
|
+
reservation: "place_deposit_reservation",
|
|
1360
|
+
},
|
|
1361
|
+
],
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
const portRule = (port, verbLabel) => ({
|
|
1365
|
+
allowedActors: [...port.allowed],
|
|
1366
|
+
detail: `${port.allowed.map(titleize).join(" or ")} decides through the tenant backend`,
|
|
1367
|
+
dueDriven: false,
|
|
1368
|
+
enforcement: "tenant_app",
|
|
1369
|
+
gatesEvent: null,
|
|
1370
|
+
key: frameKey(`${noun}_${port.name}_gate`),
|
|
1371
|
+
kind: "release_condition",
|
|
1372
|
+
label: `${verbLabel} decided through ${port.name}`,
|
|
1373
|
+
tenantTunable: false,
|
|
1374
|
+
});
|
|
1375
|
+
return {
|
|
1376
|
+
design: [
|
|
1377
|
+
`${noun}: ${amountName} held as a reservation on the ${settlement.payer.replaceAll("_", " ")}'s account; claimed whole through ${claim.name} or returned whole through ${giveBack.name}`,
|
|
1378
|
+
],
|
|
1379
|
+
feeLines: [],
|
|
1380
|
+
moneyEvents: events,
|
|
1381
|
+
noun: {
|
|
1382
|
+
actors: {
|
|
1383
|
+
[settlement.payer]: "payer",
|
|
1384
|
+
[settlement.holder]: "beneficiary",
|
|
1385
|
+
},
|
|
1386
|
+
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`,
|
|
1387
|
+
fields: {
|
|
1388
|
+
[amountName]: moneyFieldSpec(`The deposit amount in ${settlement.amount.currency} minor units, reserved in full and fully accounted on claim or return`),
|
|
1389
|
+
},
|
|
1390
|
+
id: noun,
|
|
1391
|
+
summary: `Refundable deposit from ${settlement.payer.replaceAll("_", " ")} held for ${settlement.holder.replaceAll("_", " ")}`,
|
|
1392
|
+
title: titleize(noun),
|
|
1393
|
+
verbs,
|
|
1394
|
+
},
|
|
1395
|
+
rules: [portRule(claim, "Claim"), portRule(giveBack, "Return")],
|
|
1396
|
+
settlement: { name: noun, pieces: [] },
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
// ---------------------------------------------------------------------------
|
|
1400
|
+
// scheduled and advance: finite due-driven anchors
|
|
1401
|
+
/** Equal N-way piece widths in bps; the first anchor absorbs the remainder. */
|
|
1402
|
+
function evenPieceBps(count) {
|
|
1403
|
+
const base = Math.floor(Number(TOTAL_BPS) / count);
|
|
1404
|
+
const widths = Array.from({ length: count }, () => base);
|
|
1405
|
+
widths[0] = Number(TOTAL_BPS) - base * (count - 1);
|
|
1406
|
+
return widths;
|
|
1407
|
+
}
|
|
1408
|
+
function anchorOffset(schedule, index) {
|
|
1409
|
+
return index === 0 ? {} : { offset: `P${schedule.every.days * index}D` };
|
|
1410
|
+
}
|
|
1411
|
+
function lowerScheduled(settlement) {
|
|
1412
|
+
const noun = settlement.name;
|
|
1413
|
+
const amountName = settlement.amount.name;
|
|
1414
|
+
const { schedule } = settlement;
|
|
1415
|
+
const ruleKey = `${noun}_schedule`;
|
|
1416
|
+
const widths = evenPieceBps(schedule.count);
|
|
1417
|
+
const fields = {
|
|
1418
|
+
[amountName]: moneyFieldSpec(`The total scheduled amount in ${settlement.amount.currency} minor units; the installment fields below partition it exactly`),
|
|
1419
|
+
[schedule.firstDueField]: dateFieldSpec(`Due date of the first installment; installment k falls ${schedule.every.raw} after its predecessor`),
|
|
1420
|
+
};
|
|
1421
|
+
const installmentFields = widths.map((_, index) => {
|
|
1422
|
+
const field = `installment${index + 1}Amount`;
|
|
1423
|
+
fields[field] = moneyFieldSpec(`Installment ${index + 1} of ${schedule.count}${index === 0 ? " (carries the integer-division remainder)" : ""}: about ${formatBps(widths[index])} of ${amountName}, collected on its own stored-date anchor`);
|
|
1424
|
+
return field;
|
|
1425
|
+
});
|
|
1426
|
+
const payVerbs = widths.map((_, index) => `pay_installment_${index + 1}`);
|
|
1427
|
+
const payStates = chain(payVerbs, "active", "settled", "collecting");
|
|
1428
|
+
// The whole schedule is ONE money event (occurrence: repeatable): the
|
|
1429
|
+
// budget counts money BEHAVIORS, not anchors, so a longer schedule never
|
|
1430
|
+
// crowds out a composite program's other settlements. The document still
|
|
1431
|
+
// unrolls to one idempotent anchor verb per installment, all implementing
|
|
1432
|
+
// the same event key.
|
|
1433
|
+
const eventKey = `${noun}_installments`;
|
|
1434
|
+
const events = [
|
|
1435
|
+
mintEvent({
|
|
1436
|
+
amount: `The ${amountName}, partitioned into ${schedule.count} installments`,
|
|
1437
|
+
fromActor: settlement.payer,
|
|
1438
|
+
key: eventKey,
|
|
1439
|
+
kind: "installment",
|
|
1440
|
+
occurrence: "repeatable",
|
|
1441
|
+
toActor: settlement.payee,
|
|
1442
|
+
trigger: `Collect each of the ${schedule.count} installments on its stored due date`,
|
|
1443
|
+
}),
|
|
1444
|
+
];
|
|
1445
|
+
const verbs = {
|
|
1446
|
+
create: {
|
|
1447
|
+
summary: `Create a ${titleize(noun).toLowerCase()} plan`,
|
|
1448
|
+
to: "active",
|
|
1449
|
+
},
|
|
1450
|
+
};
|
|
1451
|
+
for (const [index, field] of installmentFields.entries()) {
|
|
1452
|
+
verbs[payVerbs[index]] = {
|
|
1453
|
+
due: {
|
|
1454
|
+
field: schedule.firstDueField,
|
|
1455
|
+
rule: ruleKey,
|
|
1456
|
+
...anchorOffset(schedule, index),
|
|
1457
|
+
},
|
|
1458
|
+
from: [payStates[index]?.from],
|
|
1459
|
+
moneyEvent: eventKey,
|
|
1460
|
+
moves: [
|
|
1461
|
+
{
|
|
1462
|
+
key: "transfer",
|
|
1463
|
+
operation: "create",
|
|
1464
|
+
amount: field,
|
|
1465
|
+
from: settlement.payer,
|
|
1466
|
+
to: settlement.payee,
|
|
1467
|
+
},
|
|
1468
|
+
],
|
|
1469
|
+
summary: `Collect installment ${index + 1} of ${schedule.count}`,
|
|
1470
|
+
to: payStates[index]?.to,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
return {
|
|
1474
|
+
design: [
|
|
1475
|
+
`${noun}: ${schedule.count} installments every ${schedule.every.raw} from ${schedule.firstDueField}; finite by construction, one idempotent anchor per installment`,
|
|
1476
|
+
],
|
|
1477
|
+
feeLines: [],
|
|
1478
|
+
moneyEvents: events,
|
|
1479
|
+
noun: {
|
|
1480
|
+
actors: {
|
|
1481
|
+
[settlement.payer]: "payer",
|
|
1482
|
+
[settlement.payee]: "beneficiary",
|
|
1483
|
+
},
|
|
1484
|
+
desc: `Scheduled payment: the ${settlement.payer.replaceAll("_", " ")} pays ${amountName} to the ${settlement.payee.replaceAll("_", " ")} in ${schedule.count} installments, one every ${schedule.every.raw}`,
|
|
1485
|
+
fields,
|
|
1486
|
+
id: noun,
|
|
1487
|
+
...partitionsSpread(partitionClause(amountName, installmentFields)),
|
|
1488
|
+
summary: `${schedule.count}-installment schedule from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
1489
|
+
title: titleize(noun),
|
|
1490
|
+
verbs,
|
|
1491
|
+
},
|
|
1492
|
+
rules: [
|
|
1493
|
+
{
|
|
1494
|
+
allowedActors: [],
|
|
1495
|
+
detail: `Each of the ${schedule.count} installments is collected once from its stored due date`,
|
|
1496
|
+
dueDriven: true,
|
|
1497
|
+
enforcement: "platform",
|
|
1498
|
+
gatesEvent: null,
|
|
1499
|
+
key: ruleKey,
|
|
1500
|
+
kind: "deadline",
|
|
1501
|
+
label: "Installments collected on their stored due dates",
|
|
1502
|
+
tenantTunable: false,
|
|
1503
|
+
},
|
|
1504
|
+
],
|
|
1505
|
+
settlement: { name: noun, pieces: [] },
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
function lowerAdvance(settlement) {
|
|
1509
|
+
return settlement.source.kind === "carve"
|
|
1510
|
+
? lowerCarvedAdvance(settlement, settlement.source.settlement)
|
|
1511
|
+
: lowerScheduledAdvance(settlement, settlement.source.schedule);
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* `advance { against: <hold>.release }`. The repayment leg is not this noun's
|
|
1515
|
+
* to make: the hold releases the financed party's whole share straight to the
|
|
1516
|
+
* funder, so what stays here is the disbursement, the terms the funder is
|
|
1517
|
+
* owed on, and the close that records the carve landing. An advance carved
|
|
1518
|
+
* this way can never pay out more than the hold already holds.
|
|
1519
|
+
*/
|
|
1520
|
+
function lowerCarvedAdvance(settlement, hold) {
|
|
1521
|
+
const noun = settlement.name;
|
|
1522
|
+
const amountName = settlement.amount.name;
|
|
1523
|
+
const hasFee = settlement.feeBps > 0;
|
|
1524
|
+
const advancedWords = settlement.advanced.replaceAll("_", " ");
|
|
1525
|
+
const funderWords = settlement.funder.replaceAll("_", " ");
|
|
1526
|
+
const holdWords = hold.replaceAll("_", " ");
|
|
1527
|
+
const fields = {
|
|
1528
|
+
[amountName]: moneyFieldSpec(`The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${advancedWords} up front`),
|
|
1529
|
+
...(hasFee
|
|
1530
|
+
? {
|
|
1531
|
+
feeAmount: moneyFieldSpec(`${formatBps(settlement.feeBps)} of ${amountName}, the funder's discount owed on top of the advance`),
|
|
1532
|
+
repayableAmount: moneyFieldSpec(`${amountName} + feeAmount: what the ${holdWords} release owes the ${funderWords}`),
|
|
1533
|
+
}
|
|
1534
|
+
: {}),
|
|
1535
|
+
};
|
|
1536
|
+
return {
|
|
1537
|
+
design: [
|
|
1538
|
+
`${noun}: ${amountName} advanced to the ${settlement.advanced} up front and repaid by carving the ${hold} release${hasFee ? `; repayableAmount = ${amountName} + ${formatBps(settlement.feeBps)} fee` : ""}`,
|
|
1539
|
+
],
|
|
1540
|
+
feeLines: hasFee
|
|
1541
|
+
? [
|
|
1542
|
+
{
|
|
1543
|
+
label: `${titleize(settlement.funder)} discount`,
|
|
1544
|
+
on: `each ${noun.replaceAll("_", " ")}`,
|
|
1545
|
+
structure: `${formatBps(settlement.feeBps)} of the ${amountName}, owed on top out of the ${holdWords} release`,
|
|
1546
|
+
},
|
|
1547
|
+
]
|
|
1548
|
+
: [],
|
|
1549
|
+
moneyEvents: [
|
|
1550
|
+
mintEvent({
|
|
1551
|
+
amount: `The full ${amountName}`,
|
|
1552
|
+
fromActor: settlement.funder,
|
|
1553
|
+
key: `${noun}_disburse`,
|
|
1554
|
+
kind: "payout",
|
|
1555
|
+
toActor: settlement.advanced,
|
|
1556
|
+
trigger: `Disburse the advance to the ${advancedWords}`,
|
|
1557
|
+
}),
|
|
1558
|
+
],
|
|
1559
|
+
noun: {
|
|
1560
|
+
actors: {
|
|
1561
|
+
[settlement.advanced]: "beneficiary",
|
|
1562
|
+
[settlement.funder]: "payer",
|
|
1563
|
+
},
|
|
1564
|
+
desc: `Advance: the ${funderWords} disburses ${amountName} to the ${advancedWords} and is repaid out of the ${holdWords} release, which pays the ${funderWords} in the ${advancedWords}'s place${hasFee ? ", plus the funder's discount" : ""}`,
|
|
1565
|
+
fields,
|
|
1566
|
+
id: noun,
|
|
1567
|
+
...partitionsSpread(hasFee
|
|
1568
|
+
? partitionClause("repayableAmount", [amountName, "feeAmount"])
|
|
1569
|
+
: []),
|
|
1570
|
+
summary: `Advance to the ${advancedWords} repaid by carving the ${holdWords} release`,
|
|
1571
|
+
title: titleize(noun),
|
|
1572
|
+
verbs: {
|
|
1573
|
+
create: {
|
|
1574
|
+
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
1575
|
+
to: "created",
|
|
1576
|
+
},
|
|
1577
|
+
disburse: {
|
|
1578
|
+
from: ["created"],
|
|
1579
|
+
moneyEvent: `${noun}_disburse`,
|
|
1580
|
+
moves: [
|
|
1581
|
+
{
|
|
1582
|
+
key: "transfer",
|
|
1583
|
+
operation: "create",
|
|
1584
|
+
amount: amountName,
|
|
1585
|
+
from: settlement.funder,
|
|
1586
|
+
to: settlement.advanced,
|
|
1587
|
+
},
|
|
1588
|
+
],
|
|
1589
|
+
summary: `Disburse the ${amountName} to the ${advancedWords}`,
|
|
1590
|
+
to: "advanced",
|
|
1591
|
+
},
|
|
1592
|
+
// Moneyless by construction: the repayment already moved, on the hold.
|
|
1593
|
+
// This verb only records that it did, so the advance has a close
|
|
1594
|
+
// instead of resting forever in the state it was disbursed in.
|
|
1595
|
+
settle: {
|
|
1596
|
+
from: ["advanced"],
|
|
1597
|
+
summary: `Close the advance once the ${holdWords} has released to the ${funderWords}`,
|
|
1598
|
+
to: "repaid",
|
|
1599
|
+
},
|
|
1600
|
+
},
|
|
1601
|
+
},
|
|
1602
|
+
rules: [
|
|
1603
|
+
{
|
|
1604
|
+
allowedActors: [],
|
|
1605
|
+
detail: `The ${holdWords} releases the ${advancedWords}'s whole share to the ${funderWords} instead of to the ${advancedWords}; the advance is repaid out of that release and never out of new money`,
|
|
1606
|
+
dueDriven: false,
|
|
1607
|
+
enforcement: "platform",
|
|
1608
|
+
gatesEvent: null,
|
|
1609
|
+
key: `${noun}_carve`,
|
|
1610
|
+
kind: "release_condition",
|
|
1611
|
+
label: `Repaid by carving the ${holdWords} release`,
|
|
1612
|
+
tenantTunable: false,
|
|
1613
|
+
},
|
|
1614
|
+
],
|
|
1615
|
+
settlement: { name: noun, pieces: [] },
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
function lowerScheduledAdvance(settlement, schedule) {
|
|
1619
|
+
const noun = settlement.name;
|
|
1620
|
+
const amountName = settlement.amount.name;
|
|
1621
|
+
const ruleKey = `${noun}_schedule`;
|
|
1622
|
+
const widths = evenPieceBps(schedule.count);
|
|
1623
|
+
const hasFee = settlement.feeBps > 0;
|
|
1624
|
+
const repayableField = hasFee ? "repayableAmount" : amountName;
|
|
1625
|
+
const fields = {
|
|
1626
|
+
[amountName]: moneyFieldSpec(`The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${settlement.advanced.replaceAll("_", " ")} up front`),
|
|
1627
|
+
...(hasFee
|
|
1628
|
+
? {
|
|
1629
|
+
feeAmount: moneyFieldSpec(`${formatBps(settlement.feeBps)} of ${amountName}, the funder's discount repaid on top of the advance`),
|
|
1630
|
+
repayableAmount: moneyFieldSpec(`${amountName} + feeAmount: the total the repayment fields below partition exactly`),
|
|
1631
|
+
}
|
|
1632
|
+
: {}),
|
|
1633
|
+
[schedule.firstDueField]: dateFieldSpec(`Due date of the first repayment; repayment k falls ${schedule.every.raw} after its predecessor`),
|
|
1634
|
+
};
|
|
1635
|
+
const repaymentFields = widths.map((_, index) => {
|
|
1636
|
+
const field = `repayment${index + 1}Amount`;
|
|
1637
|
+
fields[field] = moneyFieldSpec(`Repayment ${index + 1} of ${schedule.count}${index === 0 ? " (carries the integer-division remainder)" : ""}: about ${formatBps(widths[index])} of ${repayableField}, collected on its own stored-date anchor`);
|
|
1638
|
+
return field;
|
|
1639
|
+
});
|
|
1640
|
+
const repayVerbs = widths.map((_, index) => `collect_repayment_${index + 1}`);
|
|
1641
|
+
const repayStates = chain(repayVerbs, "advanced", "repaid", "repaying");
|
|
1642
|
+
const events = [
|
|
1643
|
+
mintEvent({
|
|
1644
|
+
amount: `The full ${amountName}`,
|
|
1645
|
+
fromActor: settlement.funder,
|
|
1646
|
+
key: `${noun}_disburse`,
|
|
1647
|
+
kind: "payout",
|
|
1648
|
+
toActor: settlement.advanced,
|
|
1649
|
+
trigger: `Disburse the advance to the ${settlement.advanced.replaceAll("_", " ")}`,
|
|
1650
|
+
}),
|
|
1651
|
+
];
|
|
1652
|
+
const verbs = {
|
|
1653
|
+
create: {
|
|
1654
|
+
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
1655
|
+
to: "created",
|
|
1656
|
+
},
|
|
1657
|
+
disburse: {
|
|
1658
|
+
from: ["created"],
|
|
1659
|
+
moneyEvent: `${noun}_disburse`,
|
|
1660
|
+
moves: [
|
|
1661
|
+
{
|
|
1662
|
+
key: "transfer",
|
|
1663
|
+
operation: "create",
|
|
1664
|
+
amount: amountName,
|
|
1665
|
+
from: settlement.funder,
|
|
1666
|
+
to: settlement.advanced,
|
|
1667
|
+
},
|
|
1668
|
+
],
|
|
1669
|
+
summary: `Disburse the ${amountName} to the ${settlement.advanced.replaceAll("_", " ")}`,
|
|
1670
|
+
to: "advanced",
|
|
1671
|
+
},
|
|
1672
|
+
};
|
|
1673
|
+
// One repeatable event for the whole repayment schedule (see lowerScheduled:
|
|
1674
|
+
// the budget counts money behaviors, not anchors).
|
|
1675
|
+
const repayEventKey = `${noun}_repayments`;
|
|
1676
|
+
events.push(mintEvent({
|
|
1677
|
+
amount: `The ${repayableField}, partitioned into ${schedule.count} repayments`,
|
|
1678
|
+
fromActor: settlement.advanced,
|
|
1679
|
+
key: repayEventKey,
|
|
1680
|
+
kind: "installment",
|
|
1681
|
+
occurrence: "repeatable",
|
|
1682
|
+
toActor: settlement.funder,
|
|
1683
|
+
trigger: `Collect each of the ${schedule.count} repayments on its stored due date`,
|
|
1684
|
+
}));
|
|
1685
|
+
for (const [index, field] of repaymentFields.entries()) {
|
|
1686
|
+
const eventKey = repayEventKey;
|
|
1687
|
+
verbs[repayVerbs[index]] = {
|
|
1688
|
+
due: {
|
|
1689
|
+
field: schedule.firstDueField,
|
|
1690
|
+
rule: ruleKey,
|
|
1691
|
+
...anchorOffset(schedule, index),
|
|
1692
|
+
},
|
|
1693
|
+
from: [repayStates[index]?.from],
|
|
1694
|
+
moneyEvent: eventKey,
|
|
1695
|
+
moves: [
|
|
1696
|
+
{
|
|
1697
|
+
key: "transfer",
|
|
1698
|
+
operation: "create",
|
|
1699
|
+
amount: field,
|
|
1700
|
+
from: settlement.advanced,
|
|
1701
|
+
to: settlement.funder,
|
|
1702
|
+
},
|
|
1703
|
+
],
|
|
1704
|
+
summary: `Collect repayment ${index + 1} of ${schedule.count}`,
|
|
1705
|
+
to: repayStates[index]?.to,
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
return {
|
|
1709
|
+
design: [
|
|
1710
|
+
`${noun}: ${amountName} advanced up front; ${schedule.count} repayments every ${schedule.every.raw} conserve against ${repayableField}${hasFee ? ` (advance + ${formatBps(settlement.feeBps)} fee)` : ""}`,
|
|
1711
|
+
],
|
|
1712
|
+
feeLines: hasFee
|
|
1713
|
+
? [
|
|
1714
|
+
{
|
|
1715
|
+
label: `${titleize(settlement.funder)} discount`,
|
|
1716
|
+
on: `each ${noun.replaceAll("_", " ")}`,
|
|
1717
|
+
structure: `${formatBps(settlement.feeBps)} of the ${amountName}, repaid on top of the advance`,
|
|
1718
|
+
},
|
|
1719
|
+
]
|
|
1720
|
+
: [],
|
|
1721
|
+
moneyEvents: events,
|
|
1722
|
+
noun: {
|
|
1723
|
+
actors: {
|
|
1724
|
+
[settlement.funder]: "payer",
|
|
1725
|
+
[settlement.advanced]: "beneficiary",
|
|
1726
|
+
},
|
|
1727
|
+
desc: `Advance: the ${settlement.funder.replaceAll("_", " ")} disburses ${amountName} to the ${settlement.advanced.replaceAll("_", " ")}, repaid over ${schedule.count} scheduled repayments${hasFee ? " plus the funder's discount" : ""}`,
|
|
1728
|
+
fields,
|
|
1729
|
+
id: noun,
|
|
1730
|
+
...partitionsSpread([
|
|
1731
|
+
...partitionClause(repayableField, repaymentFields),
|
|
1732
|
+
...(hasFee
|
|
1733
|
+
? partitionClause("repayableAmount", [amountName, "feeAmount"])
|
|
1734
|
+
: []),
|
|
1735
|
+
]),
|
|
1736
|
+
summary: `Advance to ${settlement.advanced.replaceAll("_", " ")} repaid over ${schedule.count} anchors`,
|
|
1737
|
+
title: titleize(noun),
|
|
1738
|
+
verbs,
|
|
1739
|
+
},
|
|
1740
|
+
rules: [
|
|
1741
|
+
{
|
|
1742
|
+
allowedActors: [],
|
|
1743
|
+
detail: `Each of the ${schedule.count} repayments is collected once from its stored due date`,
|
|
1744
|
+
dueDriven: true,
|
|
1745
|
+
enforcement: "platform",
|
|
1746
|
+
gatesEvent: null,
|
|
1747
|
+
key: ruleKey,
|
|
1748
|
+
kind: "deadline",
|
|
1749
|
+
label: "Repayments collected on their stored due dates",
|
|
1750
|
+
tenantTunable: false,
|
|
1751
|
+
},
|
|
1752
|
+
],
|
|
1753
|
+
settlement: { name: noun, pieces: [] },
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
// ---------------------------------------------------------------------------
|
|
1757
|
+
// metered: each usage charge IS the ledger transfer
|
|
1758
|
+
function lowerMetered(settlement) {
|
|
1759
|
+
const noun = settlement.name;
|
|
1760
|
+
const ruleKey = `${noun}_period`;
|
|
1761
|
+
const currency = settlement.rates[0]?.field.currency ?? "SAR";
|
|
1762
|
+
const fields = {
|
|
1763
|
+
[settlement.closeByField]: dateFieldSpec("End of this metering period; the close makes further charges unreachable"),
|
|
1764
|
+
};
|
|
1765
|
+
for (const rate of settlement.rates) {
|
|
1766
|
+
fields[rate.field.name] = moneyFieldSpec(`Per-unit price of ${rate.meter.replaceAll("_", " ")} in ${currency} minor units, committed at period open`);
|
|
1767
|
+
}
|
|
1768
|
+
const events = [];
|
|
1769
|
+
const verbs = {
|
|
1770
|
+
close_period: {
|
|
1771
|
+
due: { field: settlement.closeByField, rule: ruleKey },
|
|
1772
|
+
from: ["open"],
|
|
1773
|
+
summary: "Close the metering period; no further usage can be charged",
|
|
1774
|
+
to: "closed",
|
|
1775
|
+
},
|
|
1776
|
+
create: {
|
|
1777
|
+
summary: `Open a ${titleize(noun).toLowerCase()} period with its committed rate card`,
|
|
1778
|
+
to: "open",
|
|
1779
|
+
},
|
|
1780
|
+
};
|
|
1781
|
+
for (const rate of settlement.rates) {
|
|
1782
|
+
const eventKey = frameKey(`${noun}_${rate.meter}`);
|
|
1783
|
+
events.push(mintEvent({
|
|
1784
|
+
amount: `The committed ${rate.field.name} per unit`,
|
|
1785
|
+
fromActor: settlement.payer,
|
|
1786
|
+
key: eventKey,
|
|
1787
|
+
kind: "charge",
|
|
1788
|
+
occurrence: "repeatable",
|
|
1789
|
+
timing: "external_schedule",
|
|
1790
|
+
toActor: settlement.payee,
|
|
1791
|
+
trigger: `Charge one ${rate.meter.replaceAll("_", " ")} at the committed rate`,
|
|
1792
|
+
}));
|
|
1793
|
+
verbs[`charge_${rate.meter}`] = {
|
|
1794
|
+
from: ["open"],
|
|
1795
|
+
moneyEvent: eventKey,
|
|
1796
|
+
moves: [
|
|
1797
|
+
{
|
|
1798
|
+
key: "transfer",
|
|
1799
|
+
operation: "create",
|
|
1800
|
+
amount: rate.field.name,
|
|
1801
|
+
from: settlement.payer,
|
|
1802
|
+
to: settlement.payee,
|
|
1803
|
+
},
|
|
1804
|
+
],
|
|
1805
|
+
summary: `Charge one metered ${rate.meter.replaceAll("_", " ")}; the emission is the transfer itself`,
|
|
1806
|
+
to: "open",
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
return {
|
|
1810
|
+
design: [
|
|
1811
|
+
`${noun}: committed rate card (${settlement.rates
|
|
1812
|
+
.map((rate) => rate.meter)
|
|
1813
|
+
.join(", ")}); each usage charge IS the ledger transfer; period closes on ${settlement.closeByField}`,
|
|
1814
|
+
],
|
|
1815
|
+
feeLines: [],
|
|
1816
|
+
moneyEvents: events,
|
|
1817
|
+
noun: {
|
|
1818
|
+
actors: {
|
|
1819
|
+
[settlement.payer]: "payer",
|
|
1820
|
+
[settlement.payee]: "beneficiary",
|
|
1821
|
+
},
|
|
1822
|
+
desc: `Metered usage: the ${settlement.payer.replaceAll("_", " ")} is charged per unit at the committed rate card until the period closes on its stored end date`,
|
|
1823
|
+
fields,
|
|
1824
|
+
id: noun,
|
|
1825
|
+
summary: `Metered charges from ${settlement.payer.replaceAll("_", " ")} on a committed rate card`,
|
|
1826
|
+
title: titleize(noun),
|
|
1827
|
+
verbs,
|
|
1828
|
+
},
|
|
1829
|
+
rules: [
|
|
1830
|
+
{
|
|
1831
|
+
allowedActors: [],
|
|
1832
|
+
detail: "The period closes once from its stored end date",
|
|
1833
|
+
dueDriven: true,
|
|
1834
|
+
enforcement: "platform",
|
|
1835
|
+
gatesEvent: null,
|
|
1836
|
+
key: ruleKey,
|
|
1837
|
+
kind: "deadline",
|
|
1838
|
+
label: "Period closed on its stored end date",
|
|
1839
|
+
tenantTunable: false,
|
|
1840
|
+
},
|
|
1841
|
+
],
|
|
1842
|
+
settlement: { name: noun, pieces: [] },
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
// ---------------------------------------------------------------------------
|
|
1846
|
+
// pooled_split: pool a period total piece-wise, distribute it exactly
|
|
1847
|
+
function lowerPooledSplit(settlement) {
|
|
1848
|
+
const noun = settlement.name;
|
|
1849
|
+
const amountName = settlement.amount.name;
|
|
1850
|
+
const ruleKey = `${noun}_payout`;
|
|
1851
|
+
const remainderIndex = settlement.shares.findIndex((share) => share.to === settlement.remainderTo);
|
|
1852
|
+
const pieces = settlement.shares.map((share) => ({
|
|
1853
|
+
bps: share.bps,
|
|
1854
|
+
field: `${camelize(share.to)}ShareAmount`,
|
|
1855
|
+
origin: share.origin,
|
|
1856
|
+
releaseTo: share.to,
|
|
1857
|
+
}));
|
|
1858
|
+
const fields = {
|
|
1859
|
+
[amountName]: moneyFieldSpec(`The pooled period total in ${settlement.amount.currency} minor units; the share fields below partition it exactly`),
|
|
1860
|
+
[settlement.distributeDueField]: dateFieldSpec("The period's payout date; the pool distributes from it"),
|
|
1861
|
+
};
|
|
1862
|
+
for (const [index, piece] of pieces.entries()) {
|
|
1863
|
+
const remainder = index === Math.max(remainderIndex, 0)
|
|
1864
|
+
? " (carries the integer-division remainder)"
|
|
1865
|
+
: "";
|
|
1866
|
+
fields[piece.field] = moneyFieldSpec(`${formatBps(piece.bps)} of ${amountName}${remainder}: the ${piece.releaseTo.replaceAll("_", " ")}'s share. Computed as floor(${amountName} * ${piece.bps} / 10000) in ${settlement.amount.currency} minor units`);
|
|
1867
|
+
}
|
|
1868
|
+
const fundVerbs = pieces.map((_, index) => `fund_share_${index + 1}`);
|
|
1869
|
+
const payoutVerbs = pieces.map((_, index) => `distribute_share_${index + 1}`);
|
|
1870
|
+
const fundStates = chain(fundVerbs, "created", "pooled", "pooling");
|
|
1871
|
+
const payoutStates = chain(payoutVerbs, "pooled", "distributed", "distributing");
|
|
1872
|
+
const events = [];
|
|
1873
|
+
const verbs = {
|
|
1874
|
+
create: {
|
|
1875
|
+
summary: `Open a ${titleize(noun).toLowerCase()} period`,
|
|
1876
|
+
to: "created",
|
|
1877
|
+
},
|
|
1878
|
+
};
|
|
1879
|
+
for (const [index, piece] of pieces.entries()) {
|
|
1880
|
+
const eventKey = `${noun}_pool_${index + 1}`;
|
|
1881
|
+
events.push(mintEvent({
|
|
1882
|
+
amount: `${formatBps(piece.bps)} of the ${amountName}`,
|
|
1883
|
+
fromActor: settlement.payer,
|
|
1884
|
+
key: eventKey,
|
|
1885
|
+
kind: "charge",
|
|
1886
|
+
toActor: "escrow",
|
|
1887
|
+
trigger: `Pool the ${piece.releaseTo.replaceAll("_", " ")}'s share for the period`,
|
|
1888
|
+
}));
|
|
1889
|
+
verbs[fundVerbs[index]] = {
|
|
1890
|
+
from: [fundStates[index]?.from],
|
|
1891
|
+
moneyEvent: eventKey,
|
|
1892
|
+
moves: [
|
|
1893
|
+
{
|
|
1894
|
+
key: "transfer",
|
|
1895
|
+
operation: "create",
|
|
1896
|
+
amount: piece.field,
|
|
1897
|
+
from: settlement.payer,
|
|
1898
|
+
to: "escrow",
|
|
1899
|
+
},
|
|
1900
|
+
],
|
|
1901
|
+
summary: `Pool share ${index + 1} of the period total`,
|
|
1902
|
+
to: fundStates[index]?.to,
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
for (const [index, piece] of pieces.entries()) {
|
|
1906
|
+
const eventKey = `${noun}_payout_${index + 1}`;
|
|
1907
|
+
events.push(mintEvent({
|
|
1908
|
+
amount: `${formatBps(piece.bps)} of the ${amountName}`,
|
|
1909
|
+
fromActor: "escrow",
|
|
1910
|
+
key: eventKey,
|
|
1911
|
+
kind: "payout",
|
|
1912
|
+
toActor: piece.releaseTo,
|
|
1913
|
+
trigger: `Distribute the ${piece.releaseTo.replaceAll("_", " ")}'s share on the payout date`,
|
|
1914
|
+
}));
|
|
1915
|
+
verbs[payoutVerbs[index]] = {
|
|
1916
|
+
due: { field: settlement.distributeDueField, rule: ruleKey },
|
|
1917
|
+
from: [payoutStates[index]?.from],
|
|
1918
|
+
moneyEvent: eventKey,
|
|
1919
|
+
moves: [
|
|
1920
|
+
{
|
|
1921
|
+
key: "transfer",
|
|
1922
|
+
operation: "create",
|
|
1923
|
+
amount: piece.field,
|
|
1924
|
+
from: "escrow",
|
|
1925
|
+
to: piece.releaseTo,
|
|
1926
|
+
},
|
|
1927
|
+
],
|
|
1928
|
+
summary: `Distribute the ${piece.releaseTo.replaceAll("_", " ")}'s share of the pool`,
|
|
1929
|
+
to: payoutStates[index]?.to,
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
return {
|
|
1933
|
+
design: [
|
|
1934
|
+
`${noun}: pool of ${amountName} partitioned ${pieces
|
|
1935
|
+
.map((piece) => `${formatBps(piece.bps)} ${piece.releaseTo}`)
|
|
1936
|
+
.join(" + ")}; distributes in full on ${settlement.distributeDueField}; remainder to ${settlement.remainderTo}`,
|
|
1937
|
+
],
|
|
1938
|
+
feeLines: [],
|
|
1939
|
+
moneyEvents: events,
|
|
1940
|
+
noun: {
|
|
1941
|
+
actors: {
|
|
1942
|
+
[settlement.payer]: "payer",
|
|
1943
|
+
...Object.fromEntries(settlement.shares.map((share) => [share.to, "beneficiary"])),
|
|
1944
|
+
},
|
|
1945
|
+
desc: `Pooled split: the ${settlement.payer.replaceAll("_", " ")} pools the period's ${amountName} share by share; the pool distributes to every recipient in full on the stored payout date`,
|
|
1946
|
+
escrow: true,
|
|
1947
|
+
fields,
|
|
1948
|
+
id: noun,
|
|
1949
|
+
...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
|
|
1950
|
+
summary: `Period pool from ${settlement.payer.replaceAll("_", " ")} split ${settlement.shares.length} ways`,
|
|
1951
|
+
title: titleize(noun),
|
|
1952
|
+
verbs,
|
|
1953
|
+
},
|
|
1954
|
+
rules: [
|
|
1955
|
+
{
|
|
1956
|
+
allowedActors: [],
|
|
1957
|
+
detail: "Every share of the pool distributes once from the stored payout date",
|
|
1958
|
+
dueDriven: true,
|
|
1959
|
+
enforcement: "platform",
|
|
1960
|
+
gatesEvent: null,
|
|
1961
|
+
key: ruleKey,
|
|
1962
|
+
kind: "deadline",
|
|
1963
|
+
label: "Pool distributed on its stored payout date",
|
|
1964
|
+
tenantTunable: false,
|
|
1965
|
+
},
|
|
1966
|
+
],
|
|
1967
|
+
settlement: { name: noun, pieces },
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* The finest common partition of the amount across both exits. Cut points
|
|
1972
|
+
* come from the release allocation (payee share, then the payee-side fee to
|
|
1973
|
+
* the platform) and the cancellation split; every resulting interval becomes
|
|
1974
|
+
* one piece with a fixed destination per exit.
|
|
1975
|
+
*
|
|
1976
|
+
* A carve changes only WHO the payee's share is released to. It is not a cut
|
|
1977
|
+
* point: the funder takes the payee's whole share, so a carved hold has the
|
|
1978
|
+
* same pieces as an uncarved one and the platform's fee is untouched.
|
|
1979
|
+
*/
|
|
1980
|
+
function partitionPieces(input) {
|
|
1981
|
+
const total = Number(TOTAL_BPS);
|
|
1982
|
+
const releaseTo = input.carveTo ?? input.payee;
|
|
1983
|
+
const release = [];
|
|
1984
|
+
if (input.payeeFeeBps < total) {
|
|
1985
|
+
release.push({ end: total - input.payeeFeeBps, to: releaseTo });
|
|
1986
|
+
}
|
|
1987
|
+
if (input.payeeFeeBps > 0)
|
|
1988
|
+
release.push({ end: total, to: "platform" });
|
|
1989
|
+
const cancel = [];
|
|
1990
|
+
let cumulative = 0;
|
|
1991
|
+
for (const share of input.onCancel?.shares ?? []) {
|
|
1992
|
+
cumulative += share.bps;
|
|
1993
|
+
cancel.push({ end: cumulative, origin: share.origin, to: share.to });
|
|
1994
|
+
}
|
|
1995
|
+
const cuts = [
|
|
1996
|
+
...new Set([
|
|
1997
|
+
...release.map((segment) => segment.end),
|
|
1998
|
+
...cancel.map((segment) => segment.end),
|
|
1999
|
+
total,
|
|
2000
|
+
]),
|
|
2001
|
+
].sort((left, right) => left - right);
|
|
2002
|
+
const destinationAt = (segments, start) => segments.find((segment) => start < segment.end);
|
|
2003
|
+
const pieces = [];
|
|
2004
|
+
let start = 0;
|
|
2005
|
+
for (const cut of cuts) {
|
|
2006
|
+
if (cut <= start)
|
|
2007
|
+
continue;
|
|
2008
|
+
const releaseSegment = destinationAt(release, start);
|
|
2009
|
+
const cancelSegment = destinationAt(cancel, start);
|
|
2010
|
+
pieces.push({
|
|
2011
|
+
bps: cut - start,
|
|
2012
|
+
...(cancelSegment ? { cancelTo: cancelSegment.to } : {}),
|
|
2013
|
+
field: `piece${pieces.length + 1}Amount`,
|
|
2014
|
+
origin: cancelSegment?.origin ?? input.amount.origin,
|
|
2015
|
+
releaseTo: releaseSegment?.to ?? releaseTo,
|
|
2016
|
+
});
|
|
2017
|
+
start = cut;
|
|
2018
|
+
}
|
|
2019
|
+
return pieces;
|
|
2020
|
+
}
|
|
2021
|
+
function pieceDescription(piece, index, amountName, currency) {
|
|
2022
|
+
const cancelLeg = piece.cancelTo
|
|
2023
|
+
? `; on cancellation to the ${piece.cancelTo.replaceAll("_", " ")}`
|
|
2024
|
+
: "";
|
|
2025
|
+
const remainder = index === 0 ? " (carries the integer-division remainder)" : "";
|
|
2026
|
+
return `${formatBps(piece.bps)} of ${amountName}${remainder}: released to the ${piece.releaseTo.replaceAll("_", " ")}${cancelLeg}. Computed as floor(${amountName} * ${piece.bps} / 10000) in ${currency} minor units`;
|
|
2027
|
+
}
|
|
2028
|
+
function formatBps(bps) {
|
|
2029
|
+
const percent = bps / 100;
|
|
2030
|
+
return `${Number.isInteger(percent) ? percent : percent.toFixed(2).replace(/0$/, "")}%`;
|
|
2031
|
+
}
|
|
2032
|
+
function summarize(program) {
|
|
2033
|
+
const carveFunderByHold = new Map(program.settlements.flatMap((settlement) => settlement.archetype === "advance" && settlement.source.kind === "carve"
|
|
2034
|
+
? [[settlement.source.settlement, settlement.funder]]
|
|
2035
|
+
: []));
|
|
2036
|
+
const lines = program.settlements.map((settlement) => {
|
|
2037
|
+
switch (settlement.archetype) {
|
|
2038
|
+
case "held_payment": {
|
|
2039
|
+
const cancel = settlement.onCancel
|
|
2040
|
+
? `; cancellation splits the held amount ${settlement.onCancel.shares
|
|
2041
|
+
.map((share) => `${formatBps(share.bps)} to the ${share.to.replaceAll("_", " ")}`)
|
|
2042
|
+
.join(" and ")}`
|
|
2043
|
+
: "";
|
|
2044
|
+
const carveTo = carveFunderByHold.get(settlement.name);
|
|
2045
|
+
const paid = carveTo
|
|
2046
|
+
? `the ${carveTo.replaceAll("_", " ")} is paid on confirmed release, in the ${settlement.payee.replaceAll("_", " ")}'s place`
|
|
2047
|
+
: `the ${settlement.payee.replaceAll("_", " ")} is paid on confirmed release`;
|
|
2048
|
+
return `The ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into escrow and ${paid}${cancel}`;
|
|
2049
|
+
}
|
|
2050
|
+
case "instant_transfer":
|
|
2051
|
+
return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} straight through to the ${settlement.payee.replaceAll("_", " ")}`;
|
|
2052
|
+
case "premium_forward":
|
|
2053
|
+
return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on binding`;
|
|
2054
|
+
case "deposit":
|
|
2055
|
+
return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved for the ${settlement.holder.replaceAll("_", " ")} until claimed or returned`;
|
|
2056
|
+
case "scheduled":
|
|
2057
|
+
return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} to the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} scheduled installments`;
|
|
2058
|
+
case "advance":
|
|
2059
|
+
return settlement.source.kind === "carve"
|
|
2060
|
+
? `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid out of the ${settlement.source.settlement.replaceAll("_", " ")} release`
|
|
2061
|
+
: `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid over ${settlement.source.schedule.count} anchors`;
|
|
2062
|
+
case "metered":
|
|
2063
|
+
return `The ${settlement.payer.replaceAll("_", " ")} is charged per metered unit at a committed rate card until the period closes`;
|
|
2064
|
+
case "pooled_split":
|
|
2065
|
+
return `The ${settlement.payer.replaceAll("_", " ")} pools ${settlement.amount.name} and it distributes ${settlement.shares.length} ways on the payout date`;
|
|
2066
|
+
case "swap":
|
|
2067
|
+
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`;
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
return `${lines.join(". ")}.`.slice(0, 400);
|
|
2071
|
+
}
|
|
2072
|
+
function camelize(snake) {
|
|
2073
|
+
const [head, ...rest] = snake.split("_");
|
|
2074
|
+
return ((head ?? "") +
|
|
2075
|
+
rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(""));
|
|
2076
|
+
}
|
|
2077
|
+
function titleize(snake) {
|
|
2078
|
+
const spaced = snake.replaceAll("_", " ");
|
|
2079
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
2080
|
+
}
|
|
2081
|
+
//# sourceMappingURL=lower.js.map
|