@hyperscale0/hsx 1.0.0-beta.1 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -9
- package/LICENSING.md +3 -3
- package/README.md +37 -149
- package/TRADEMARKS.md +4 -4
- package/dist/src/ast.d.ts +129 -10
- package/dist/src/ast.d.ts.map +1 -1
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +148 -12
- package/dist/src/cli.js.map +1 -1
- package/dist/src/compile.d.ts +41 -4
- package/dist/src/compile.d.ts.map +1 -1
- package/dist/src/compile.js +82 -15
- package/dist/src/compile.js.map +1 -1
- package/dist/src/cost.d.ts +78 -0
- package/dist/src/cost.d.ts.map +1 -0
- package/dist/src/cost.js +369 -0
- package/dist/src/cost.js.map +1 -0
- package/dist/src/diagnostics.d.ts +11 -0
- package/dist/src/diagnostics.d.ts.map +1 -0
- package/dist/src/diagnostics.js +420 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/emit.d.ts +35 -0
- package/dist/src/emit.d.ts.map +1 -0
- package/dist/src/emit.js +359 -0
- package/dist/src/emit.js.map +1 -0
- package/dist/src/entry-overrides.d.ts +17 -3
- package/dist/src/entry-overrides.d.ts.map +1 -1
- package/dist/src/entry-overrides.js +180 -35
- package/dist/src/entry-overrides.js.map +1 -1
- package/dist/src/format.d.ts +10 -0
- package/dist/src/format.d.ts.map +1 -0
- package/dist/src/format.js +183 -0
- package/dist/src/format.js.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/ir.d.ts +66 -0
- package/dist/src/ir.d.ts.map +1 -0
- package/dist/src/ir.js +15 -0
- package/dist/src/ir.js.map +1 -0
- package/dist/src/lex.d.ts +9 -1
- package/dist/src/lex.d.ts.map +1 -1
- package/dist/src/lex.js +50 -4
- package/dist/src/lex.js.map +1 -1
- package/dist/src/limits.d.ts +2 -3
- package/dist/src/limits.d.ts.map +1 -1
- package/dist/src/limits.js +2 -3
- package/dist/src/limits.js.map +1 -1
- package/dist/src/modules.d.ts +36 -0
- package/dist/src/modules.d.ts.map +1 -0
- package/dist/src/modules.js +372 -0
- package/dist/src/modules.js.map +1 -0
- package/dist/src/parse.d.ts.map +1 -1
- package/dist/src/parse.js +590 -17
- package/dist/src/parse.js.map +1 -1
- package/dist/src/typecheck.d.ts +8 -0
- package/dist/src/typecheck.d.ts.map +1 -0
- package/dist/src/typecheck.js +2958 -0
- package/dist/src/typecheck.js.map +1 -0
- package/dist/src/version.d.ts +4 -15
- package/dist/src/version.d.ts.map +1 -1
- package/dist/src/version.js +4 -15
- package/dist/src/version.js.map +1 -1
- package/docs/README.md +23 -0
- package/docs/guide/01-first-program.md +24 -0
- package/docs/guide/02-money.md +25 -0
- package/docs/guide/03-instruments.md +22 -0
- package/docs/guide/04-lifecycles.md +25 -0
- package/docs/guide/05-fees-and-splits.md +45 -0
- package/docs/guide/06-schedules.md +22 -0
- package/docs/guide/07-composition.md +46 -0
- package/docs/guide/08-writing-a-module.md +26 -0
- package/docs/guide/09-cost.md +24 -0
- package/docs/guide/10-diagnostics.md +27 -0
- package/docs/llms-full.txt +1738 -0
- package/docs/llms.txt +45 -0
- package/docs/reference/cli.md +33 -0
- package/docs/reference/diagnostics.md +619 -0
- package/docs/reference/grammar.md +39 -0
- package/docs/reference/std/advance.md +35 -0
- package/docs/reference/std/cancellable_booking.md +35 -0
- package/docs/reference/std/captured_payment.md +43 -0
- package/docs/reference/std/conditional_disbursement.md +33 -0
- package/docs/reference/std/credit_facility.md +35 -0
- package/docs/reference/std/held_payment.md +64 -0
- package/docs/reference/std/instant_transfer.md +30 -0
- package/docs/reference/std/metered.md +27 -0
- package/docs/reference/std/pooled_split.md +26 -0
- package/docs/reference/std/premium_forward.md +39 -0
- package/docs/reference/std/reconciled_payout.md +33 -0
- package/docs/reference/std/recurring_collection.md +25 -0
- package/docs/reference/std/rotating_pool.md +42 -0
- package/docs/reference/std/scheduled.md +51 -0
- package/docs/reference/std/security_deposit.md +46 -0
- package/docs/reference/std/settlement_batch.md +42 -0
- package/docs/reference/std/swap.md +46 -0
- package/docs/reference/std/threshold_pool.md +47 -0
- package/docs/reference/std/weighted_distribution.md +39 -0
- package/docs/reference/types.md +20 -0
- package/docs/reference/udl-output.md +14 -0
- package/docs/sessions/2026-09-02-two-instruments.hsx +21 -0
- package/docs/sessions/2026-09-02-two-instruments.md +41 -0
- package/examples/01-first-program/README.md +45 -0
- package/examples/01-first-program/tip-jar.hsx +17 -0
- package/examples/02-imports-and-archetypes/README.md +99 -0
- package/examples/02-imports-and-archetypes/photo-booth.hsx +37 -0
- package/examples/03-diagnostics/README.md +68 -0
- package/examples/03-diagnostics/corner-shop-fixed.hsx +22 -0
- package/examples/03-diagnostics/corner-shop.hsx +19 -0
- package/examples/04-complete-product/README.md +87 -0
- package/examples/04-complete-product/study-hall.hsx +67 -0
- package/examples/05-watch-club/README.md +9 -0
- package/examples/05-watch-club/watch-club.hsx +124 -0
- package/examples/README.md +18 -0
- package/examples/advance/README.md +3 -0
- package/examples/advance/advance.hsx +13 -0
- package/examples/advance/advance.udl +339 -0
- package/examples/cancellable_booking/README.md +3 -0
- package/examples/cancellable_booking/cancellable_booking.hsx +14 -0
- package/examples/cancellable_booking/cancellable_booking.udl +391 -0
- package/examples/captured_payment/README.md +3 -0
- package/examples/captured_payment/captured_payment.hsx +21 -0
- package/examples/captured_payment/captured_payment.udl +677 -0
- package/examples/conditional_disbursement/README.md +3 -0
- package/examples/conditional_disbursement/conditional_disbursement.hsx +17 -0
- package/examples/conditional_disbursement/conditional_disbursement.udl +412 -0
- package/examples/cost-table.json +386 -0
- package/examples/credit_facility/README.md +3 -0
- package/examples/credit_facility/credit_facility.hsx +27 -0
- package/examples/credit_facility/credit_facility.udl +1373 -0
- package/examples/held_payment/README.md +3 -0
- package/examples/held_payment/held_payment.hsx +13 -0
- package/examples/held_payment/held_payment.udl +507 -0
- package/examples/instant_transfer/README.md +3 -0
- package/examples/instant_transfer/instant_transfer.hsx +16 -0
- package/examples/instant_transfer/instant_transfer.udl +364 -0
- package/examples/metered/README.md +3 -0
- package/examples/metered/metered.hsx +13 -0
- package/examples/metered/metered.udl +247 -0
- package/examples/pooled_split/README.md +3 -0
- package/examples/pooled_split/pooled_split.hsx +15 -0
- package/examples/pooled_split/pooled_split.udl +425 -0
- package/examples/premium_forward/README.md +3 -0
- package/examples/premium_forward/premium_forward.hsx +19 -0
- package/examples/premium_forward/premium_forward.udl +594 -0
- package/examples/reconciled_payout/README.md +3 -0
- package/examples/reconciled_payout/reconciled_payout.hsx +13 -0
- package/examples/reconciled_payout/reconciled_payout.udl +303 -0
- package/examples/recurring_collection/README.md +3 -0
- package/examples/recurring_collection/recurring_collection.hsx +21 -0
- package/examples/recurring_collection/recurring_collection.udl +1130 -0
- package/examples/rotating_pool/README.md +3 -0
- package/examples/rotating_pool/rotating_pool.hsx +18 -0
- package/examples/rotating_pool/rotating_pool.udl +4352 -0
- package/examples/scheduled/README.md +3 -0
- package/examples/scheduled/scheduled.hsx +12 -0
- package/examples/scheduled/scheduled.udl +335 -0
- package/examples/security_deposit/README.md +3 -0
- package/examples/security_deposit/security_deposit.hsx +21 -0
- package/examples/security_deposit/security_deposit.udl +293 -0
- package/examples/settlement_batch/README.md +3 -0
- package/examples/settlement_batch/settlement_batch.hsx +19 -0
- package/examples/settlement_batch/settlement_batch.udl +753 -0
- package/examples/swap/README.md +3 -0
- package/examples/swap/swap.hsx +19 -0
- package/examples/swap/swap.udl +876 -0
- package/examples/threshold_pool/README.md +3 -0
- package/examples/threshold_pool/threshold_pool.hsx +16 -0
- package/examples/threshold_pool/threshold_pool.udl +667 -0
- package/examples/weighted_distribution/README.md +3 -0
- package/examples/weighted_distribution/weighted_distribution.hsx +20 -0
- package/examples/weighted_distribution/weighted_distribution.udl +337 -0
- package/package.json +19 -6
- package/skills/hsx/SKILL.md +483 -0
- package/src/ast.ts +163 -9
- package/src/cli.ts +164 -12
- package/src/compile.ts +160 -20
- package/src/cost.ts +579 -0
- package/src/diagnostics.ts +442 -0
- package/src/emit.ts +447 -0
- package/src/entry-overrides.ts +263 -48
- package/src/format.ts +261 -0
- package/src/index.ts +32 -4
- package/src/ir.ts +99 -0
- package/src/lex.ts +59 -4
- package/src/limits.ts +2 -3
- package/src/modules.ts +444 -0
- package/src/parse.ts +655 -21
- package/src/typecheck.ts +3876 -0
- package/src/version.ts +4 -16
- package/std/SEMANTICS.md +130 -0
- package/std/settlements/advance.hsx +177 -0
- package/std/settlements/cancellable_booking.hsx +87 -0
- package/std/settlements/captured_payment.hsx +144 -0
- package/std/settlements/conditional_disbursement.hsx +185 -0
- package/std/settlements/credit_facility.hsx +167 -0
- package/std/settlements/held_payment.hsx +578 -0
- package/std/settlements/index.hsx +3 -0
- package/std/settlements/instant_transfer.hsx +190 -0
- package/std/settlements/metered.hsx +67 -0
- package/std/settlements/pooled_split.hsx +68 -0
- package/std/settlements/premium_forward.hsx +257 -0
- package/std/settlements/reconciled_payout.hsx +77 -0
- package/std/settlements/recurring_collection.hsx +27 -0
- package/std/settlements/rotating_pool.hsx +252 -0
- package/std/settlements/scheduled.hsx +641 -0
- package/std/settlements/security_deposit.hsx +166 -0
- package/std/settlements/settlement_batch.hsx +122 -0
- package/std/settlements/swap.hsx +1114 -0
- package/std/settlements/threshold_pool.hsx +221 -0
- package/std/settlements/weighted_distribution.hsx +141 -0
- package/dist/src/archetypes.d.ts +0 -17
- package/dist/src/archetypes.d.ts.map +0 -1
- package/dist/src/archetypes.js +0 -327
- package/dist/src/archetypes.js.map +0 -1
- package/dist/src/check.d.ts +0 -12
- package/dist/src/check.d.ts.map +0 -1
- package/dist/src/check.js +0 -1910
- package/dist/src/check.js.map +0 -1
- package/dist/src/lower.d.ts +0 -146
- package/dist/src/lower.d.ts.map +0 -1
- package/dist/src/lower.js +0 -4570
- package/dist/src/lower.js.map +0 -1
- package/dist/src/model.d.ts +0 -467
- package/dist/src/model.d.ts.map +0 -1
- package/dist/src/model.js +0 -15
- package/dist/src/model.js.map +0 -1
- package/spec/hsx-ir.schema.json +0 -920
- package/src/archetypes.ts +0 -340
- package/src/check.ts +0 -2938
- package/src/lower.ts +0 -5416
- package/src/model.ts +0 -519
package/dist/src/lower.js
DELETED
|
@@ -1,4570 +0,0 @@
|
|
|
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 { ARCHETYPE_DEFINITIONS } from "./archetypes.js";
|
|
33
|
-
import { HSX_IR_VERSION } from "./version.js";
|
|
34
|
-
/**
|
|
35
|
-
* The most money events one program may mint. The Business Frame contract caps
|
|
36
|
-
* its moneyEvents array at the same number, and a runtime spec pins the two
|
|
37
|
-
* against each other, so neither can drift alone. A repeatable schedule costs
|
|
38
|
-
* one event declaration. Fee legs, cancellation legs, refunds, and forwards
|
|
39
|
-
* each count when they emit their own event declaration.
|
|
40
|
-
*/
|
|
41
|
-
export const MONEY_EVENT_BUDGET = 20;
|
|
42
|
-
const PUBLIC_INTENT_BUDGET = 48;
|
|
43
|
-
const TOTAL_BPS = 10000n;
|
|
44
|
-
/**
|
|
45
|
-
* Split a minor-unit amount across pieces by exact basis points. Floors every
|
|
46
|
-
* piece and gives the division remainder to the piece at `remainderIndex`
|
|
47
|
-
* (the first by default), so the piece amounts always sum exactly.
|
|
48
|
-
*/
|
|
49
|
-
export function pieceAmounts(pieces, amountMinor, remainderIndex = 0) {
|
|
50
|
-
if (amountMinor < 0n)
|
|
51
|
-
throw new Error("amount must be non-negative");
|
|
52
|
-
const floors = pieces.map((piece) => (amountMinor * BigInt(piece.bps)) / TOTAL_BPS);
|
|
53
|
-
const distributed = floors.reduce((sum, value) => sum + value, 0n);
|
|
54
|
-
if (floors.length > 0) {
|
|
55
|
-
const target = remainderIndex < floors.length ? remainderIndex : 0;
|
|
56
|
-
floors[target] = floors[target] + (amountMinor - distributed);
|
|
57
|
-
}
|
|
58
|
-
return floors;
|
|
59
|
-
}
|
|
60
|
-
/** Lower one arithmetic dependency into the Business Frame's existing keys. */
|
|
61
|
-
export function lowerAmountDependency(expression) {
|
|
62
|
-
if (!expression)
|
|
63
|
-
return { amountDependencies: [], amountMode: "fixed" };
|
|
64
|
-
switch (expression.kind) {
|
|
65
|
-
case "bounded_by_reference":
|
|
66
|
-
return {
|
|
67
|
-
amountDependencies: [frameKey(expression.reference)],
|
|
68
|
-
amountMode: "runtime_bounded",
|
|
69
|
-
};
|
|
70
|
-
case "net_of_offsets": {
|
|
71
|
-
if (expression.offsets.length === 0) {
|
|
72
|
-
throw new Error("net_of_offsets requires at least one offset event");
|
|
73
|
-
}
|
|
74
|
-
const dependencies = canonicalDependencies(expression.source, expression.offsets);
|
|
75
|
-
return {
|
|
76
|
-
amountDependencies: dependencies,
|
|
77
|
-
amountMode: "runtime_bounded",
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
case "percent_of_reference":
|
|
81
|
-
if (!Number.isInteger(expression.bps) ||
|
|
82
|
-
expression.bps <= 0 ||
|
|
83
|
-
expression.bps > 10_000) {
|
|
84
|
-
throw new Error("percent_of_reference bps must be an integer between 1 and 10000");
|
|
85
|
-
}
|
|
86
|
-
return {
|
|
87
|
-
amountDependencies: [frameKey(expression.reference)],
|
|
88
|
-
amountMode: "runtime_bounded",
|
|
89
|
-
};
|
|
90
|
-
case "remainder": {
|
|
91
|
-
if (expression.consumed.length === 0) {
|
|
92
|
-
throw new Error("remainder requires at least one consumed event");
|
|
93
|
-
}
|
|
94
|
-
const dependencies = canonicalDependencies(expression.source, expression.consumed);
|
|
95
|
-
return {
|
|
96
|
-
amountDependencies: dependencies,
|
|
97
|
-
amountMode: "remaining_balance",
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
function canonicalDependencies(source, dependents) {
|
|
103
|
-
const all = [source, ...dependents].map(frameKey);
|
|
104
|
-
if (new Set(all).size !== all.length) {
|
|
105
|
-
throw new Error("amount dependency event keys must be distinct");
|
|
106
|
-
}
|
|
107
|
-
return all;
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Frame keys carry a 40-char snake_case budget, set by the Business Frame
|
|
111
|
-
* contract's key text. Composed keys include model-authored names (ports,
|
|
112
|
-
* parties, meters) with no length bound of their own, so an overlong
|
|
113
|
-
* composition clamps to a 33-char prefix plus a stable 6-char hash of the
|
|
114
|
-
* full name. Deterministic and idempotent: equal compositions stay equal, so
|
|
115
|
-
* a rule's gatesEvent keeps matching its money event's key.
|
|
116
|
-
*/
|
|
117
|
-
/**
|
|
118
|
-
* Frame prose fields (headline, summary, design lines, event labels and
|
|
119
|
-
* triggers, rule details, amounts) share a 160-char schema budget, and
|
|
120
|
-
* their compositions embed model-authored names with no length bound of
|
|
121
|
-
* their own. One walker over the assembled frame clamps every prose string
|
|
122
|
-
* so the compiler can never emit a frame the platform schema rejects as an
|
|
123
|
-
* internal fault. Keys are snake_case identities, not prose: they clamp
|
|
124
|
-
* separately via frameKey and are never touched here.
|
|
125
|
-
*/
|
|
126
|
-
const PROSE_BUDGET = 160;
|
|
127
|
-
const FRAME_PROSE_FIELDS = new Set([
|
|
128
|
-
"amount",
|
|
129
|
-
"design",
|
|
130
|
-
"detail",
|
|
131
|
-
"headline",
|
|
132
|
-
"label",
|
|
133
|
-
"summary",
|
|
134
|
-
"title",
|
|
135
|
-
"trigger",
|
|
136
|
-
"why",
|
|
137
|
-
]);
|
|
138
|
-
function clampProseValue(value, field) {
|
|
139
|
-
if (typeof value === "string") {
|
|
140
|
-
return field !== undefined &&
|
|
141
|
-
FRAME_PROSE_FIELDS.has(field) &&
|
|
142
|
-
value.length > PROSE_BUDGET
|
|
143
|
-
? `${value.slice(0, PROSE_BUDGET - 3)}...`
|
|
144
|
-
: value;
|
|
145
|
-
}
|
|
146
|
-
if (Array.isArray(value)) {
|
|
147
|
-
return value.map((item) => clampProseValue(item, field));
|
|
148
|
-
}
|
|
149
|
-
if (value !== null && typeof value === "object") {
|
|
150
|
-
return Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
151
|
-
key,
|
|
152
|
-
clampProseValue(child, key),
|
|
153
|
-
]));
|
|
154
|
-
}
|
|
155
|
-
return value;
|
|
156
|
-
}
|
|
157
|
-
function clampFrameProse(frame) {
|
|
158
|
-
return clampProseValue(frame);
|
|
159
|
-
}
|
|
160
|
-
function frameKey(key) {
|
|
161
|
-
if (key.length <= 40)
|
|
162
|
-
return key;
|
|
163
|
-
let hash = 2166136261;
|
|
164
|
-
for (let index = 0; index < key.length; index += 1) {
|
|
165
|
-
hash = Math.imul(hash ^ key.charCodeAt(index), 16777619);
|
|
166
|
-
}
|
|
167
|
-
return `${key.slice(0, 33)}_${(hash >>> 0).toString(36).slice(0, 6)}`;
|
|
168
|
-
}
|
|
169
|
-
function dependentAmountDescription(spec) {
|
|
170
|
-
const expression = spec.amountDependency;
|
|
171
|
-
if (!expression)
|
|
172
|
-
return spec.amount;
|
|
173
|
-
switch (expression.kind) {
|
|
174
|
-
case "bounded_by_reference":
|
|
175
|
-
return `Bounded by ${expression.reference}: ${spec.amount}`;
|
|
176
|
-
case "net_of_offsets":
|
|
177
|
-
return `Net of ${expression.source} after ${expression.offsets.join(", ")}: ${spec.amount}`;
|
|
178
|
-
case "percent_of_reference":
|
|
179
|
-
return `${formatBps(expression.bps)} of ${expression.reference}: ${spec.amount}`;
|
|
180
|
-
case "remainder":
|
|
181
|
-
return `Remainder of ${expression.source} after ${expression.consumed.join(", ")}: ${spec.amount}`;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
export function mintEvent(spec) {
|
|
185
|
-
return {
|
|
186
|
-
allocationTotalBps: 0,
|
|
187
|
-
amount: dependentAmountDescription(spec),
|
|
188
|
-
...lowerAmountDependency(spec.amountDependency),
|
|
189
|
-
amountSchedule: [],
|
|
190
|
-
distribution: "single",
|
|
191
|
-
fromActor: spec.fromActor,
|
|
192
|
-
key: frameKey(spec.key),
|
|
193
|
-
kind: spec.kind,
|
|
194
|
-
label: spec.trigger,
|
|
195
|
-
occurrence: spec.occurrence ?? "once",
|
|
196
|
-
timing: spec.timing ?? "on_lifecycle",
|
|
197
|
-
toActor: spec.toActor,
|
|
198
|
-
trigger: spec.trigger,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
/** One lifecycle edge per verb, threading `from -> stem_1 -> ... -> to`. */
|
|
202
|
-
function chain(names, from, to, stateStem) {
|
|
203
|
-
return names.map((_, index) => ({
|
|
204
|
-
from: index === 0 ? from : `${stateStem}_${index}`,
|
|
205
|
-
to: index === names.length - 1 ? to : `${stateStem}_${index + 1}`,
|
|
206
|
-
}));
|
|
207
|
-
}
|
|
208
|
-
/** Refuse verb-name collisions before they silently overwrite each other. */
|
|
209
|
-
function verbNameIssues(settlementName, names, origin, issues) {
|
|
210
|
-
const seen = new Set();
|
|
211
|
-
for (const name of names) {
|
|
212
|
-
if (name === "create" || seen.has(name)) {
|
|
213
|
-
issues.push({
|
|
214
|
-
message: `settlement ${settlementName} generates two verbs named "${name}"; rename the colliding port`,
|
|
215
|
-
span: origin,
|
|
216
|
-
});
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
seen.add(name);
|
|
220
|
-
}
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* The partition clauses declared on the noun: create admission proves each
|
|
225
|
-
* sum exactly. Spread into the noun literal; empty when nothing partitions.
|
|
226
|
-
*/
|
|
227
|
-
function partitionClause(total, pieces) {
|
|
228
|
-
return pieces.length >= 2 ? [{ pieces: [...pieces], total }] : [];
|
|
229
|
-
}
|
|
230
|
-
function partitionsSpread(clauses) {
|
|
231
|
-
return clauses.length > 0 ? { partitions: [...clauses] } : {};
|
|
232
|
-
}
|
|
233
|
-
function moneyFieldSpec(desc) {
|
|
234
|
-
return { desc, type: "money" };
|
|
235
|
-
}
|
|
236
|
-
function dateFieldSpec(desc) {
|
|
237
|
-
return { desc, type: "date" };
|
|
238
|
-
}
|
|
239
|
-
function optionalDateFieldSpec(desc) {
|
|
240
|
-
return { desc, type: "date?" };
|
|
241
|
-
}
|
|
242
|
-
function derivedNounPrefix(noun) {
|
|
243
|
-
if (typeof noun.prefix === "string")
|
|
244
|
-
return noun.prefix;
|
|
245
|
-
const id = noun.id;
|
|
246
|
-
const words = id.split("_");
|
|
247
|
-
const derived = words.length > 1 ? words.map((word) => word[0]).join("") : id.slice(0, 4);
|
|
248
|
-
return derived.slice(0, 8).padEnd(2, "x");
|
|
249
|
-
}
|
|
250
|
-
function allocatedGeneratedPrefixes(nouns, generatedIds) {
|
|
251
|
-
const used = new Set(nouns
|
|
252
|
-
.filter((noun) => !generatedIds.has(noun.id))
|
|
253
|
-
.map(derivedNounPrefix));
|
|
254
|
-
let ordinal = 0;
|
|
255
|
-
const nextPrefix = () => {
|
|
256
|
-
while (true) {
|
|
257
|
-
const high = String.fromCharCode(97 + Math.floor(ordinal / 26));
|
|
258
|
-
const low = String.fromCharCode(97 + (ordinal % 26));
|
|
259
|
-
ordinal += 1;
|
|
260
|
-
const candidate = `zz${high}${low}`;
|
|
261
|
-
if (!used.has(candidate)) {
|
|
262
|
-
used.add(candidate);
|
|
263
|
-
return candidate;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
};
|
|
267
|
-
return nouns.map((noun) => generatedIds.has(noun.id)
|
|
268
|
-
? { ...noun, prefix: nextPrefix() }
|
|
269
|
-
: noun);
|
|
270
|
-
}
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
// The whole-program lowering
|
|
273
|
-
export function lowerProgram(program) {
|
|
274
|
-
const issues = [];
|
|
275
|
-
const settlements = [];
|
|
276
|
-
const nouns = [];
|
|
277
|
-
const moneyEvents = [];
|
|
278
|
-
const rules = [];
|
|
279
|
-
const design = [];
|
|
280
|
-
const feeLines = [];
|
|
281
|
-
const repeatableCounterparties = [];
|
|
282
|
-
const generatedPrefixNounIds = new Set();
|
|
283
|
-
const mintedKeys = new Map();
|
|
284
|
-
const portsByName = new Map(program.ports.map((port) => [port.name, port]));
|
|
285
|
-
const portFor = (settlement, portName, origin) => {
|
|
286
|
-
const port = portsByName.get(portName);
|
|
287
|
-
if (!port) {
|
|
288
|
-
issues.push({
|
|
289
|
-
message: `settlement ${settlement.name} decides through an unknown port; the checker should have refused this program`,
|
|
290
|
-
span: origin,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
return port;
|
|
294
|
-
};
|
|
295
|
-
// `advance { against: <hold>.release }` carves the hold's release, so the
|
|
296
|
-
// hold must know the funder's name before it lowers. The checker already
|
|
297
|
-
// proved each target is a held payment releasing to the financed party, and
|
|
298
|
-
// that no two advances draw against the same one.
|
|
299
|
-
const carveFunderByHold = new Map(program.settlements.flatMap((settlement) => settlement.archetype === "advance" && settlement.source.kind === "carve"
|
|
300
|
-
? [[settlement.source.settlement, settlement.funder]]
|
|
301
|
-
: []));
|
|
302
|
-
const recoursesByAdvance = new Map(program.settlements.flatMap((settlement) => {
|
|
303
|
-
if (settlement.archetype !== "advance" ||
|
|
304
|
-
settlement.source.kind !== "carve") {
|
|
305
|
-
return [];
|
|
306
|
-
}
|
|
307
|
-
const recourses = program.settlements.filter((candidate) => candidate.archetype === "scheduled" &&
|
|
308
|
-
candidate.mode === "transfer" &&
|
|
309
|
-
candidate.payer === settlement.advanced &&
|
|
310
|
-
candidate.payee === settlement.funder &&
|
|
311
|
-
candidate.amount.name === settlement.amount.name &&
|
|
312
|
-
candidate.amount.currency === settlement.amount.currency);
|
|
313
|
-
return [[settlement.name, recourses]];
|
|
314
|
-
}));
|
|
315
|
-
const collectionByObligation = new Map(program.settlements.flatMap((settlement) => settlement.archetype === "recurring_collection"
|
|
316
|
-
? [[settlement.obligation.settlement, settlement]]
|
|
317
|
-
: []));
|
|
318
|
-
for (const settlement of program.settlements) {
|
|
319
|
-
let lowered;
|
|
320
|
-
switch (settlement.archetype) {
|
|
321
|
-
case "held_payment": {
|
|
322
|
-
const port = portFor(settlement, settlement.release.port, settlement.release.origin);
|
|
323
|
-
if (!port)
|
|
324
|
-
continue;
|
|
325
|
-
lowered = lowerHeldPayment(settlement, port, carveFunderByHold.get(settlement.name), issues);
|
|
326
|
-
break;
|
|
327
|
-
}
|
|
328
|
-
case "captured_payment": {
|
|
329
|
-
const correction = portFor(settlement, settlement.correction.port, settlement.correction.origin);
|
|
330
|
-
const externalReversal = portFor(settlement, settlement.externalReversal.port, settlement.externalReversal.origin);
|
|
331
|
-
if (!correction || !externalReversal)
|
|
332
|
-
continue;
|
|
333
|
-
lowered = lowerCaptureReservation(settlement, correction, externalReversal, issues);
|
|
334
|
-
break;
|
|
335
|
-
}
|
|
336
|
-
case "settlement_batch": {
|
|
337
|
-
const acknowledgement = portFor(settlement, settlement.payoutAcknowledgement.port, settlement.payoutAcknowledgement.origin);
|
|
338
|
-
if (!acknowledgement)
|
|
339
|
-
continue;
|
|
340
|
-
lowered = lowerSettlementBatch(settlement, acknowledgement, issues);
|
|
341
|
-
break;
|
|
342
|
-
}
|
|
343
|
-
case "funding_round":
|
|
344
|
-
lowered = lowerFundingRound(settlement);
|
|
345
|
-
break;
|
|
346
|
-
case "weighted_distribution": {
|
|
347
|
-
const snapshot = portFor(settlement, settlement.snapshot.port, settlement.snapshot.origin);
|
|
348
|
-
if (!snapshot)
|
|
349
|
-
continue;
|
|
350
|
-
lowered = lowerWeightedDistribution(settlement, snapshot);
|
|
351
|
-
break;
|
|
352
|
-
}
|
|
353
|
-
case "credit_facility":
|
|
354
|
-
lowered = lowerCreditFacility(settlement);
|
|
355
|
-
break;
|
|
356
|
-
case "recurring_collection":
|
|
357
|
-
// The referenced scheduled obligation owns the payment nouns, amount
|
|
358
|
-
// allocation, and delinquency. Its lowerer adds the explicit mandate
|
|
359
|
-
// evidence gate, so this declaration mints no second noun or event.
|
|
360
|
-
continue;
|
|
361
|
-
case "conditional_disbursement": {
|
|
362
|
-
const decision = portFor(settlement, settlement.decision.port, settlement.decision.origin);
|
|
363
|
-
if (!decision)
|
|
364
|
-
continue;
|
|
365
|
-
lowered = lowerConditionalDisbursement(settlement, decision);
|
|
366
|
-
break;
|
|
367
|
-
}
|
|
368
|
-
case "rotating_pool":
|
|
369
|
-
lowered = lowerRotatingPool(settlement);
|
|
370
|
-
break;
|
|
371
|
-
case "premium_forward": {
|
|
372
|
-
const port = portFor(settlement, settlement.bind.port, settlement.bind.origin);
|
|
373
|
-
const endorsement = settlement.endorsement
|
|
374
|
-
? portFor(settlement, settlement.endorsement.port, settlement.endorsement.origin)
|
|
375
|
-
: undefined;
|
|
376
|
-
if (!port || (settlement.endorsement && !endorsement))
|
|
377
|
-
continue;
|
|
378
|
-
lowered = lowerPremiumForward(settlement, port, endorsement, issues);
|
|
379
|
-
break;
|
|
380
|
-
}
|
|
381
|
-
case "deposit": {
|
|
382
|
-
const claim = portFor(settlement, settlement.claim.port, settlement.claim.origin);
|
|
383
|
-
const giveBack = portFor(settlement, settlement.return.port, settlement.return.origin);
|
|
384
|
-
if (!claim || !giveBack)
|
|
385
|
-
continue;
|
|
386
|
-
lowered = lowerDeposit(settlement, claim, giveBack, issues);
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
389
|
-
case "instant_transfer":
|
|
390
|
-
lowered = lowerInstantTransfer(settlement);
|
|
391
|
-
break;
|
|
392
|
-
case "scheduled":
|
|
393
|
-
lowered =
|
|
394
|
-
settlement.mode === "obligation"
|
|
395
|
-
? lowerScheduledObligation(settlement, collectionByObligation.get(settlement.name), collectionByObligation.has(settlement.name)
|
|
396
|
-
? portFor(collectionByObligation.get(settlement.name), collectionByObligation.get(settlement.name).mandate.port, collectionByObligation.get(settlement.name).mandate
|
|
397
|
-
.origin)
|
|
398
|
-
: undefined)
|
|
399
|
-
: lowerScheduled(settlement);
|
|
400
|
-
break;
|
|
401
|
-
case "advance":
|
|
402
|
-
lowered = lowerAdvance(settlement, recoursesByAdvance.get(settlement.name) ?? []);
|
|
403
|
-
break;
|
|
404
|
-
case "metered":
|
|
405
|
-
lowered = lowerMetered(settlement);
|
|
406
|
-
break;
|
|
407
|
-
case "pooled_split":
|
|
408
|
-
lowered = lowerPooledSplit(settlement);
|
|
409
|
-
break;
|
|
410
|
-
case "swap": {
|
|
411
|
-
const release = portFor(settlement, settlement.release.port, settlement.release.origin);
|
|
412
|
-
const dispute = settlement.dispute
|
|
413
|
-
? portFor(settlement, settlement.dispute.port, settlement.dispute.origin)
|
|
414
|
-
: undefined;
|
|
415
|
-
if (!release || (settlement.dispute && !dispute))
|
|
416
|
-
continue;
|
|
417
|
-
lowered = lowerSwap(settlement, release, dispute);
|
|
418
|
-
break;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
if (!lowered)
|
|
422
|
-
continue;
|
|
423
|
-
const derivedAmounts = (program.derivedAmounts ?? []).filter((amount) => amount.settlement === settlement.name);
|
|
424
|
-
if (derivedAmounts.length > 0) {
|
|
425
|
-
lowered = addDerivedAmounts(lowered, settlement, derivedAmounts, issues);
|
|
426
|
-
if (!lowered)
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
const localCap = ARCHETYPE_DEFINITIONS[settlement.archetype].eventCap +
|
|
430
|
-
derivedAmounts.length;
|
|
431
|
-
if (lowered.moneyEvents.length > localCap) {
|
|
432
|
-
issues.push({
|
|
433
|
-
message: `settlement ${settlement.name} emits ${lowered.moneyEvents.length} money events, but ${settlement.archetype} carries a local cap of ${localCap}`,
|
|
434
|
-
span: settlement.origin,
|
|
435
|
-
});
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
|
-
// Event and rule keys concatenate settlement names with generated stems,
|
|
439
|
-
// so two settlements can mint the same key (a + b_service_fee vs a_b +
|
|
440
|
-
// service_fee). The frame schema refuses duplicates wholesale, which
|
|
441
|
-
// would surface as an internal fault; refuse here at the source instead.
|
|
442
|
-
for (const minted of [...lowered.moneyEvents, ...lowered.rules]) {
|
|
443
|
-
const key = minted.key;
|
|
444
|
-
const owner = mintedKeys.get(key);
|
|
445
|
-
if (owner) {
|
|
446
|
-
issues.push({
|
|
447
|
-
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`,
|
|
448
|
-
span: settlement.origin,
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
mintedKeys.set(key, settlement.name);
|
|
452
|
-
}
|
|
453
|
-
settlements.push(lowered.settlement);
|
|
454
|
-
const loweredNouns = [lowered.noun, ...(lowered.extraNouns ?? [])];
|
|
455
|
-
for (const noun of loweredNouns) {
|
|
456
|
-
const verbs = noun.verbs;
|
|
457
|
-
for (const [verbName, verb] of Object.entries(verbs)) {
|
|
458
|
-
if (Object.hasOwn(verb, "due") ||
|
|
459
|
-
Object.hasOwn(verb, "requiresSettlement")) {
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
const publicIntent = callerDrivenPublicIntent(noun.id, verbName);
|
|
463
|
-
if (publicIntent.length <= PUBLIC_INTENT_BUDGET)
|
|
464
|
-
continue;
|
|
465
|
-
issues.push({
|
|
466
|
-
message: `settlement ${settlement.name} generates public intent "${publicIntent}" with ${publicIntent.length} characters; rename the settlement so each public intent fits the ${PUBLIC_INTENT_BUDGET}-character camelName limit`,
|
|
467
|
-
span: settlement.origin,
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
nouns.push(...loweredNouns);
|
|
472
|
-
for (const nounId of lowered.generatedPrefixNounIds ?? []) {
|
|
473
|
-
generatedPrefixNounIds.add(nounId);
|
|
474
|
-
}
|
|
475
|
-
moneyEvents.push(...lowered.moneyEvents);
|
|
476
|
-
rules.push(...lowered.rules);
|
|
477
|
-
design.push(...lowered.design);
|
|
478
|
-
feeLines.push(...lowered.feeLines);
|
|
479
|
-
if (lowered.repeatableCounterparty) {
|
|
480
|
-
repeatableCounterparties.push(lowered.repeatableCounterparty);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
if (moneyEvents.length > MONEY_EVENT_BUDGET) {
|
|
484
|
-
issues.push({
|
|
485
|
-
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`,
|
|
486
|
-
span: program.settlements[0]?.origin ?? { end: 0, start: 0 },
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
issues.push(...validateAmountDependencyGraph(moneyEvents, program.settlements[0]?.origin ?? { end: 0, start: 0 }));
|
|
490
|
-
const actorLowering = lowerFrameActors(program, repeatableCounterparties);
|
|
491
|
-
issues.push(...actorLowering.issues);
|
|
492
|
-
if (issues.length > 0)
|
|
493
|
-
return { issues, ok: false };
|
|
494
|
-
const publishedNouns = publishCallerDrivenVerbs(allocatedGeneratedPrefixes(nouns, generatedPrefixNounIds));
|
|
495
|
-
const subjects = program.assets.map((asset) => ({
|
|
496
|
-
kind: asset.name,
|
|
497
|
-
title: titleize(asset.name),
|
|
498
|
-
value: "optional",
|
|
499
|
-
}));
|
|
500
|
-
const document = {
|
|
501
|
-
hsx: HSX_IR_VERSION,
|
|
502
|
-
nouns: publishedNouns,
|
|
503
|
-
product: program.name,
|
|
504
|
-
...(subjects.length > 0 ? { subjects } : {}),
|
|
505
|
-
title: program.title,
|
|
506
|
-
};
|
|
507
|
-
const frame = {
|
|
508
|
-
actors: actorLowering.actors,
|
|
509
|
-
confidence: "high",
|
|
510
|
-
conservationGroups: [],
|
|
511
|
-
design,
|
|
512
|
-
feePolicy: feeLines.length > 0 ? "defined" : "none",
|
|
513
|
-
fees: feeLines,
|
|
514
|
-
headline: program.title,
|
|
515
|
-
mechanics: mechanicsOf(program.settlements),
|
|
516
|
-
moneyEvents,
|
|
517
|
-
offPlatform: program.assets
|
|
518
|
-
.filter((asset) => asset.titleTransfer === "off_platform")
|
|
519
|
-
.map((asset) => ({
|
|
520
|
-
label: `${titleize(asset.name)} title transfer`,
|
|
521
|
-
why: `Ownership of the ${asset.name.replaceAll("_", " ")} changes hands outside the platform`,
|
|
522
|
-
})),
|
|
523
|
-
openQuestions: [],
|
|
524
|
-
rules,
|
|
525
|
-
subjects: program.assets.map((asset) => ({
|
|
526
|
-
kind: asset.name,
|
|
527
|
-
title: titleize(asset.name),
|
|
528
|
-
})),
|
|
529
|
-
summary: summarize(program),
|
|
530
|
-
};
|
|
531
|
-
return {
|
|
532
|
-
ok: true,
|
|
533
|
-
value: { document, frame: clampFrameProse(frame), settlements },
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
function publishCallerDrivenVerbs(nouns) {
|
|
537
|
-
return nouns.map((noun) => {
|
|
538
|
-
const verbs = noun.verbs;
|
|
539
|
-
return {
|
|
540
|
-
...noun,
|
|
541
|
-
verbs: Object.fromEntries(Object.entries(verbs).map(([verbName, verb]) => [
|
|
542
|
-
verbName,
|
|
543
|
-
Object.hasOwn(verb, "due") ||
|
|
544
|
-
Object.hasOwn(verb, "requiresSettlement")
|
|
545
|
-
? verb
|
|
546
|
-
: {
|
|
547
|
-
...verb,
|
|
548
|
-
publicIntent: callerDrivenPublicIntent(noun.id, verbName),
|
|
549
|
-
},
|
|
550
|
-
])),
|
|
551
|
-
};
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
function callerDrivenPublicIntent(nounId, verbName) {
|
|
555
|
-
const nounName = camelize(nounId);
|
|
556
|
-
const domainName = nounName.charAt(0).toUpperCase() + nounName.slice(1);
|
|
557
|
-
return `${camelize(verbName)}${domainName}`;
|
|
558
|
-
}
|
|
559
|
-
/** Add generic on-top amounts after archetype lowering, so no brick owns fee syntax. */
|
|
560
|
-
function addDerivedAmounts(lowered, settlement, amounts, issues) {
|
|
561
|
-
const noun = lowered.noun;
|
|
562
|
-
const fields = { ...(noun.fields ?? {}) };
|
|
563
|
-
const verbs = { ...(noun.verbs ?? {}) };
|
|
564
|
-
const create = { ...(verbs.create ?? {}) };
|
|
565
|
-
const moves = [...(create.moves ?? [])];
|
|
566
|
-
const actors = { ...(noun.actors ?? {}) };
|
|
567
|
-
const derived = [];
|
|
568
|
-
const events = [...lowered.moneyEvents];
|
|
569
|
-
const lines = [...lowered.feeLines];
|
|
570
|
-
for (const amount of amounts) {
|
|
571
|
-
const sourceField = fields[amount.baseField];
|
|
572
|
-
if (sourceField === undefined || sourceField.type !== "money") {
|
|
573
|
-
issues.push({
|
|
574
|
-
message: `settlement ${settlement.name} derives ${amount.field} from ${sourceField === undefined ? "unknown " : "non-money "}field ${amount.baseField}; from must name a stored money field on the settlement owner`,
|
|
575
|
-
span: amount.origin,
|
|
576
|
-
});
|
|
577
|
-
return undefined;
|
|
578
|
-
}
|
|
579
|
-
if (fields[amount.field] !== undefined) {
|
|
580
|
-
issues.push({
|
|
581
|
-
message: `settlement ${settlement.name} derives into existing field ${amount.field}; choose a new derived amount field`,
|
|
582
|
-
span: amount.origin,
|
|
583
|
-
});
|
|
584
|
-
return undefined;
|
|
585
|
-
}
|
|
586
|
-
const eventKey = frameKey(`${settlement.name}_derived_amount`);
|
|
587
|
-
fields[amount.field] = moneyFieldSpec(`Machine-computed ${formatBps(amount.bps)} of ${amount.baseField}; callers never supply it`);
|
|
588
|
-
if (actors[amount.bearer] === undefined) {
|
|
589
|
-
actors[amount.bearer] = "payer";
|
|
590
|
-
}
|
|
591
|
-
actors.platform = "beneficiary";
|
|
592
|
-
derived.push({
|
|
593
|
-
field: amount.field,
|
|
594
|
-
rounding: "floor",
|
|
595
|
-
rule: { bps: amount.bps, kind: "percentage_of" },
|
|
596
|
-
sourceField: amount.baseField,
|
|
597
|
-
});
|
|
598
|
-
moves.push({
|
|
599
|
-
amount: amount.field,
|
|
600
|
-
from: amount.bearer,
|
|
601
|
-
key: "derived_amount",
|
|
602
|
-
moneyEvent: eventKey,
|
|
603
|
-
operation: "create",
|
|
604
|
-
to: "platform",
|
|
605
|
-
});
|
|
606
|
-
events.push(mintEvent({
|
|
607
|
-
amount: `The machine-computed ${amount.field}`,
|
|
608
|
-
fromActor: amount.bearer,
|
|
609
|
-
key: eventKey,
|
|
610
|
-
kind: "charge",
|
|
611
|
-
toActor: "platform",
|
|
612
|
-
trigger: `Collect ${amount.field} with settlement creation`,
|
|
613
|
-
}));
|
|
614
|
-
lines.push({
|
|
615
|
-
label: titleize(amount.field),
|
|
616
|
-
on: `each ${settlement.name.replaceAll("_", " ")}`,
|
|
617
|
-
structure: `${formatBps(amount.bps)} of stored ${amount.baseField}, computed by the runtime`,
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
create.moves = moves;
|
|
621
|
-
verbs.create = create;
|
|
622
|
-
return {
|
|
623
|
-
...lowered,
|
|
624
|
-
design: [
|
|
625
|
-
...lowered.design,
|
|
626
|
-
`${settlement.name}: derived amounts are machine-computed from stored source fields before create movements; fixed and tiered rules are refused`,
|
|
627
|
-
],
|
|
628
|
-
feeLines: lines,
|
|
629
|
-
moneyEvents: events,
|
|
630
|
-
noun: {
|
|
631
|
-
...noun,
|
|
632
|
-
actors,
|
|
633
|
-
derivedAmounts: derived,
|
|
634
|
-
fields,
|
|
635
|
-
verbs,
|
|
636
|
-
},
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
export function lowerFrameActors(program, repeatableCounterparties = []) {
|
|
640
|
-
const roles = partyRoles(program.settlements);
|
|
641
|
-
const parties = new Set(program.parties.map((party) => party.name));
|
|
642
|
-
const overrides = new Map();
|
|
643
|
-
const issues = [];
|
|
644
|
-
for (const counterparty of repeatableCounterparties) {
|
|
645
|
-
if (!parties.has(counterparty.key)) {
|
|
646
|
-
issues.push({
|
|
647
|
-
message: `repeatable counterparty ${counterparty.key} is not a declared party`,
|
|
648
|
-
span: counterparty.origin,
|
|
649
|
-
});
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
const fixedRole = roles.get(counterparty.key);
|
|
653
|
-
if (!fixedRole) {
|
|
654
|
-
issues.push({
|
|
655
|
-
message: `repeatable counterparty ${counterparty.key} is not used by any settlement`,
|
|
656
|
-
span: counterparty.origin,
|
|
657
|
-
});
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
if (fixedRole !== counterparty.role) {
|
|
661
|
-
issues.push({
|
|
662
|
-
message: `repeatable counterparty ${counterparty.key} declares role ${counterparty.role}, but its settlement uses role ${fixedRole}`,
|
|
663
|
-
span: counterparty.origin,
|
|
664
|
-
});
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
if (counterparty.label.trim().length === 0 ||
|
|
668
|
-
counterparty.label.length > 160) {
|
|
669
|
-
issues.push({
|
|
670
|
-
message: `repeatable counterparty ${counterparty.key} label must contain 1 through 160 characters`,
|
|
671
|
-
span: counterparty.origin,
|
|
672
|
-
});
|
|
673
|
-
continue;
|
|
674
|
-
}
|
|
675
|
-
if (!Number.isInteger(counterparty.minCount) ||
|
|
676
|
-
!Number.isInteger(counterparty.maxCount) ||
|
|
677
|
-
counterparty.minCount < 1 ||
|
|
678
|
-
counterparty.maxCount > 10_000) {
|
|
679
|
-
issues.push({
|
|
680
|
-
message: `repeatable counterparty ${counterparty.key} counts must be integers from 1 through 10000`,
|
|
681
|
-
span: counterparty.origin,
|
|
682
|
-
});
|
|
683
|
-
continue;
|
|
684
|
-
}
|
|
685
|
-
if (counterparty.minCount > counterparty.maxCount) {
|
|
686
|
-
issues.push({
|
|
687
|
-
message: `repeatable counterparty ${counterparty.key} has minCount ${counterparty.minCount} above maxCount ${counterparty.maxCount}`,
|
|
688
|
-
span: counterparty.origin,
|
|
689
|
-
});
|
|
690
|
-
continue;
|
|
691
|
-
}
|
|
692
|
-
if (overrides.has(counterparty.key)) {
|
|
693
|
-
issues.push({
|
|
694
|
-
message: `repeatable counterparty ${counterparty.key} is declared twice`,
|
|
695
|
-
span: counterparty.origin,
|
|
696
|
-
});
|
|
697
|
-
continue;
|
|
698
|
-
}
|
|
699
|
-
overrides.set(counterparty.key, counterparty);
|
|
700
|
-
}
|
|
701
|
-
const actors = [
|
|
702
|
-
...program.parties
|
|
703
|
-
.filter((party) => roles.has(party.name))
|
|
704
|
-
.map((party) => {
|
|
705
|
-
const override = overrides.get(party.name);
|
|
706
|
-
return override
|
|
707
|
-
? {
|
|
708
|
-
key: override.key,
|
|
709
|
-
label: override.label,
|
|
710
|
-
maxCount: override.maxCount,
|
|
711
|
-
minCount: override.minCount,
|
|
712
|
-
role: override.role,
|
|
713
|
-
}
|
|
714
|
-
: {
|
|
715
|
-
key: party.name,
|
|
716
|
-
label: titleize(party.name),
|
|
717
|
-
maxCount: 1,
|
|
718
|
-
minCount: 1,
|
|
719
|
-
role: roles.get(party.name),
|
|
720
|
-
};
|
|
721
|
-
}),
|
|
722
|
-
{
|
|
723
|
-
key: "platform",
|
|
724
|
-
label: "Platform",
|
|
725
|
-
maxCount: 1,
|
|
726
|
-
minCount: 1,
|
|
727
|
-
role: "platform",
|
|
728
|
-
},
|
|
729
|
-
];
|
|
730
|
-
return { actors, issues };
|
|
731
|
-
}
|
|
732
|
-
/** Validate the completed event graph before HSX returns a frame. */
|
|
733
|
-
export function validateAmountDependencyGraph(events, origin) {
|
|
734
|
-
const issues = [];
|
|
735
|
-
const dependenciesByKey = new Map();
|
|
736
|
-
for (const event of events) {
|
|
737
|
-
if (typeof event.key !== "string")
|
|
738
|
-
continue;
|
|
739
|
-
const dependencies = Array.isArray(event.amountDependencies)
|
|
740
|
-
? event.amountDependencies.filter((dependency) => typeof dependency === "string")
|
|
741
|
-
: [];
|
|
742
|
-
dependenciesByKey.set(event.key, dependencies);
|
|
743
|
-
}
|
|
744
|
-
for (const [key, dependencies] of dependenciesByKey) {
|
|
745
|
-
for (const dependency of dependencies) {
|
|
746
|
-
if (dependency === key) {
|
|
747
|
-
issues.push({
|
|
748
|
-
message: `money event ${key} cannot depend on itself`,
|
|
749
|
-
span: origin,
|
|
750
|
-
});
|
|
751
|
-
continue;
|
|
752
|
-
}
|
|
753
|
-
if (!dependenciesByKey.has(dependency)) {
|
|
754
|
-
issues.push({
|
|
755
|
-
message: `money event ${key} depends on missing money event ${dependency}`,
|
|
756
|
-
span: origin,
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
const visiting = new Set();
|
|
762
|
-
const visited = new Set();
|
|
763
|
-
const cyclic = new Set();
|
|
764
|
-
const visit = (key) => {
|
|
765
|
-
if (visited.has(key) || cyclic.has(key))
|
|
766
|
-
return;
|
|
767
|
-
if (visiting.has(key)) {
|
|
768
|
-
cyclic.add(key);
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
visiting.add(key);
|
|
772
|
-
for (const dependency of dependenciesByKey.get(key) ?? []) {
|
|
773
|
-
if (dependenciesByKey.has(dependency))
|
|
774
|
-
visit(dependency);
|
|
775
|
-
if (cyclic.has(dependency))
|
|
776
|
-
cyclic.add(key);
|
|
777
|
-
}
|
|
778
|
-
visiting.delete(key);
|
|
779
|
-
visited.add(key);
|
|
780
|
-
};
|
|
781
|
-
for (const key of dependenciesByKey.keys())
|
|
782
|
-
visit(key);
|
|
783
|
-
if (cyclic.size > 0) {
|
|
784
|
-
issues.push({
|
|
785
|
-
message: `money event amount dependencies contain a cycle through ${[...cyclic].sort().join(", ")}`,
|
|
786
|
-
span: origin,
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
return issues;
|
|
790
|
-
}
|
|
791
|
-
/** Frame actor role per party, with a fixed precedence when roles overlap. */
|
|
792
|
-
function partyRoles(settlements) {
|
|
793
|
-
const payers = new Set();
|
|
794
|
-
const beneficiaries = new Set();
|
|
795
|
-
const providers = new Set();
|
|
796
|
-
const holders = new Set();
|
|
797
|
-
for (const settlement of settlements) {
|
|
798
|
-
switch (settlement.archetype) {
|
|
799
|
-
case "held_payment":
|
|
800
|
-
case "captured_payment":
|
|
801
|
-
case "instant_transfer":
|
|
802
|
-
case "metered":
|
|
803
|
-
payers.add(settlement.payer);
|
|
804
|
-
beneficiaries.add(settlement.payee);
|
|
805
|
-
break;
|
|
806
|
-
case "scheduled":
|
|
807
|
-
payers.add(settlement.payer);
|
|
808
|
-
beneficiaries.add(settlement.payee);
|
|
809
|
-
if (settlement.mode === "obligation") {
|
|
810
|
-
payers.add(settlement.debtor);
|
|
811
|
-
if (settlement.advanceTo)
|
|
812
|
-
beneficiaries.add(settlement.advanceTo);
|
|
813
|
-
}
|
|
814
|
-
break;
|
|
815
|
-
case "premium_forward":
|
|
816
|
-
payers.add(settlement.payer);
|
|
817
|
-
providers.add(settlement.carrier);
|
|
818
|
-
break;
|
|
819
|
-
case "deposit":
|
|
820
|
-
payers.add(settlement.payer);
|
|
821
|
-
holders.add(settlement.holder);
|
|
822
|
-
break;
|
|
823
|
-
case "advance":
|
|
824
|
-
payers.add(settlement.funder);
|
|
825
|
-
beneficiaries.add(settlement.advanced);
|
|
826
|
-
break;
|
|
827
|
-
case "pooled_split":
|
|
828
|
-
payers.add(settlement.payer);
|
|
829
|
-
for (const share of settlement.shares)
|
|
830
|
-
beneficiaries.add(share.to);
|
|
831
|
-
break;
|
|
832
|
-
case "settlement_batch":
|
|
833
|
-
payers.add(settlement.settlementAccount);
|
|
834
|
-
beneficiaries.add(settlement.payoutDestination);
|
|
835
|
-
break;
|
|
836
|
-
case "funding_round":
|
|
837
|
-
payers.add(settlement.contributor);
|
|
838
|
-
beneficiaries.add(settlement.beneficiary);
|
|
839
|
-
break;
|
|
840
|
-
case "weighted_distribution":
|
|
841
|
-
payers.add(settlement.source);
|
|
842
|
-
beneficiaries.add(settlement.recipient);
|
|
843
|
-
break;
|
|
844
|
-
case "credit_facility":
|
|
845
|
-
payers.add(settlement.lender);
|
|
846
|
-
beneficiaries.add(settlement.borrower);
|
|
847
|
-
beneficiaries.add(settlement.drawDestination);
|
|
848
|
-
break;
|
|
849
|
-
case "recurring_collection":
|
|
850
|
-
break;
|
|
851
|
-
case "conditional_disbursement":
|
|
852
|
-
payers.add(settlement.source);
|
|
853
|
-
beneficiaries.add(settlement.destination);
|
|
854
|
-
break;
|
|
855
|
-
case "rotating_pool":
|
|
856
|
-
for (const member of settlement.members) {
|
|
857
|
-
payers.add(member);
|
|
858
|
-
beneficiaries.add(member);
|
|
859
|
-
}
|
|
860
|
-
if (settlement.guarantor)
|
|
861
|
-
payers.add(settlement.guarantor);
|
|
862
|
-
break;
|
|
863
|
-
case "swap":
|
|
864
|
-
payers.add(settlement.sides[0].party);
|
|
865
|
-
beneficiaries.add(settlement.sides[1].party);
|
|
866
|
-
break;
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
const roles = new Map();
|
|
870
|
-
const assign = (names, role) => {
|
|
871
|
-
for (const name of names)
|
|
872
|
-
if (!roles.has(name))
|
|
873
|
-
roles.set(name, role);
|
|
874
|
-
};
|
|
875
|
-
assign(payers, "payer");
|
|
876
|
-
assign(providers, "provider");
|
|
877
|
-
assign(holders, "holder");
|
|
878
|
-
assign(beneficiaries, "beneficiary");
|
|
879
|
-
return roles;
|
|
880
|
-
}
|
|
881
|
-
const ARCHETYPE_MECHANICS = {
|
|
882
|
-
advance: "credit",
|
|
883
|
-
captured_payment: "escrow",
|
|
884
|
-
conditional_disbursement: "marketplace",
|
|
885
|
-
credit_facility: "credit",
|
|
886
|
-
deposit: "escrow",
|
|
887
|
-
funding_round: "credit",
|
|
888
|
-
held_payment: "escrow",
|
|
889
|
-
instant_transfer: "marketplace",
|
|
890
|
-
metered: "recurring_billing",
|
|
891
|
-
pooled_split: "marketplace",
|
|
892
|
-
premium_forward: "insurance",
|
|
893
|
-
recurring_collection: "recurring_billing",
|
|
894
|
-
rotating_pool: "recurring_billing",
|
|
895
|
-
scheduled: "recurring_billing",
|
|
896
|
-
settlement_batch: "marketplace",
|
|
897
|
-
swap: "escrow",
|
|
898
|
-
weighted_distribution: "marketplace",
|
|
899
|
-
};
|
|
900
|
-
function mechanicsOf(settlements) {
|
|
901
|
-
const mechanics = new Set(settlements.map((settlement) => settlement.archetype === "scheduled" && settlement.mode === "obligation"
|
|
902
|
-
? "credit"
|
|
903
|
-
: ARCHETYPE_MECHANICS[settlement.archetype]));
|
|
904
|
-
return mechanics.size > 0 ? [...mechanics] : ["escrow"];
|
|
905
|
-
}
|
|
906
|
-
// ---------------------------------------------------------------------------
|
|
907
|
-
// swap: strict two-party, two-leg atomic custody
|
|
908
|
-
function lowerSwap(settlement, releasePort, disputePort) {
|
|
909
|
-
const noun = settlement.name;
|
|
910
|
-
const [sideA, sideB] = settlement.sides;
|
|
911
|
-
const window = settlement.dispute?.window;
|
|
912
|
-
const hasClawback = window !== undefined && window.days > 0;
|
|
913
|
-
const postRuleKey = frameKey(`${noun}_clawback_maturity`);
|
|
914
|
-
const events = [];
|
|
915
|
-
const event = (stem, kind, amount, fromActor, toActor, trigger) => {
|
|
916
|
-
const key = frameKey(`${noun}_${stem}`);
|
|
917
|
-
events.push(mintEvent({ amount, fromActor, key, kind, toActor, trigger }));
|
|
918
|
-
return key;
|
|
919
|
-
};
|
|
920
|
-
const sideAFundEvent = event("side_a_fund", "charge", `The full ${sideA.amount.name}`, sideA.party, "escrow", `Fund ${sideA.amount.name} into the shared trade escrow`);
|
|
921
|
-
const sideBFundEvent = event("side_b_fund", "charge", `The full ${sideB.amount.name}`, sideB.party, "escrow", `Fund ${sideB.amount.name} into the shared trade escrow`);
|
|
922
|
-
const sideAReleaseEvent = event("side_a_release", "payout", `The full ${sideA.amount.name}`, "escrow", sideB.party, `Release ${sideA.amount.name} across to ${sideB.party.replaceAll("_", " ")}`);
|
|
923
|
-
const sideBReleaseEvent = event("side_b_release", "payout", `The full ${sideB.amount.name}`, "escrow", sideA.party, `Release ${sideB.amount.name} across to ${sideA.party.replaceAll("_", " ")}`);
|
|
924
|
-
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`);
|
|
925
|
-
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`);
|
|
926
|
-
const sideAClawbackEvent = hasClawback
|
|
927
|
-
? event("side_a_clawback", "refund", `The full ${sideA.amount.name}`, "escrow", sideA.party, `Return ${sideA.amount.name} after the whole trade is disputed`)
|
|
928
|
-
: undefined;
|
|
929
|
-
const sideBClawbackEvent = hasClawback
|
|
930
|
-
? event("side_b_clawback", "refund", `The full ${sideB.amount.name}`, "escrow", sideB.party, `Return ${sideB.amount.name} after the whole trade is disputed`)
|
|
931
|
-
: undefined;
|
|
932
|
-
const fields = {
|
|
933
|
-
[sideA.amount.name]: moneyFieldSpec(`Side A amount in ${sideA.amount.currency} minor units, held against the whole trade`),
|
|
934
|
-
[sideB.amount.name]: moneyFieldSpec(`Side B amount in ${sideB.amount.currency} minor units, held against the whole trade`),
|
|
935
|
-
...(hasClawback
|
|
936
|
-
? {
|
|
937
|
-
clawbackAt: {
|
|
938
|
-
type: "date?",
|
|
939
|
-
desc: `Machine-owned end of the ${window?.raw ?? "fixed"} whole-trade dispute window`,
|
|
940
|
-
},
|
|
941
|
-
}
|
|
942
|
-
: {}),
|
|
943
|
-
};
|
|
944
|
-
const fundMoves = [
|
|
945
|
-
{
|
|
946
|
-
amount: sideA.amount.name,
|
|
947
|
-
from: sideA.party,
|
|
948
|
-
key: "side_a_principal",
|
|
949
|
-
moneyEvent: sideAFundEvent,
|
|
950
|
-
operation: "create",
|
|
951
|
-
to: "escrow",
|
|
952
|
-
},
|
|
953
|
-
{
|
|
954
|
-
amount: sideB.amount.name,
|
|
955
|
-
from: sideB.party,
|
|
956
|
-
key: "side_b_principal",
|
|
957
|
-
moneyEvent: sideBFundEvent,
|
|
958
|
-
operation: "create",
|
|
959
|
-
to: "escrow",
|
|
960
|
-
},
|
|
961
|
-
];
|
|
962
|
-
const feeLines = [];
|
|
963
|
-
for (const [index, side] of settlement.sides.entries()) {
|
|
964
|
-
if (!side.fee)
|
|
965
|
-
continue;
|
|
966
|
-
const field = side.fee.amount.name;
|
|
967
|
-
fields[field] = moneyFieldSpec(`Exact ${side.party.replaceAll("_", " ")} service fee in ${side.fee.amount.currency} minor units, charged on top and never held`);
|
|
968
|
-
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`);
|
|
969
|
-
fundMoves.push({
|
|
970
|
-
amount: field,
|
|
971
|
-
from: side.party,
|
|
972
|
-
key: index === 0 ? "side_a_service_fee" : "side_b_service_fee",
|
|
973
|
-
moneyEvent: feeEvent,
|
|
974
|
-
operation: "create",
|
|
975
|
-
to: "platform",
|
|
976
|
-
});
|
|
977
|
-
feeLines.push({
|
|
978
|
-
label: `${titleize(side.party)} custody fee`,
|
|
979
|
-
on: `each funded ${noun.replaceAll("_", " ")}`,
|
|
980
|
-
structure: `Exact ${field}, on top`,
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
const releaseMoves = [
|
|
984
|
-
{
|
|
985
|
-
amount: sideA.amount.name,
|
|
986
|
-
from: "escrow",
|
|
987
|
-
key: "side_a",
|
|
988
|
-
moneyEvent: sideAReleaseEvent,
|
|
989
|
-
operation: hasClawback ? "reserve" : "create",
|
|
990
|
-
to: sideB.party,
|
|
991
|
-
},
|
|
992
|
-
{
|
|
993
|
-
amount: sideB.amount.name,
|
|
994
|
-
from: "escrow",
|
|
995
|
-
key: "side_b",
|
|
996
|
-
moneyEvent: sideBReleaseEvent,
|
|
997
|
-
operation: hasClawback ? "reserve" : "create",
|
|
998
|
-
to: sideA.party,
|
|
999
|
-
},
|
|
1000
|
-
];
|
|
1001
|
-
const verbs = {
|
|
1002
|
-
abandon: {
|
|
1003
|
-
from: ["created"],
|
|
1004
|
-
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
1005
|
-
summary: "Abandon the trade before its atomic funding batch",
|
|
1006
|
-
to: "abandoned",
|
|
1007
|
-
},
|
|
1008
|
-
cancel: {
|
|
1009
|
-
from: ["funded"],
|
|
1010
|
-
moves: [
|
|
1011
|
-
{
|
|
1012
|
-
amount: sideA.amount.name,
|
|
1013
|
-
from: "escrow",
|
|
1014
|
-
key: "side_a_refund",
|
|
1015
|
-
moneyEvent: sideACancelEvent,
|
|
1016
|
-
operation: "create",
|
|
1017
|
-
to: sideA.party,
|
|
1018
|
-
},
|
|
1019
|
-
{
|
|
1020
|
-
amount: sideB.amount.name,
|
|
1021
|
-
from: "escrow",
|
|
1022
|
-
key: "side_b_refund",
|
|
1023
|
-
moneyEvent: sideBCancelEvent,
|
|
1024
|
-
operation: "create",
|
|
1025
|
-
to: sideB.party,
|
|
1026
|
-
},
|
|
1027
|
-
],
|
|
1028
|
-
summary: "Cancel and return both trade principals atomically",
|
|
1029
|
-
to: "cancelled",
|
|
1030
|
-
},
|
|
1031
|
-
create: {
|
|
1032
|
-
summary: `Create a ${titleize(noun).toLowerCase()} atomic trade`,
|
|
1033
|
-
to: "created",
|
|
1034
|
-
},
|
|
1035
|
-
fund: {
|
|
1036
|
-
from: ["created"],
|
|
1037
|
-
moves: fundMoves,
|
|
1038
|
-
summary: "Fund both trade sides and collect on-top fees atomically",
|
|
1039
|
-
to: "funded",
|
|
1040
|
-
},
|
|
1041
|
-
release: {
|
|
1042
|
-
from: ["funded"],
|
|
1043
|
-
moves: releaseMoves,
|
|
1044
|
-
port: { allowed: [...releasePort.allowed] },
|
|
1045
|
-
...(hasClawback
|
|
1046
|
-
? { setsAt: { field: "clawbackAt", offset: window?.raw } }
|
|
1047
|
-
: {}),
|
|
1048
|
-
summary: hasClawback
|
|
1049
|
-
? "Reserve both cross-payments for the whole-trade clawback window"
|
|
1050
|
-
: "Post both cross-payments atomically",
|
|
1051
|
-
to: hasClawback ? "released" : "settled",
|
|
1052
|
-
},
|
|
1053
|
-
};
|
|
1054
|
-
if (hasClawback &&
|
|
1055
|
-
settlement.dispute &&
|
|
1056
|
-
disputePort &&
|
|
1057
|
-
sideAClawbackEvent &&
|
|
1058
|
-
sideBClawbackEvent) {
|
|
1059
|
-
verbs.post = {
|
|
1060
|
-
due: { field: "clawbackAt", rule: postRuleKey },
|
|
1061
|
-
from: ["released"],
|
|
1062
|
-
moves: [
|
|
1063
|
-
{ key: "side_a", operation: "post", reservation: "release_side_a" },
|
|
1064
|
-
{ key: "side_b", operation: "post", reservation: "release_side_b" },
|
|
1065
|
-
],
|
|
1066
|
-
summary: "Post both trade reservations when the clawback window matures",
|
|
1067
|
-
to: "settled",
|
|
1068
|
-
};
|
|
1069
|
-
verbs.dispute = {
|
|
1070
|
-
deadline: { field: "clawbackAt" },
|
|
1071
|
-
from: ["released"],
|
|
1072
|
-
moves: [
|
|
1073
|
-
{
|
|
1074
|
-
key: "side_a_void",
|
|
1075
|
-
operation: "void",
|
|
1076
|
-
reason: "Whole trade disputed inside the clawback window",
|
|
1077
|
-
reservation: "release_side_a",
|
|
1078
|
-
},
|
|
1079
|
-
{
|
|
1080
|
-
key: "side_b_void",
|
|
1081
|
-
operation: "void",
|
|
1082
|
-
reason: "Whole trade disputed inside the clawback window",
|
|
1083
|
-
reservation: "release_side_b",
|
|
1084
|
-
},
|
|
1085
|
-
{
|
|
1086
|
-
amount: sideA.amount.name,
|
|
1087
|
-
clawbackOf: "release_side_a",
|
|
1088
|
-
from: "escrow",
|
|
1089
|
-
key: "side_a_refund",
|
|
1090
|
-
moneyEvent: sideAClawbackEvent,
|
|
1091
|
-
operation: "create",
|
|
1092
|
-
to: sideA.party,
|
|
1093
|
-
},
|
|
1094
|
-
{
|
|
1095
|
-
amount: sideB.amount.name,
|
|
1096
|
-
clawbackOf: "release_side_b",
|
|
1097
|
-
from: "escrow",
|
|
1098
|
-
key: "side_b_refund",
|
|
1099
|
-
moneyEvent: sideBClawbackEvent,
|
|
1100
|
-
operation: "create",
|
|
1101
|
-
to: sideB.party,
|
|
1102
|
-
},
|
|
1103
|
-
],
|
|
1104
|
-
port: { allowed: [...disputePort.allowed] },
|
|
1105
|
-
summary: "Void both reservations, then refund both principals atomically",
|
|
1106
|
-
to: "clawed_back",
|
|
1107
|
-
};
|
|
1108
|
-
}
|
|
1109
|
-
const rules = [
|
|
1110
|
-
{
|
|
1111
|
-
allowedActors: [...releasePort.allowed],
|
|
1112
|
-
detail: `${releasePort.allowed.map(titleize).join(" or ")} confirms the whole exchange through ${releasePort.name}`,
|
|
1113
|
-
dueDriven: false,
|
|
1114
|
-
enforcement: "tenant_app",
|
|
1115
|
-
gatesEvent: sideAReleaseEvent,
|
|
1116
|
-
key: frameKey(`${noun}_${releasePort.name}_gate`),
|
|
1117
|
-
kind: "release_condition",
|
|
1118
|
-
label: `Whole trade released through ${releasePort.name}`,
|
|
1119
|
-
tenantTunable: false,
|
|
1120
|
-
},
|
|
1121
|
-
];
|
|
1122
|
-
if (hasClawback && settlement.dispute && disputePort) {
|
|
1123
|
-
rules.push({
|
|
1124
|
-
allowedActors: [],
|
|
1125
|
-
detail: `Both pending trade payouts post together at the immutable ${settlement.dispute.window.raw} cutoff`,
|
|
1126
|
-
dueDriven: true,
|
|
1127
|
-
enforcement: "platform",
|
|
1128
|
-
gatesEvent: null,
|
|
1129
|
-
key: postRuleKey,
|
|
1130
|
-
kind: "deadline",
|
|
1131
|
-
label: "Whole trade posts when its clawback window matures",
|
|
1132
|
-
tenantTunable: false,
|
|
1133
|
-
}, {
|
|
1134
|
-
allowedActors: [...disputePort.allowed],
|
|
1135
|
-
detail: `${disputePort.allowed.map(titleize).join(" or ")} may dispute only before the immutable cutoff`,
|
|
1136
|
-
dueDriven: false,
|
|
1137
|
-
enforcement: "tenant_app",
|
|
1138
|
-
gatesEvent: sideAClawbackEvent ?? null,
|
|
1139
|
-
key: frameKey(`${noun}_${disputePort.name}_gate`),
|
|
1140
|
-
kind: "release_condition",
|
|
1141
|
-
label: `Whole trade disputed through ${disputePort.name}`,
|
|
1142
|
-
tenantTunable: false,
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
return {
|
|
1146
|
-
design: [
|
|
1147
|
-
`${noun}: exactly two parties, two same-currency principals, one escrow, and one linked batch per phase`,
|
|
1148
|
-
hasClawback
|
|
1149
|
-
? `${noun}: ${window?.raw} whole-trade clawback; release reserves both legs, then exactly one grouped post or grouped void-and-refund wins`
|
|
1150
|
-
: `${noun}: no clawback window; release posts both legs directly and exposes no pending or dispute surface`,
|
|
1151
|
-
],
|
|
1152
|
-
feeLines,
|
|
1153
|
-
moneyEvents: events,
|
|
1154
|
-
noun: {
|
|
1155
|
-
actors: {
|
|
1156
|
-
[sideA.party]: "payer",
|
|
1157
|
-
[sideB.party]: "beneficiary",
|
|
1158
|
-
...(feeLines.length > 0 ? { platform: "party" } : {}),
|
|
1159
|
-
},
|
|
1160
|
-
desc: `Atomic swap between ${sideA.party.replaceAll("_", " ")} and ${sideB.party.replaceAll("_", " ")}; half-funded and half-released states do not exist`,
|
|
1161
|
-
distinctParties: true,
|
|
1162
|
-
escrow: true,
|
|
1163
|
-
fields,
|
|
1164
|
-
id: noun,
|
|
1165
|
-
summary: `Two-party atomic trade between ${sideA.party.replaceAll("_", " ")} and ${sideB.party.replaceAll("_", " ")}`,
|
|
1166
|
-
title: titleize(noun),
|
|
1167
|
-
verbs,
|
|
1168
|
-
},
|
|
1169
|
-
rules,
|
|
1170
|
-
settlement: {
|
|
1171
|
-
name: noun,
|
|
1172
|
-
pieces: [
|
|
1173
|
-
{
|
|
1174
|
-
bps: 10_000,
|
|
1175
|
-
cancelTo: sideA.party,
|
|
1176
|
-
field: sideA.amount.name,
|
|
1177
|
-
origin: sideA.amount.origin,
|
|
1178
|
-
releaseTo: sideB.party,
|
|
1179
|
-
},
|
|
1180
|
-
{
|
|
1181
|
-
bps: 10_000,
|
|
1182
|
-
cancelTo: sideB.party,
|
|
1183
|
-
field: sideB.amount.name,
|
|
1184
|
-
origin: sideB.amount.origin,
|
|
1185
|
-
releaseTo: sideA.party,
|
|
1186
|
-
},
|
|
1187
|
-
],
|
|
1188
|
-
},
|
|
1189
|
-
};
|
|
1190
|
-
}
|
|
1191
|
-
// ---------------------------------------------------------------------------
|
|
1192
|
-
// held_payment and premium_forward: the escrow-held family
|
|
1193
|
-
function lowerHeldPayment(settlement, port,
|
|
1194
|
-
/** The funder of the advance carving this hold's release, when one does. */
|
|
1195
|
-
carveTo, issues) {
|
|
1196
|
-
const payerFee = settlement.fees.find((fee) => fee.bearer === settlement.payer);
|
|
1197
|
-
const payeeFee = settlement.fees.find((fee) => fee.bearer === settlement.payee);
|
|
1198
|
-
const held = lowerHeldFamily({
|
|
1199
|
-
amount: settlement.amount,
|
|
1200
|
-
carveTo,
|
|
1201
|
-
deadlineField: settlement.releaseDeadlineField,
|
|
1202
|
-
fundEventKind: "charge",
|
|
1203
|
-
fundTrigger: (index, total) => `Fund piece ${index + 1} of ${total} into escrow`,
|
|
1204
|
-
name: settlement.name,
|
|
1205
|
-
onCancel: settlement.onCancel,
|
|
1206
|
-
payee: settlement.payee,
|
|
1207
|
-
payeeFeeBps: payeeFee?.bps ?? 0,
|
|
1208
|
-
payer: settlement.payer,
|
|
1209
|
-
payerFeeBps: payerFee?.bps,
|
|
1210
|
-
port,
|
|
1211
|
-
releaseWord: "release",
|
|
1212
|
-
}, issues);
|
|
1213
|
-
if (!held)
|
|
1214
|
-
return undefined;
|
|
1215
|
-
return {
|
|
1216
|
-
...held,
|
|
1217
|
-
feeLines: [
|
|
1218
|
-
...(payerFee
|
|
1219
|
-
? [
|
|
1220
|
-
{
|
|
1221
|
-
label: `${titleize(settlement.payer)} service fee`,
|
|
1222
|
-
on: `each funded ${settlement.name.replaceAll("_", " ")}`,
|
|
1223
|
-
structure: `${formatBps(payerFee.bps)} of the ${settlement.amount.name}, on top`,
|
|
1224
|
-
},
|
|
1225
|
-
]
|
|
1226
|
-
: []),
|
|
1227
|
-
...(payeeFee
|
|
1228
|
-
? [
|
|
1229
|
-
{
|
|
1230
|
-
label: `${titleize(settlement.payee)} fee`,
|
|
1231
|
-
on: `each released ${settlement.name.replaceAll("_", " ")}`,
|
|
1232
|
-
structure: `${formatBps(payeeFee.bps)} of the ${settlement.amount.name}, deducted from the payout`,
|
|
1233
|
-
},
|
|
1234
|
-
]
|
|
1235
|
-
: []),
|
|
1236
|
-
],
|
|
1237
|
-
noun: {
|
|
1238
|
-
...held.noun,
|
|
1239
|
-
desc: `Held payment: the ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into this settlement's own escrow; ${port.allowed
|
|
1240
|
-
.map((party) => party.replaceAll("_", " "))
|
|
1241
|
-
.join(" or ")} confirms through ${port.name} to release${carveTo ? ` to the ${carveTo.replaceAll("_", " ")}, whose advance the ${settlement.payee.replaceAll("_", " ")} repays out of it` : ""}`,
|
|
1242
|
-
summary: `Escrow-held payment from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
1243
|
-
},
|
|
1244
|
-
};
|
|
1245
|
-
}
|
|
1246
|
-
function lowerPremiumForward(settlement, port, endorsement, issues) {
|
|
1247
|
-
const held = lowerHeldFamily({
|
|
1248
|
-
amount: settlement.amount,
|
|
1249
|
-
// A premium is the carrier's, never the payer's receivable, so there is
|
|
1250
|
-
// nothing here for an advance to draw against.
|
|
1251
|
-
carveTo: undefined,
|
|
1252
|
-
deadlineField: undefined,
|
|
1253
|
-
fundEventKind: "premium",
|
|
1254
|
-
fundTrigger: (index, total) => total === 1
|
|
1255
|
-
? "Collect the premium into escrow"
|
|
1256
|
-
: `Collect premium piece ${index + 1} of ${total} into escrow`,
|
|
1257
|
-
name: settlement.name,
|
|
1258
|
-
onCancel: settlement.onCancel,
|
|
1259
|
-
payee: settlement.carrier,
|
|
1260
|
-
payeeFeeBps: settlement.commissionBps,
|
|
1261
|
-
payer: settlement.payer,
|
|
1262
|
-
payerFeeBps: undefined,
|
|
1263
|
-
port,
|
|
1264
|
-
releaseWord: "forward",
|
|
1265
|
-
}, issues);
|
|
1266
|
-
if (!held)
|
|
1267
|
-
return undefined;
|
|
1268
|
-
const baseNoun = held.noun;
|
|
1269
|
-
const fields = { ...(baseNoun.fields ?? {}) };
|
|
1270
|
-
const verbs = { ...(baseNoun.verbs ?? {}) };
|
|
1271
|
-
const rules = [...held.rules];
|
|
1272
|
-
if (settlement.policyReferenceField &&
|
|
1273
|
-
settlement.renewalDueField &&
|
|
1274
|
-
settlement.endorsement &&
|
|
1275
|
-
endorsement) {
|
|
1276
|
-
fields[settlement.policyReferenceField] = {
|
|
1277
|
-
desc: "Immutable external policy reference recorded with this forward",
|
|
1278
|
-
type: "text",
|
|
1279
|
-
};
|
|
1280
|
-
fields[settlement.renewalDueField] = dateFieldSpec("Stored renewal due condition for the forwarded policy");
|
|
1281
|
-
verbs[settlement.endorsement.port] = {
|
|
1282
|
-
captureInput: { endorsementEvidenceReference: "evidenceReference" },
|
|
1283
|
-
from: ["released"],
|
|
1284
|
-
port: {
|
|
1285
|
-
allowed: endorsement.allowed,
|
|
1286
|
-
fields: { evidenceReference: "text" },
|
|
1287
|
-
},
|
|
1288
|
-
summary: "Record one non-money endorsement from external evidence",
|
|
1289
|
-
to: "endorsed",
|
|
1290
|
-
};
|
|
1291
|
-
const lapseRule = frameKey(`${settlement.name}_renewal_due`);
|
|
1292
|
-
verbs.lapse = {
|
|
1293
|
-
due: { field: settlement.renewalDueField, rule: lapseRule },
|
|
1294
|
-
from: ["released", "endorsed"],
|
|
1295
|
-
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
1296
|
-
summary: "Mark the forwarded policy lapsed at its stored renewal due condition",
|
|
1297
|
-
to: "lapsed",
|
|
1298
|
-
};
|
|
1299
|
-
rules.push({
|
|
1300
|
-
allowedActors: [],
|
|
1301
|
-
detail: "The stored renewal due condition changes policy state without moving money",
|
|
1302
|
-
dueDriven: true,
|
|
1303
|
-
enforcement: "platform",
|
|
1304
|
-
gatesEvent: null,
|
|
1305
|
-
key: lapseRule,
|
|
1306
|
-
kind: "deadline",
|
|
1307
|
-
label: "Policy lapses at its stored renewal due condition",
|
|
1308
|
-
tenantTunable: false,
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
return {
|
|
1312
|
-
...held,
|
|
1313
|
-
design: [
|
|
1314
|
-
`${settlement.name}: premium forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on ${port.name}; ${formatBps(settlement.commissionBps)} commission retained by the platform`,
|
|
1315
|
-
...(settlement.policyReferenceField
|
|
1316
|
-
? [
|
|
1317
|
-
`${settlement.name}: extends premium_forward with stored policy reference, non-money endorsement evidence, and a due-only lapse; renewal creates a new forward`,
|
|
1318
|
-
]
|
|
1319
|
-
: []),
|
|
1320
|
-
],
|
|
1321
|
-
feeLines: settlement.commissionBps > 0
|
|
1322
|
-
? [
|
|
1323
|
-
{
|
|
1324
|
-
label: "Platform commission",
|
|
1325
|
-
on: `each bound ${settlement.name.replaceAll("_", " ")}`,
|
|
1326
|
-
structure: `${formatBps(settlement.commissionBps)} of the ${settlement.amount.name}, deducted at forwarding`,
|
|
1327
|
-
},
|
|
1328
|
-
]
|
|
1329
|
-
: [],
|
|
1330
|
-
noun: {
|
|
1331
|
-
...held.noun,
|
|
1332
|
-
fields,
|
|
1333
|
-
verbs,
|
|
1334
|
-
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`,
|
|
1335
|
-
summary: `Premium held for the ${settlement.carrier.replaceAll("_", " ")} until the policy binds`,
|
|
1336
|
-
},
|
|
1337
|
-
rules,
|
|
1338
|
-
};
|
|
1339
|
-
}
|
|
1340
|
-
function lowerHeldFamily(params, issues) {
|
|
1341
|
-
const amountName = params.amount.name;
|
|
1342
|
-
// A single-piece partition would mint a piece field nothing ties to the
|
|
1343
|
-
// gross amount (no partition clause is declarable over one piece), letting
|
|
1344
|
-
// an instance store one gross and move another. When the amount never
|
|
1345
|
-
// splits, the choreography moves the amount field ITSELF.
|
|
1346
|
-
const rawPieces = partitionPieces(params);
|
|
1347
|
-
const pieces = rawPieces.length === 1
|
|
1348
|
-
? rawPieces.map((piece) => ({ ...piece, field: amountName }))
|
|
1349
|
-
: rawPieces;
|
|
1350
|
-
const noun = params.name;
|
|
1351
|
-
// Who the payee's share actually lands on. Every sentence about the release
|
|
1352
|
-
// has to say this name, not the payee's, or the program would describe a
|
|
1353
|
-
// payout it does not make.
|
|
1354
|
-
const releaseTo = params.carveTo ?? params.payee;
|
|
1355
|
-
const releaseToWords = releaseTo.replaceAll("_", " ");
|
|
1356
|
-
const fields = {
|
|
1357
|
-
[amountName]: moneyFieldSpec(pieces.length === 1
|
|
1358
|
-
? `The held amount in ${params.amount.currency} minor units, funded and paid out whole`
|
|
1359
|
-
: `The gross held amount in ${params.amount.currency} minor units; the piece fields below partition it exactly`),
|
|
1360
|
-
};
|
|
1361
|
-
for (const [index, piece] of pieces.entries()) {
|
|
1362
|
-
if (piece.field === amountName)
|
|
1363
|
-
continue;
|
|
1364
|
-
fields[piece.field] = moneyFieldSpec(pieceDescription(piece, index, amountName, params.amount.currency));
|
|
1365
|
-
}
|
|
1366
|
-
if (params.deadlineField) {
|
|
1367
|
-
fields[params.deadlineField] = dateFieldSpec(`The date an undecided hold releases to the ${releaseToWords} on; ${params.port.name} and cancellation decide only before it`);
|
|
1368
|
-
}
|
|
1369
|
-
if (params.payerFeeBps !== undefined) {
|
|
1370
|
-
fields.serviceFeeAmount = moneyFieldSpec(`${formatBps(params.payerFeeBps)} of ${amountName}, the ${params.payer.replaceAll("_", " ")}-side service fee charged on top at funding; non-refundable`);
|
|
1371
|
-
}
|
|
1372
|
-
const fundVerbs = pieces.map((_, index) => `fund_piece_${index + 1}`);
|
|
1373
|
-
if (params.payerFeeBps !== undefined)
|
|
1374
|
-
fundVerbs.push("collect_service_fee");
|
|
1375
|
-
const releaseVerbs = pieces.map((_, index) => index === 0 ? params.port.name : `${params.releaseWord}_piece_${index + 1}`);
|
|
1376
|
-
const cancelVerbs = params.onCancel
|
|
1377
|
-
? pieces.map((_, index) => index === 0 ? "cancel" : `refund_piece_${index + 1}`)
|
|
1378
|
-
: [];
|
|
1379
|
-
// The anchor is the DEFAULT exit, not a second decider. It mints one more
|
|
1380
|
-
// entry into the SAME release chain, so every piece drains through the
|
|
1381
|
-
// verbs the port path already proves, and the port and the cancel keep
|
|
1382
|
-
// their veto only until the date. Acting before it IS the veto.
|
|
1383
|
-
const deadlineVerb = params.deadlineField
|
|
1384
|
-
? `${params.releaseWord}_on_deadline`
|
|
1385
|
-
: undefined;
|
|
1386
|
-
const deadlineRuleKey = frameKey(`${noun}_${params.releaseWord}_deadline`);
|
|
1387
|
-
// Abandonment: the pre-funded exit. Custody exists only while the deal is
|
|
1388
|
-
// still forming, so each intermediate funding state (funding_k holds pieces
|
|
1389
|
-
// 1..k) unwinds piece by piece. Every unfund verb returns exactly the piece
|
|
1390
|
-
// its funding verb moved, back to the payer, and `created` closes directly.
|
|
1391
|
-
// The chain runs through its own abandoning_* states (never back into
|
|
1392
|
-
// funding states) so the lifecycle stays acyclic and the terminal-escrow
|
|
1393
|
-
// analysis keeps its exact custody tokens. The service fee moves only on the
|
|
1394
|
-
// transition INTO funded (a completed collection), so abandonment never owes
|
|
1395
|
-
// it, the on_cancel policy stays the sole exit from funded.
|
|
1396
|
-
const fundingStateCount = fundVerbs.length - 1;
|
|
1397
|
-
const unfundVerbs = Array.from({ length: fundingStateCount }, (_, index) => `unfund_piece_${index + 1}`);
|
|
1398
|
-
if (!verbNameIssues(params.name, [
|
|
1399
|
-
...fundVerbs,
|
|
1400
|
-
...releaseVerbs,
|
|
1401
|
-
...(deadlineVerb ? [deadlineVerb] : []),
|
|
1402
|
-
...cancelVerbs,
|
|
1403
|
-
"abandon",
|
|
1404
|
-
...unfundVerbs,
|
|
1405
|
-
], params.port.origin, issues)) {
|
|
1406
|
-
return undefined;
|
|
1407
|
-
}
|
|
1408
|
-
const fundStates = chain(fundVerbs, "created", "funded", "funding");
|
|
1409
|
-
const releaseStates = chain(releaseVerbs, "funded", "released", "releasing");
|
|
1410
|
-
const cancelStates = chain(cancelVerbs, "funded", "cancelled", "cancelling");
|
|
1411
|
-
const events = [];
|
|
1412
|
-
const verbs = {};
|
|
1413
|
-
// The budget counts money BEHAVIORS, not pieces: every piece verb sharing a
|
|
1414
|
-
// phase and endpoint pair implements ONE frame event (occurrence repeatable
|
|
1415
|
-
// when several piece verbs share it), so fee carving and cancellation splits
|
|
1416
|
-
// never crowd a composite program out of the frame's event budget.
|
|
1417
|
-
const fundEventKey = `${noun}_fund`;
|
|
1418
|
-
events.push(mintEvent({
|
|
1419
|
-
amount: pieces.length === 1
|
|
1420
|
-
? `The full ${amountName}`
|
|
1421
|
-
: `The ${amountName}, funded piece by piece`,
|
|
1422
|
-
fromActor: params.payer,
|
|
1423
|
-
key: fundEventKey,
|
|
1424
|
-
kind: params.fundEventKind,
|
|
1425
|
-
...(pieces.length > 1 ? { occurrence: "repeatable" } : {}),
|
|
1426
|
-
toActor: "escrow",
|
|
1427
|
-
trigger: params.fundTrigger(0, pieces.length),
|
|
1428
|
-
}));
|
|
1429
|
-
for (const [index, piece] of pieces.entries()) {
|
|
1430
|
-
verbs[fundVerbs[index]] = {
|
|
1431
|
-
from: [fundStates[index]?.from],
|
|
1432
|
-
moneyEvent: fundEventKey,
|
|
1433
|
-
moves: [
|
|
1434
|
-
{
|
|
1435
|
-
key: "transfer",
|
|
1436
|
-
operation: "create",
|
|
1437
|
-
amount: piece.field,
|
|
1438
|
-
from: params.payer,
|
|
1439
|
-
to: "escrow",
|
|
1440
|
-
},
|
|
1441
|
-
],
|
|
1442
|
-
summary: `Fund piece ${index + 1} of the held amount into escrow`,
|
|
1443
|
-
to: fundStates[index]?.to,
|
|
1444
|
-
};
|
|
1445
|
-
}
|
|
1446
|
-
if (params.payerFeeBps !== undefined) {
|
|
1447
|
-
const index = fundVerbs.length - 1;
|
|
1448
|
-
const eventKey = `${noun}_service_fee`;
|
|
1449
|
-
events.push(mintEvent({
|
|
1450
|
-
amount: `${formatBps(params.payerFeeBps)} of the ${amountName}, on top`,
|
|
1451
|
-
fromActor: params.payer,
|
|
1452
|
-
key: eventKey,
|
|
1453
|
-
kind: "charge",
|
|
1454
|
-
toActor: "platform",
|
|
1455
|
-
trigger: "Collect the service fee at funding",
|
|
1456
|
-
}));
|
|
1457
|
-
verbs.collect_service_fee = {
|
|
1458
|
-
from: [fundStates[index]?.from],
|
|
1459
|
-
moneyEvent: eventKey,
|
|
1460
|
-
moves: [
|
|
1461
|
-
{
|
|
1462
|
-
key: "transfer",
|
|
1463
|
-
operation: "create",
|
|
1464
|
-
amount: "serviceFeeAmount",
|
|
1465
|
-
from: params.payer,
|
|
1466
|
-
to: "platform",
|
|
1467
|
-
},
|
|
1468
|
-
],
|
|
1469
|
-
summary: "Collect the payer-side service fee",
|
|
1470
|
-
to: fundStates[index]?.to,
|
|
1471
|
-
};
|
|
1472
|
-
}
|
|
1473
|
-
// Release and cancel pieces group by recipient: one frame event per
|
|
1474
|
-
// distinct endpoint (an event's toActor is fixed), shared by every piece
|
|
1475
|
-
// verb paying that recipient.
|
|
1476
|
-
const releaseGroups = new Map();
|
|
1477
|
-
for (const piece of pieces) {
|
|
1478
|
-
releaseGroups.set(piece.releaseTo, (releaseGroups.get(piece.releaseTo) ?? 0) + 1);
|
|
1479
|
-
}
|
|
1480
|
-
for (const [releaseTo, pieceCount] of releaseGroups) {
|
|
1481
|
-
const totalBps = pieces
|
|
1482
|
-
.filter((piece) => piece.releaseTo === releaseTo)
|
|
1483
|
-
.reduce((sum, piece) => sum + piece.bps, 0);
|
|
1484
|
-
events.push(mintEvent({
|
|
1485
|
-
amount: `${formatBps(totalBps)} of the ${amountName}`,
|
|
1486
|
-
fromActor: "escrow",
|
|
1487
|
-
key: `${noun}_release_${releaseTo}`,
|
|
1488
|
-
kind: "payout",
|
|
1489
|
-
...(pieceCount > 1 ? { occurrence: "repeatable" } : {}),
|
|
1490
|
-
toActor: releaseTo,
|
|
1491
|
-
trigger: `Release to the ${releaseTo.replaceAll("_", " ")}`,
|
|
1492
|
-
}));
|
|
1493
|
-
}
|
|
1494
|
-
for (const [index, piece] of pieces.entries()) {
|
|
1495
|
-
verbs[releaseVerbs[index]] = {
|
|
1496
|
-
...(index === 0 && params.deadlineField
|
|
1497
|
-
? { deadline: { field: params.deadlineField } }
|
|
1498
|
-
: {}),
|
|
1499
|
-
from: [releaseStates[index]?.from],
|
|
1500
|
-
moneyEvent: frameKey(`${noun}_release_${piece.releaseTo}`),
|
|
1501
|
-
moves: [
|
|
1502
|
-
{
|
|
1503
|
-
key: "transfer",
|
|
1504
|
-
operation: "create",
|
|
1505
|
-
amount: piece.field,
|
|
1506
|
-
from: "escrow",
|
|
1507
|
-
to: piece.releaseTo,
|
|
1508
|
-
},
|
|
1509
|
-
],
|
|
1510
|
-
...(index === 0
|
|
1511
|
-
? {
|
|
1512
|
-
port: {
|
|
1513
|
-
allowed: [...params.port.allowed],
|
|
1514
|
-
fields: Object.fromEntries(params.port.fields.map((field) => [field.name, "text"])),
|
|
1515
|
-
},
|
|
1516
|
-
}
|
|
1517
|
-
: {}),
|
|
1518
|
-
summary: index === 0
|
|
1519
|
-
? `Confirm through ${params.port.name} and start the ${params.releaseWord} payout`
|
|
1520
|
-
: `${titleize(params.releaseWord)} piece ${index + 1} of the held amount`,
|
|
1521
|
-
to: releaseStates[index]?.to,
|
|
1522
|
-
};
|
|
1523
|
-
}
|
|
1524
|
-
if (deadlineVerb && params.deadlineField) {
|
|
1525
|
-
const first = pieces[0];
|
|
1526
|
-
verbs[deadlineVerb] = {
|
|
1527
|
-
due: { field: params.deadlineField, rule: deadlineRuleKey },
|
|
1528
|
-
from: [releaseStates[0]?.from],
|
|
1529
|
-
moneyEvent: frameKey(`${noun}_release_${first.releaseTo}`),
|
|
1530
|
-
moves: [
|
|
1531
|
-
{
|
|
1532
|
-
key: "transfer",
|
|
1533
|
-
operation: "create",
|
|
1534
|
-
amount: first.field,
|
|
1535
|
-
from: "escrow",
|
|
1536
|
-
to: first.releaseTo,
|
|
1537
|
-
},
|
|
1538
|
-
],
|
|
1539
|
-
summary: `Release to the ${first.releaseTo.replaceAll("_", " ")} when ${params.deadlineField} arrives undecided`,
|
|
1540
|
-
to: releaseStates[0]?.to,
|
|
1541
|
-
};
|
|
1542
|
-
}
|
|
1543
|
-
if (params.onCancel) {
|
|
1544
|
-
const cancelGroups = new Map();
|
|
1545
|
-
for (const piece of pieces) {
|
|
1546
|
-
const cancelTo = piece.cancelTo;
|
|
1547
|
-
cancelGroups.set(cancelTo, (cancelGroups.get(cancelTo) ?? 0) + 1);
|
|
1548
|
-
}
|
|
1549
|
-
for (const [cancelTo, pieceCount] of cancelGroups) {
|
|
1550
|
-
const totalBps = pieces
|
|
1551
|
-
.filter((piece) => piece.cancelTo === cancelTo)
|
|
1552
|
-
.reduce((sum, piece) => sum + piece.bps, 0);
|
|
1553
|
-
events.push(mintEvent({
|
|
1554
|
-
amount: `${formatBps(totalBps)} of the ${amountName}`,
|
|
1555
|
-
fromActor: "escrow",
|
|
1556
|
-
key: `${noun}_cancel_${cancelTo}`,
|
|
1557
|
-
kind: cancelTo === params.payer ? "refund" : "penalty",
|
|
1558
|
-
...(pieceCount > 1 ? { occurrence: "repeatable" } : {}),
|
|
1559
|
-
toActor: cancelTo,
|
|
1560
|
-
trigger: `Return to the ${cancelTo.replaceAll("_", " ")} on cancellation`,
|
|
1561
|
-
}));
|
|
1562
|
-
}
|
|
1563
|
-
for (const [index, piece] of pieces.entries()) {
|
|
1564
|
-
verbs[cancelVerbs[index]] = {
|
|
1565
|
-
...(index === 0 && params.deadlineField
|
|
1566
|
-
? { deadline: { field: params.deadlineField } }
|
|
1567
|
-
: {}),
|
|
1568
|
-
from: [cancelStates[index]?.from],
|
|
1569
|
-
moneyEvent: frameKey(`${noun}_cancel_${piece.cancelTo}`),
|
|
1570
|
-
moves: [
|
|
1571
|
-
{
|
|
1572
|
-
key: "transfer",
|
|
1573
|
-
operation: "create",
|
|
1574
|
-
amount: piece.field,
|
|
1575
|
-
from: "escrow",
|
|
1576
|
-
to: piece.cancelTo,
|
|
1577
|
-
},
|
|
1578
|
-
],
|
|
1579
|
-
summary: index === 0
|
|
1580
|
-
? "Cancel the settlement and start the unwind"
|
|
1581
|
-
: `Return piece ${index + 1} on cancellation`,
|
|
1582
|
-
to: cancelStates[index]?.to,
|
|
1583
|
-
};
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
if (unfundVerbs.length > 0) {
|
|
1587
|
-
const eventKey = `${noun}_abandon`;
|
|
1588
|
-
events.push(mintEvent({
|
|
1589
|
-
amount: `The funded pieces of the ${amountName}, returned exactly`,
|
|
1590
|
-
fromActor: "escrow",
|
|
1591
|
-
key: eventKey,
|
|
1592
|
-
kind: "refund",
|
|
1593
|
-
...(unfundVerbs.length > 1
|
|
1594
|
-
? { occurrence: "repeatable" }
|
|
1595
|
-
: {}),
|
|
1596
|
-
toActor: params.payer,
|
|
1597
|
-
trigger: `Return the held pieces to the ${params.payer.replaceAll("_", " ")} on abandonment`,
|
|
1598
|
-
}));
|
|
1599
|
-
for (const [index, verbName] of unfundVerbs.entries()) {
|
|
1600
|
-
const step = index + 1;
|
|
1601
|
-
verbs[verbName] = {
|
|
1602
|
-
from: [
|
|
1603
|
-
`funding_${step}`,
|
|
1604
|
-
...(step < fundingStateCount ? [`abandoning_${step}`] : []),
|
|
1605
|
-
],
|
|
1606
|
-
moneyEvent: frameKey(eventKey),
|
|
1607
|
-
moves: [
|
|
1608
|
-
{
|
|
1609
|
-
key: "transfer",
|
|
1610
|
-
operation: "create",
|
|
1611
|
-
amount: pieces[index].field,
|
|
1612
|
-
from: "escrow",
|
|
1613
|
-
to: params.payer,
|
|
1614
|
-
},
|
|
1615
|
-
],
|
|
1616
|
-
summary: `Return piece ${step} to the ${params.payer.replaceAll("_", " ")} on abandonment`,
|
|
1617
|
-
to: step === 1 ? "abandoned" : `abandoning_${step - 1}`,
|
|
1618
|
-
};
|
|
1619
|
-
}
|
|
1620
|
-
}
|
|
1621
|
-
verbs.abandon = {
|
|
1622
|
-
from: ["created"],
|
|
1623
|
-
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
1624
|
-
summary: "Abandon the settlement before any money is held",
|
|
1625
|
-
to: "abandoned",
|
|
1626
|
-
};
|
|
1627
|
-
verbs.create = {
|
|
1628
|
-
summary: `Create a ${titleize(params.name).toLowerCase()} settlement`,
|
|
1629
|
-
to: "created",
|
|
1630
|
-
};
|
|
1631
|
-
const rules = [
|
|
1632
|
-
{
|
|
1633
|
-
allowedActors: [...params.port.allowed],
|
|
1634
|
-
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"}`,
|
|
1635
|
-
dueDriven: false,
|
|
1636
|
-
enforcement: "tenant_app",
|
|
1637
|
-
gatesEvent: frameKey(`${noun}_release_${pieces[0].releaseTo}`),
|
|
1638
|
-
key: frameKey(`${noun}_${params.port.name}_gate`),
|
|
1639
|
-
kind: "release_condition",
|
|
1640
|
-
label: `${titleize(params.releaseWord)} decided through ${params.port.name}`,
|
|
1641
|
-
tenantTunable: false,
|
|
1642
|
-
},
|
|
1643
|
-
];
|
|
1644
|
-
if (params.deadlineField) {
|
|
1645
|
-
rules.push({
|
|
1646
|
-
allowedActors: [],
|
|
1647
|
-
detail: `A hold nobody decided releases to the ${releaseToWords} on its stored ${params.deadlineField}, exactly once`,
|
|
1648
|
-
dueDriven: true,
|
|
1649
|
-
enforcement: "platform",
|
|
1650
|
-
gatesEvent: null,
|
|
1651
|
-
key: deadlineRuleKey,
|
|
1652
|
-
kind: "deadline",
|
|
1653
|
-
label: `Undecided holds release on ${params.deadlineField}`,
|
|
1654
|
-
tenantTunable: false,
|
|
1655
|
-
});
|
|
1656
|
-
}
|
|
1657
|
-
return {
|
|
1658
|
-
design: [
|
|
1659
|
-
`${noun}: own escrow; ${pieces.length}-piece partition of ${amountName} (${pieces
|
|
1660
|
-
.map((piece) => formatBps(piece.bps))
|
|
1661
|
-
.join(" + ")}); every exit drains every piece; abandonable before funded (created closes directly, funding states unwind piece by piece to the ${params.payer})`,
|
|
1662
|
-
...(params.deadlineField
|
|
1663
|
-
? [
|
|
1664
|
-
`${noun}: undecided holds release to the ${releaseTo} on ${params.deadlineField}; the port and the cancel decide only before that anchor`,
|
|
1665
|
-
]
|
|
1666
|
-
: []),
|
|
1667
|
-
...(params.carveTo
|
|
1668
|
-
? [
|
|
1669
|
-
`${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`,
|
|
1670
|
-
]
|
|
1671
|
-
: []),
|
|
1672
|
-
...(params.payerFeeBps !== undefined
|
|
1673
|
-
? [
|
|
1674
|
-
`${noun}: ${formatBps(params.payerFeeBps)} ${params.payer} service fee on top, straight to platform at funding`,
|
|
1675
|
-
]
|
|
1676
|
-
: []),
|
|
1677
|
-
],
|
|
1678
|
-
feeLines: [],
|
|
1679
|
-
moneyEvents: events,
|
|
1680
|
-
noun: {
|
|
1681
|
-
actors: {
|
|
1682
|
-
[params.payer]: "payer",
|
|
1683
|
-
// The payee stays the beneficiary under a carve, it is their
|
|
1684
|
-
// receivable, while the funder joins as the endpoint the release
|
|
1685
|
-
// pays. Only one beneficiary, so the frame's parties stay unambiguous.
|
|
1686
|
-
[params.payee]: "beneficiary",
|
|
1687
|
-
...(params.carveTo ? { [params.carveTo]: "party" } : {}),
|
|
1688
|
-
platform: "party",
|
|
1689
|
-
},
|
|
1690
|
-
desc: `Held amount from ${params.payer.replaceAll("_", " ")} to ${params.payee.replaceAll("_", " ")}${params.carveTo ? `, released to the ${releaseToWords} against the advance it secures` : ""}`,
|
|
1691
|
-
escrow: true,
|
|
1692
|
-
fields,
|
|
1693
|
-
id: params.name,
|
|
1694
|
-
...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
|
|
1695
|
-
summary: `Escrow-held amount from ${params.payer.replaceAll("_", " ")}`,
|
|
1696
|
-
title: titleize(params.name),
|
|
1697
|
-
verbs,
|
|
1698
|
-
},
|
|
1699
|
-
rules,
|
|
1700
|
-
settlement: {
|
|
1701
|
-
name: params.name,
|
|
1702
|
-
pieces,
|
|
1703
|
-
...(params.payerFeeBps !== undefined
|
|
1704
|
-
? { serviceFee: { bps: params.payerFeeBps, field: "serviceFeeAmount" } }
|
|
1705
|
-
: {}),
|
|
1706
|
-
},
|
|
1707
|
-
};
|
|
1708
|
-
}
|
|
1709
|
-
// ---------------------------------------------------------------------------
|
|
1710
|
-
// instant_transfer: straight-through partitioned payment, no custody
|
|
1711
|
-
function lowerInstantTransfer(settlement) {
|
|
1712
|
-
const payerFee = settlement.fees.find((fee) => fee.bearer === settlement.payer);
|
|
1713
|
-
const payeeFee = settlement.fees.find((fee) => fee.bearer === settlement.payee);
|
|
1714
|
-
const amountName = settlement.amount.name;
|
|
1715
|
-
// Same single-piece law as the held family: a fee-free transfer moves the
|
|
1716
|
-
// amount field itself, so nothing untied to the gross can be admitted.
|
|
1717
|
-
const rawPieces = partitionPieces({
|
|
1718
|
-
amount: settlement.amount,
|
|
1719
|
-
payee: settlement.payee,
|
|
1720
|
-
payeeFeeBps: payeeFee?.bps ?? 0,
|
|
1721
|
-
});
|
|
1722
|
-
const pieces = rawPieces.length === 1
|
|
1723
|
-
? rawPieces.map((piece) => ({ ...piece, field: amountName }))
|
|
1724
|
-
: rawPieces;
|
|
1725
|
-
const noun = settlement.name;
|
|
1726
|
-
const fields = {
|
|
1727
|
-
[amountName]: moneyFieldSpec(pieces.length === 1
|
|
1728
|
-
? `The amount in ${settlement.amount.currency} minor units, paid through whole`
|
|
1729
|
-
: `The gross amount in ${settlement.amount.currency} minor units; the piece fields below partition it exactly`),
|
|
1730
|
-
};
|
|
1731
|
-
for (const [index, piece] of pieces.entries()) {
|
|
1732
|
-
if (piece.field === amountName)
|
|
1733
|
-
continue;
|
|
1734
|
-
fields[piece.field] = moneyFieldSpec(pieceDescription(piece, index, amountName, settlement.amount.currency));
|
|
1735
|
-
}
|
|
1736
|
-
if (payerFee) {
|
|
1737
|
-
fields.serviceFeeAmount = moneyFieldSpec(`${formatBps(payerFee.bps)} of ${amountName}, the ${settlement.payer.replaceAll("_", " ")}-side service fee charged on top; non-refundable`);
|
|
1738
|
-
}
|
|
1739
|
-
const payVerbs = pieces.map((_, index) => `pay_piece_${index + 1}`);
|
|
1740
|
-
if (payerFee)
|
|
1741
|
-
payVerbs.push("collect_service_fee");
|
|
1742
|
-
const payStates = chain(payVerbs, "created", "paid", "paying");
|
|
1743
|
-
const events = [];
|
|
1744
|
-
const verbs = {
|
|
1745
|
-
create: {
|
|
1746
|
-
summary: `Create a ${titleize(noun).toLowerCase()} payment`,
|
|
1747
|
-
to: "created",
|
|
1748
|
-
},
|
|
1749
|
-
};
|
|
1750
|
-
for (const [index, piece] of pieces.entries()) {
|
|
1751
|
-
const eventKey = `${noun}_pay_${index + 1}`;
|
|
1752
|
-
events.push(mintEvent({
|
|
1753
|
-
amount: `${formatBps(piece.bps)} of the ${amountName}`,
|
|
1754
|
-
fromActor: settlement.payer,
|
|
1755
|
-
key: eventKey,
|
|
1756
|
-
kind: "charge",
|
|
1757
|
-
toActor: piece.releaseTo,
|
|
1758
|
-
trigger: `Pay piece ${index + 1} straight to the ${piece.releaseTo.replaceAll("_", " ")}`,
|
|
1759
|
-
}));
|
|
1760
|
-
verbs[payVerbs[index]] = {
|
|
1761
|
-
from: [payStates[index]?.from],
|
|
1762
|
-
moneyEvent: eventKey,
|
|
1763
|
-
moves: [
|
|
1764
|
-
{
|
|
1765
|
-
key: "transfer",
|
|
1766
|
-
operation: "create",
|
|
1767
|
-
amount: piece.field,
|
|
1768
|
-
from: settlement.payer,
|
|
1769
|
-
to: piece.releaseTo,
|
|
1770
|
-
},
|
|
1771
|
-
],
|
|
1772
|
-
summary: `Pay piece ${index + 1} of the amount through`,
|
|
1773
|
-
to: payStates[index]?.to,
|
|
1774
|
-
};
|
|
1775
|
-
}
|
|
1776
|
-
if (payerFee) {
|
|
1777
|
-
const index = payVerbs.length - 1;
|
|
1778
|
-
const eventKey = `${noun}_service_fee`;
|
|
1779
|
-
events.push(mintEvent({
|
|
1780
|
-
amount: `${formatBps(payerFee.bps)} of the ${amountName}, on top`,
|
|
1781
|
-
fromActor: settlement.payer,
|
|
1782
|
-
key: eventKey,
|
|
1783
|
-
kind: "charge",
|
|
1784
|
-
toActor: "platform",
|
|
1785
|
-
trigger: "Collect the service fee with the payment",
|
|
1786
|
-
}));
|
|
1787
|
-
verbs.collect_service_fee = {
|
|
1788
|
-
from: [payStates[index]?.from],
|
|
1789
|
-
moneyEvent: eventKey,
|
|
1790
|
-
moves: [
|
|
1791
|
-
{
|
|
1792
|
-
key: "transfer",
|
|
1793
|
-
operation: "create",
|
|
1794
|
-
amount: "serviceFeeAmount",
|
|
1795
|
-
from: settlement.payer,
|
|
1796
|
-
to: "platform",
|
|
1797
|
-
},
|
|
1798
|
-
],
|
|
1799
|
-
summary: "Collect the payer-side service fee",
|
|
1800
|
-
to: payStates[index]?.to,
|
|
1801
|
-
};
|
|
1802
|
-
}
|
|
1803
|
-
const touchesPlatform = payerFee !== undefined ||
|
|
1804
|
-
pieces.some((piece) => piece.releaseTo === "platform");
|
|
1805
|
-
return {
|
|
1806
|
-
design: [
|
|
1807
|
-
`${noun}: instant pass-through; ${pieces.length}-piece partition of ${amountName} (${pieces
|
|
1808
|
-
.map((piece) => formatBps(piece.bps))
|
|
1809
|
-
.join(" + ")}); no custody`,
|
|
1810
|
-
],
|
|
1811
|
-
feeLines: [
|
|
1812
|
-
...(payerFee
|
|
1813
|
-
? [
|
|
1814
|
-
{
|
|
1815
|
-
label: `${titleize(settlement.payer)} service fee`,
|
|
1816
|
-
on: `each ${noun.replaceAll("_", " ")}`,
|
|
1817
|
-
structure: `${formatBps(payerFee.bps)} of the ${amountName}, on top`,
|
|
1818
|
-
},
|
|
1819
|
-
]
|
|
1820
|
-
: []),
|
|
1821
|
-
...(payeeFee
|
|
1822
|
-
? [
|
|
1823
|
-
{
|
|
1824
|
-
label: `${titleize(settlement.payee)} fee`,
|
|
1825
|
-
on: `each ${noun.replaceAll("_", " ")}`,
|
|
1826
|
-
structure: `${formatBps(payeeFee.bps)} of the ${amountName}, deducted from the payout`,
|
|
1827
|
-
},
|
|
1828
|
-
]
|
|
1829
|
-
: []),
|
|
1830
|
-
],
|
|
1831
|
-
moneyEvents: events,
|
|
1832
|
-
noun: {
|
|
1833
|
-
actors: {
|
|
1834
|
-
[settlement.payer]: "payer",
|
|
1835
|
-
[settlement.payee]: "beneficiary",
|
|
1836
|
-
...(touchesPlatform ? { platform: "party" } : {}),
|
|
1837
|
-
},
|
|
1838
|
-
desc: `Instant transfer: the ${settlement.payer.replaceAll("_", " ")} pays ${amountName} straight through to the ${settlement.payee.replaceAll("_", " ")}, no custody`,
|
|
1839
|
-
fields,
|
|
1840
|
-
id: noun,
|
|
1841
|
-
...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
|
|
1842
|
-
summary: `Instant payment from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
1843
|
-
title: titleize(noun),
|
|
1844
|
-
verbs,
|
|
1845
|
-
},
|
|
1846
|
-
rules: [],
|
|
1847
|
-
settlement: {
|
|
1848
|
-
name: noun,
|
|
1849
|
-
pieces,
|
|
1850
|
-
...(payerFee
|
|
1851
|
-
? { serviceFee: { bps: payerFee.bps, field: "serviceFeeAmount" } }
|
|
1852
|
-
: {}),
|
|
1853
|
-
},
|
|
1854
|
-
};
|
|
1855
|
-
}
|
|
1856
|
-
// ---------------------------------------------------------------------------
|
|
1857
|
-
// deposit: a reservation placed, then claimed or returned
|
|
1858
|
-
function lowerCaptureReservation(settlement, correctionPort, reversalPort, issues) {
|
|
1859
|
-
const noun = settlement.name;
|
|
1860
|
-
const amountName = settlement.amount.name;
|
|
1861
|
-
const reserveRef = "authorize_reservation";
|
|
1862
|
-
const capturedRef = "capturedAmount";
|
|
1863
|
-
const reversalCutoffField = "reversalUntil";
|
|
1864
|
-
const captureVerbs = ["capture", "capture_more"];
|
|
1865
|
-
const verbNames = [
|
|
1866
|
-
"authorize",
|
|
1867
|
-
...captureVerbs,
|
|
1868
|
-
"settle",
|
|
1869
|
-
"void",
|
|
1870
|
-
"expire",
|
|
1871
|
-
"settle_on_expiry",
|
|
1872
|
-
settlement.correction.port,
|
|
1873
|
-
settlement.externalReversal.port,
|
|
1874
|
-
];
|
|
1875
|
-
if (!verbNameIssues(noun, verbNames, settlement.origin, issues)) {
|
|
1876
|
-
return undefined;
|
|
1877
|
-
}
|
|
1878
|
-
const reserveEventKey = frameKey(`${noun}_reserve`);
|
|
1879
|
-
const captureEventKey = frameKey(`${noun}_capture`);
|
|
1880
|
-
const correctionEventKey = frameKey(`${noun}_correction`);
|
|
1881
|
-
const reversalEventKey = frameKey(`${noun}_external_reversal`);
|
|
1882
|
-
const expiryRuleKey = frameKey(`${noun}_reservation_expiry`);
|
|
1883
|
-
const captureMove = (partialOnly) => ({
|
|
1884
|
-
amount: "captureAmount",
|
|
1885
|
-
capture: { [capturedRef]: "postedAmount" },
|
|
1886
|
-
key: "post",
|
|
1887
|
-
operation: "post",
|
|
1888
|
-
...(partialOnly ? { partialOnly: true } : {}),
|
|
1889
|
-
reservation: reserveRef,
|
|
1890
|
-
});
|
|
1891
|
-
const reverseMove = () => ({
|
|
1892
|
-
amount: `refs.${capturedRef}`,
|
|
1893
|
-
clawbackOf: reserveRef,
|
|
1894
|
-
from: settlement.payee,
|
|
1895
|
-
key: "transfer",
|
|
1896
|
-
operation: "create",
|
|
1897
|
-
to: settlement.payer,
|
|
1898
|
-
});
|
|
1899
|
-
const verbs = {
|
|
1900
|
-
create: {
|
|
1901
|
-
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
1902
|
-
to: "created",
|
|
1903
|
-
},
|
|
1904
|
-
authorize: {
|
|
1905
|
-
from: ["created"],
|
|
1906
|
-
moneyEvent: reserveEventKey,
|
|
1907
|
-
moves: [
|
|
1908
|
-
{
|
|
1909
|
-
amount: amountName,
|
|
1910
|
-
from: settlement.payer,
|
|
1911
|
-
key: "reservation",
|
|
1912
|
-
operation: "reserve",
|
|
1913
|
-
to: settlement.payee,
|
|
1914
|
-
},
|
|
1915
|
-
],
|
|
1916
|
-
summary: `Reserve the ${amountName} until ${settlement.reserveUntilField}`,
|
|
1917
|
-
to: "authorized",
|
|
1918
|
-
},
|
|
1919
|
-
capture: {
|
|
1920
|
-
deadline: { field: settlement.reserveUntilField },
|
|
1921
|
-
from: ["authorized"],
|
|
1922
|
-
moneyEvent: captureEventKey,
|
|
1923
|
-
moves: [captureMove(true)],
|
|
1924
|
-
summary: "Post one strict partial capture slice",
|
|
1925
|
-
to: "partially_captured",
|
|
1926
|
-
},
|
|
1927
|
-
capture_more: {
|
|
1928
|
-
deadline: { field: settlement.reserveUntilField },
|
|
1929
|
-
from: ["partially_captured"],
|
|
1930
|
-
moneyEvent: captureEventKey,
|
|
1931
|
-
moves: [captureMove(true)],
|
|
1932
|
-
summary: "Post another strict partial capture slice",
|
|
1933
|
-
to: "partially_captured",
|
|
1934
|
-
},
|
|
1935
|
-
settle: {
|
|
1936
|
-
deadline: { field: settlement.reserveUntilField },
|
|
1937
|
-
from: ["authorized", "partially_captured"],
|
|
1938
|
-
moneyEvent: captureEventKey,
|
|
1939
|
-
moves: [
|
|
1940
|
-
{
|
|
1941
|
-
capture: { [capturedRef]: "postedAmount" },
|
|
1942
|
-
key: "post",
|
|
1943
|
-
operation: "post",
|
|
1944
|
-
reservation: reserveRef,
|
|
1945
|
-
},
|
|
1946
|
-
],
|
|
1947
|
-
summary: "Post the full reserved remainder and settle",
|
|
1948
|
-
setsAt: {
|
|
1949
|
-
field: reversalCutoffField,
|
|
1950
|
-
offset: settlement.externalReversal.window.raw,
|
|
1951
|
-
},
|
|
1952
|
-
to: "settled",
|
|
1953
|
-
},
|
|
1954
|
-
void: {
|
|
1955
|
-
from: ["authorized"],
|
|
1956
|
-
moves: [
|
|
1957
|
-
{
|
|
1958
|
-
key: "void",
|
|
1959
|
-
operation: "void",
|
|
1960
|
-
reason: "Reservation voided before any capture",
|
|
1961
|
-
reservation: reserveRef,
|
|
1962
|
-
},
|
|
1963
|
-
],
|
|
1964
|
-
summary: "Release an entirely uncaptured reservation",
|
|
1965
|
-
to: "voided",
|
|
1966
|
-
},
|
|
1967
|
-
expire: {
|
|
1968
|
-
due: { field: settlement.reserveUntilField, rule: expiryRuleKey },
|
|
1969
|
-
from: ["authorized"],
|
|
1970
|
-
moves: [
|
|
1971
|
-
{
|
|
1972
|
-
key: "void",
|
|
1973
|
-
operation: "void",
|
|
1974
|
-
reason: "Uncaptured reservation expired",
|
|
1975
|
-
reservation: reserveRef,
|
|
1976
|
-
},
|
|
1977
|
-
],
|
|
1978
|
-
summary: "Release an uncaptured reservation at expiry",
|
|
1979
|
-
to: "expired",
|
|
1980
|
-
},
|
|
1981
|
-
settle_on_expiry: {
|
|
1982
|
-
due: { field: settlement.reserveUntilField, rule: expiryRuleKey },
|
|
1983
|
-
from: ["partially_captured"],
|
|
1984
|
-
moves: [
|
|
1985
|
-
{
|
|
1986
|
-
key: "void",
|
|
1987
|
-
operation: "void",
|
|
1988
|
-
reason: "Uncaptured remainder released at expiry",
|
|
1989
|
-
reservation: reserveRef,
|
|
1990
|
-
},
|
|
1991
|
-
],
|
|
1992
|
-
summary: "Release the uncaptured remainder and settle captured slices",
|
|
1993
|
-
setsAt: {
|
|
1994
|
-
field: reversalCutoffField,
|
|
1995
|
-
offset: settlement.externalReversal.window.raw,
|
|
1996
|
-
},
|
|
1997
|
-
to: "settled",
|
|
1998
|
-
},
|
|
1999
|
-
[settlement.correction.port]: {
|
|
2000
|
-
from: ["settled"],
|
|
2001
|
-
moneyEvent: correctionEventKey,
|
|
2002
|
-
moves: [reverseMove()],
|
|
2003
|
-
port: { allowed: correctionPort.allowed },
|
|
2004
|
-
summary: "Return the full captured amount on payee correction",
|
|
2005
|
-
to: "corrected",
|
|
2006
|
-
},
|
|
2007
|
-
[settlement.externalReversal.port]: {
|
|
2008
|
-
captureInput: { externalReference: "externalReference" },
|
|
2009
|
-
deadline: { field: reversalCutoffField },
|
|
2010
|
-
from: ["settled"],
|
|
2011
|
-
moneyEvent: reversalEventKey,
|
|
2012
|
-
moves: [reverseMove()],
|
|
2013
|
-
port: {
|
|
2014
|
-
allowed: reversalPort.allowed,
|
|
2015
|
-
fields: { externalReference: "text" },
|
|
2016
|
-
},
|
|
2017
|
-
summary: "Return the full captured amount on an external reversal",
|
|
2018
|
-
to: "reversed",
|
|
2019
|
-
},
|
|
2020
|
-
};
|
|
2021
|
-
const events = [
|
|
2022
|
-
mintEvent({
|
|
2023
|
-
amount: `The full ${amountName}`,
|
|
2024
|
-
fromActor: settlement.payer,
|
|
2025
|
-
key: reserveEventKey,
|
|
2026
|
-
kind: "hold",
|
|
2027
|
-
toActor: settlement.payee,
|
|
2028
|
-
trigger: `Reserve ${amountName} until ${settlement.reserveUntilField}`,
|
|
2029
|
-
}),
|
|
2030
|
-
mintEvent({
|
|
2031
|
-
amount: `Each posted slice, never more than the remaining ${amountName}`,
|
|
2032
|
-
amountDependency: {
|
|
2033
|
-
kind: "bounded_by_reference",
|
|
2034
|
-
reference: reserveEventKey,
|
|
2035
|
-
},
|
|
2036
|
-
fromActor: settlement.payer,
|
|
2037
|
-
key: captureEventKey,
|
|
2038
|
-
kind: "payout",
|
|
2039
|
-
occurrence: "repeatable",
|
|
2040
|
-
toActor: settlement.payee,
|
|
2041
|
-
trigger: "Post a capture slice or the final remainder",
|
|
2042
|
-
}),
|
|
2043
|
-
mintEvent({
|
|
2044
|
-
amount: "100% of the cumulative captured amount",
|
|
2045
|
-
amountDependency: {
|
|
2046
|
-
bps: 10_000,
|
|
2047
|
-
kind: "percent_of_reference",
|
|
2048
|
-
reference: captureEventKey,
|
|
2049
|
-
},
|
|
2050
|
-
fromActor: settlement.payee,
|
|
2051
|
-
key: correctionEventKey,
|
|
2052
|
-
kind: "refund",
|
|
2053
|
-
toActor: settlement.payer,
|
|
2054
|
-
trigger: "Apply one full payee correction",
|
|
2055
|
-
}),
|
|
2056
|
-
mintEvent({
|
|
2057
|
-
amount: "100% of the cumulative captured amount",
|
|
2058
|
-
amountDependency: {
|
|
2059
|
-
bps: 10_000,
|
|
2060
|
-
kind: "percent_of_reference",
|
|
2061
|
-
reference: captureEventKey,
|
|
2062
|
-
},
|
|
2063
|
-
fromActor: settlement.payee,
|
|
2064
|
-
key: reversalEventKey,
|
|
2065
|
-
kind: "refund",
|
|
2066
|
-
toActor: settlement.payer,
|
|
2067
|
-
trigger: "Apply one full externally decided reversal",
|
|
2068
|
-
}),
|
|
2069
|
-
];
|
|
2070
|
-
return {
|
|
2071
|
-
design: [
|
|
2072
|
-
`${noun}: reserve ${amountName} until ${settlement.reserveUntilField}; capture in strict partial slices; post the remainder to settle; expiry releases only the uncaptured remainder`,
|
|
2073
|
-
`${noun}: correction and external reversal each return the full captured amount once; insufficient payee funds reject the move instead of creating a negative position`,
|
|
2074
|
-
],
|
|
2075
|
-
feeLines: [],
|
|
2076
|
-
moneyEvents: events,
|
|
2077
|
-
noun: {
|
|
2078
|
-
actors: {
|
|
2079
|
-
[settlement.payer]: "payer",
|
|
2080
|
-
[settlement.payee]: "beneficiary",
|
|
2081
|
-
},
|
|
2082
|
-
desc: `Payer reservation captured by the payee in slices within a fixed window`,
|
|
2083
|
-
fields: {
|
|
2084
|
-
[amountName]: moneyFieldSpec(`Maximum captured amount in ${settlement.amount.currency} minor units`),
|
|
2085
|
-
[settlement.reserveUntilField]: dateFieldSpec("Reservation expiry that releases any uncaptured remainder"),
|
|
2086
|
-
[reversalCutoffField]: {
|
|
2087
|
-
desc: "Machine-owned external reversal cutoff anchored when settlement completes",
|
|
2088
|
-
type: "date?",
|
|
2089
|
-
},
|
|
2090
|
-
},
|
|
2091
|
-
id: noun,
|
|
2092
|
-
summary: `Capture reservation from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
2093
|
-
title: titleize(noun),
|
|
2094
|
-
verbs,
|
|
2095
|
-
},
|
|
2096
|
-
rules: [
|
|
2097
|
-
{
|
|
2098
|
-
allowedActors: [],
|
|
2099
|
-
detail: `At ${settlement.reserveUntilField}, the platform releases the uncaptured remainder and preserves any posted slices`,
|
|
2100
|
-
dueDriven: true,
|
|
2101
|
-
enforcement: "platform",
|
|
2102
|
-
gatesEvent: null,
|
|
2103
|
-
key: expiryRuleKey,
|
|
2104
|
-
kind: "deadline",
|
|
2105
|
-
label: `Uncaptured remainder releases on ${settlement.reserveUntilField}`,
|
|
2106
|
-
tenantTunable: false,
|
|
2107
|
-
},
|
|
2108
|
-
{
|
|
2109
|
-
allowedActors: [...correctionPort.allowed],
|
|
2110
|
-
detail: "The payee may return the full captured amount once",
|
|
2111
|
-
dueDriven: false,
|
|
2112
|
-
enforcement: "tenant_app",
|
|
2113
|
-
gatesEvent: correctionEventKey,
|
|
2114
|
-
key: frameKey(`${noun}_${settlement.correction.port}_gate`),
|
|
2115
|
-
kind: "release_condition",
|
|
2116
|
-
label: "Full correction confirmed through the tenant backend",
|
|
2117
|
-
tenantTunable: false,
|
|
2118
|
-
},
|
|
2119
|
-
{
|
|
2120
|
-
allowedActors: [...reversalPort.allowed],
|
|
2121
|
-
detail: `A confirmed external decision may reverse the full captured amount within ${settlement.externalReversal.window.raw}; timeout moves nothing`,
|
|
2122
|
-
dueDriven: false,
|
|
2123
|
-
enforcement: "tenant_app",
|
|
2124
|
-
gatesEvent: reversalEventKey,
|
|
2125
|
-
key: frameKey(`${noun}_${settlement.externalReversal.port}_gate`),
|
|
2126
|
-
kind: "release_condition",
|
|
2127
|
-
label: "External reversal confirmed through the tenant backend",
|
|
2128
|
-
tenantTunable: false,
|
|
2129
|
-
},
|
|
2130
|
-
],
|
|
2131
|
-
settlement: { name: noun, pieces: [] },
|
|
2132
|
-
};
|
|
2133
|
-
}
|
|
2134
|
-
// ---------------------------------------------------------------------------
|
|
2135
|
-
// settlement_batch: immutable close, signed lineage sum, one payout
|
|
2136
|
-
function lowerSettlementBatch(settlement, acknowledgementPort, issues) {
|
|
2137
|
-
const noun = settlement.name;
|
|
2138
|
-
const captureEntry = `${noun}_capture_entry`;
|
|
2139
|
-
const creditAdjustment = `${noun}_credit_adjustment`;
|
|
2140
|
-
const debitAdjustment = `${noun}_debit_adjustment`;
|
|
2141
|
-
const batchIdField = `${noun.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase())}Id`;
|
|
2142
|
-
const payoutEventKey = frameKey(`${noun}_payout`);
|
|
2143
|
-
const closeRuleKey = frameKey(`${noun}_close`);
|
|
2144
|
-
if (!verbNameIssues(noun, [
|
|
2145
|
-
"close",
|
|
2146
|
-
"calculate",
|
|
2147
|
-
"approve",
|
|
2148
|
-
"instruct",
|
|
2149
|
-
"reconcile",
|
|
2150
|
-
settlement.payoutAcknowledgement.port,
|
|
2151
|
-
], settlement.origin, issues)) {
|
|
2152
|
-
return undefined;
|
|
2153
|
-
}
|
|
2154
|
-
const parentRequirement = {
|
|
2155
|
-
[batchIdField]: {
|
|
2156
|
-
match: { "fields.currency": "fields.currency" },
|
|
2157
|
-
statuses: ["open"],
|
|
2158
|
-
},
|
|
2159
|
-
};
|
|
2160
|
-
const captureNoun = {
|
|
2161
|
-
desc: "One gross capture entry linked to an open payout batch",
|
|
2162
|
-
fields: {
|
|
2163
|
-
amount: moneyFieldSpec("Gross captured amount in minor units"),
|
|
2164
|
-
currency: {
|
|
2165
|
-
desc: "ISO 4217 currency shared with the payout batch",
|
|
2166
|
-
type: "currency",
|
|
2167
|
-
},
|
|
2168
|
-
[batchIdField]: {
|
|
2169
|
-
desc: "Open batch this capture entry accrues into",
|
|
2170
|
-
type: `ref:${noun}`,
|
|
2171
|
-
},
|
|
2172
|
-
[settlement.sourceCaptureReferenceField]: {
|
|
2173
|
-
desc: "Immutable source capture reference",
|
|
2174
|
-
type: "text",
|
|
2175
|
-
},
|
|
2176
|
-
},
|
|
2177
|
-
id: captureEntry,
|
|
2178
|
-
summary: "Gross capture lineage entry",
|
|
2179
|
-
title: `${titleize(noun)} Capture Entry`,
|
|
2180
|
-
verbs: {
|
|
2181
|
-
create: {
|
|
2182
|
-
requires: parentRequirement,
|
|
2183
|
-
summary: "Create a capture lineage entry on an open batch",
|
|
2184
|
-
to: "created",
|
|
2185
|
-
},
|
|
2186
|
-
accrue: {
|
|
2187
|
-
from: ["created"],
|
|
2188
|
-
requires: parentRequirement,
|
|
2189
|
-
summary: "Accrue the capture entry into the open batch",
|
|
2190
|
-
to: "accrued",
|
|
2191
|
-
},
|
|
2192
|
-
},
|
|
2193
|
-
};
|
|
2194
|
-
const adjustmentNoun = (id, direction) => ({
|
|
2195
|
-
desc: `One ${direction} adjustment linked to an open payout batch; closed batches stay unchanged`,
|
|
2196
|
-
fields: {
|
|
2197
|
-
amount: moneyFieldSpec(`${titleize(direction)} adjustment amount in minor units`),
|
|
2198
|
-
currency: {
|
|
2199
|
-
desc: "ISO 4217 currency shared with the payout batch",
|
|
2200
|
-
type: "currency",
|
|
2201
|
-
},
|
|
2202
|
-
adjustmentReference: {
|
|
2203
|
-
desc: "Immutable explicit adjustment reference",
|
|
2204
|
-
type: "text",
|
|
2205
|
-
},
|
|
2206
|
-
[batchIdField]: {
|
|
2207
|
-
desc: "Open batch this adjustment applies to",
|
|
2208
|
-
type: `ref:${noun}`,
|
|
2209
|
-
},
|
|
2210
|
-
[settlement.externalReversalReferenceField]: {
|
|
2211
|
-
desc: "Optional externally decided reversal reference",
|
|
2212
|
-
type: "text?",
|
|
2213
|
-
},
|
|
2214
|
-
[settlement.feeReferenceField]: {
|
|
2215
|
-
desc: "Optional fee entry reference",
|
|
2216
|
-
type: "text?",
|
|
2217
|
-
},
|
|
2218
|
-
[settlement.sourceCaptureReferenceField]: {
|
|
2219
|
-
desc: "Original capture reference that this adjustment corrects",
|
|
2220
|
-
type: "text",
|
|
2221
|
-
},
|
|
2222
|
-
},
|
|
2223
|
-
id,
|
|
2224
|
-
summary: `${titleize(direction)} adjustment with capture lineage`,
|
|
2225
|
-
title: `${titleize(noun)} ${titleize(direction)} Adjustment`,
|
|
2226
|
-
verbs: {
|
|
2227
|
-
create: {
|
|
2228
|
-
requires: parentRequirement,
|
|
2229
|
-
summary: `Create a ${direction} adjustment on an open batch`,
|
|
2230
|
-
to: "created",
|
|
2231
|
-
},
|
|
2232
|
-
adjust: {
|
|
2233
|
-
from: ["created"],
|
|
2234
|
-
requires: parentRequirement,
|
|
2235
|
-
summary: `Apply the ${direction} adjustment to the open batch`,
|
|
2236
|
-
to: "applied",
|
|
2237
|
-
},
|
|
2238
|
-
correct: {
|
|
2239
|
-
from: ["created"],
|
|
2240
|
-
requires: parentRequirement,
|
|
2241
|
-
summary: "Record a later correction on this open batch instead of changing the closed source batch",
|
|
2242
|
-
to: "applied",
|
|
2243
|
-
},
|
|
2244
|
-
},
|
|
2245
|
-
});
|
|
2246
|
-
const verbs = {
|
|
2247
|
-
create: {
|
|
2248
|
-
summary: `Open a ${titleize(noun).toLowerCase()}`,
|
|
2249
|
-
to: "open",
|
|
2250
|
-
},
|
|
2251
|
-
close: {
|
|
2252
|
-
due: { field: settlement.closeTriggerField, rule: closeRuleKey },
|
|
2253
|
-
from: ["open"],
|
|
2254
|
-
summary: "Freeze the batch and stop all new entries",
|
|
2255
|
-
to: "closed",
|
|
2256
|
-
},
|
|
2257
|
-
calculate: {
|
|
2258
|
-
from: ["closed"],
|
|
2259
|
-
signedSum: {
|
|
2260
|
-
amountRef: "netPayable",
|
|
2261
|
-
onNegative: "refuse",
|
|
2262
|
-
onZero: "refuse",
|
|
2263
|
-
sources: [
|
|
2264
|
-
{
|
|
2265
|
-
amountField: "amount",
|
|
2266
|
-
nounId: captureEntry,
|
|
2267
|
-
refField: batchIdField,
|
|
2268
|
-
sign: "add",
|
|
2269
|
-
statuses: ["accrued"],
|
|
2270
|
-
subtotalRef: "grossCaptureAmount",
|
|
2271
|
-
},
|
|
2272
|
-
{
|
|
2273
|
-
amountField: "amount",
|
|
2274
|
-
nounId: creditAdjustment,
|
|
2275
|
-
refField: batchIdField,
|
|
2276
|
-
sign: "add",
|
|
2277
|
-
statuses: ["applied"],
|
|
2278
|
-
subtotalRef: "creditAdjustmentAmount",
|
|
2279
|
-
},
|
|
2280
|
-
{
|
|
2281
|
-
amountField: "amount",
|
|
2282
|
-
nounId: debitAdjustment,
|
|
2283
|
-
refField: batchIdField,
|
|
2284
|
-
sign: "subtract",
|
|
2285
|
-
statuses: ["applied"],
|
|
2286
|
-
subtotalRef: "debitAdjustmentAmount",
|
|
2287
|
-
},
|
|
2288
|
-
],
|
|
2289
|
-
},
|
|
2290
|
-
summary: "Prove and freeze the one signed net payable amount",
|
|
2291
|
-
to: "calculated",
|
|
2292
|
-
},
|
|
2293
|
-
approve: {
|
|
2294
|
-
from: ["calculated"],
|
|
2295
|
-
summary: "Approve the frozen payable without recomputing it",
|
|
2296
|
-
to: "approved",
|
|
2297
|
-
},
|
|
2298
|
-
instruct: {
|
|
2299
|
-
from: ["approved"],
|
|
2300
|
-
moneyEvent: payoutEventKey,
|
|
2301
|
-
payout: {
|
|
2302
|
-
amount: "refs.netPayable",
|
|
2303
|
-
beneficiaryField: settlement.payoutBeneficiaryReferenceField,
|
|
2304
|
-
beneficiaryPartyField: `${camelize(settlement.payoutDestination)}AccountId`,
|
|
2305
|
-
capture: "payoutId",
|
|
2306
|
-
currencyField: "currency",
|
|
2307
|
-
sourceAccountField: `${camelize(settlement.settlementAccount)}AccountId`,
|
|
2308
|
-
speed: "standard",
|
|
2309
|
-
},
|
|
2310
|
-
summary: "Create one idempotent payout from the frozen net payable",
|
|
2311
|
-
to: "instructed",
|
|
2312
|
-
},
|
|
2313
|
-
[settlement.payoutAcknowledgement.port]: {
|
|
2314
|
-
captureInput: {
|
|
2315
|
-
acknowledgementReference: "acknowledgementReference",
|
|
2316
|
-
},
|
|
2317
|
-
from: ["instructed"],
|
|
2318
|
-
port: {
|
|
2319
|
-
allowed: acknowledgementPort.allowed,
|
|
2320
|
-
fields: { acknowledgementReference: "text" },
|
|
2321
|
-
},
|
|
2322
|
-
summary: "Record the tenant's payout acknowledgement in the receipt",
|
|
2323
|
-
to: "acknowledged",
|
|
2324
|
-
},
|
|
2325
|
-
reconcile: {
|
|
2326
|
-
from: ["instructed", "acknowledged"],
|
|
2327
|
-
requiresSettlement: {
|
|
2328
|
-
capture: "settlementEvidenceId",
|
|
2329
|
-
payoutRef: "payoutId",
|
|
2330
|
-
},
|
|
2331
|
-
summary: "Record durable evidence that the payout settled",
|
|
2332
|
-
to: "reconciled",
|
|
2333
|
-
},
|
|
2334
|
-
};
|
|
2335
|
-
return {
|
|
2336
|
-
design: [
|
|
2337
|
-
`${noun}: capture entries plus signed adjustments freeze at ${settlement.closeTriggerField}; calculate persists gross, credit, debit, and net refs; negative or zero net refuses`,
|
|
2338
|
-
`${noun}: instruct creates one payout intent for the frozen refs.netPayable; only matched settlement evidence can reconcile it`,
|
|
2339
|
-
],
|
|
2340
|
-
extraNouns: [
|
|
2341
|
-
captureNoun,
|
|
2342
|
-
adjustmentNoun(creditAdjustment, "credit"),
|
|
2343
|
-
adjustmentNoun(debitAdjustment, "debit"),
|
|
2344
|
-
],
|
|
2345
|
-
feeLines: [],
|
|
2346
|
-
moneyEvents: [
|
|
2347
|
-
mintEvent({
|
|
2348
|
-
amount: "The frozen signed sum of gross capture entries plus credit adjustments minus debit adjustments",
|
|
2349
|
-
fromActor: settlement.settlementAccount,
|
|
2350
|
-
key: payoutEventKey,
|
|
2351
|
-
kind: "payout",
|
|
2352
|
-
toActor: settlement.payoutDestination,
|
|
2353
|
-
trigger: "Instruct the approved batch payout exactly once",
|
|
2354
|
-
}),
|
|
2355
|
-
],
|
|
2356
|
-
noun: {
|
|
2357
|
-
actors: {
|
|
2358
|
-
[settlement.payoutDestination]: "beneficiary",
|
|
2359
|
-
[settlement.settlementAccount]: "payer",
|
|
2360
|
-
},
|
|
2361
|
-
desc: "Immutable batch of capture lineage and signed adjustments that creates one payout",
|
|
2362
|
-
fields: {
|
|
2363
|
-
[settlement.closeTriggerField]: dateFieldSpec("Date the open batch freezes against later entries"),
|
|
2364
|
-
currency: {
|
|
2365
|
-
desc: "ISO 4217 currency shared by the batch and payout instruction",
|
|
2366
|
-
type: "currency",
|
|
2367
|
-
},
|
|
2368
|
-
[settlement.payoutBeneficiaryReferenceField]: {
|
|
2369
|
-
desc: "Beneficiary ID for the payout instruction",
|
|
2370
|
-
type: "beneficiary",
|
|
2371
|
-
},
|
|
2372
|
-
},
|
|
2373
|
-
id: noun,
|
|
2374
|
-
summary: `Payout batch from ${settlement.settlementAccount.replaceAll("_", " ")} to ${settlement.payoutDestination.replaceAll("_", " ")}`,
|
|
2375
|
-
title: titleize(noun),
|
|
2376
|
-
verbs,
|
|
2377
|
-
},
|
|
2378
|
-
rules: [
|
|
2379
|
-
{
|
|
2380
|
-
allowedActors: [],
|
|
2381
|
-
detail: `At ${settlement.closeTriggerField}, the platform closes the batch and every child reference gate refuses later entries`,
|
|
2382
|
-
dueDriven: true,
|
|
2383
|
-
enforcement: "platform",
|
|
2384
|
-
gatesEvent: null,
|
|
2385
|
-
key: closeRuleKey,
|
|
2386
|
-
kind: "deadline",
|
|
2387
|
-
label: `Batch freezes on ${settlement.closeTriggerField}`,
|
|
2388
|
-
tenantTunable: false,
|
|
2389
|
-
},
|
|
2390
|
-
],
|
|
2391
|
-
settlement: { name: noun, pieces: [] },
|
|
2392
|
-
};
|
|
2393
|
-
}
|
|
2394
|
-
function lowerDeposit(settlement, claim, giveBack, issues) {
|
|
2395
|
-
const noun = settlement.name;
|
|
2396
|
-
const amountName = settlement.amount.name;
|
|
2397
|
-
if (!verbNameIssues(noun, ["place_deposit", claim.name, giveBack.name], settlement.origin, issues)) {
|
|
2398
|
-
return undefined;
|
|
2399
|
-
}
|
|
2400
|
-
const eventKey = `${noun}_hold_1`;
|
|
2401
|
-
const events = [
|
|
2402
|
-
mintEvent({
|
|
2403
|
-
amount: `The full ${amountName}`,
|
|
2404
|
-
fromActor: settlement.payer,
|
|
2405
|
-
key: eventKey,
|
|
2406
|
-
kind: "hold",
|
|
2407
|
-
toActor: settlement.holder,
|
|
2408
|
-
trigger: `Reserve the ${amountName} in the ${settlement.holder.replaceAll("_", " ")}'s favor`,
|
|
2409
|
-
}),
|
|
2410
|
-
];
|
|
2411
|
-
const verbs = {
|
|
2412
|
-
create: {
|
|
2413
|
-
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
2414
|
-
to: "created",
|
|
2415
|
-
},
|
|
2416
|
-
place_deposit: {
|
|
2417
|
-
from: ["created"],
|
|
2418
|
-
moves: [
|
|
2419
|
-
{
|
|
2420
|
-
key: "reservation",
|
|
2421
|
-
operation: "reserve",
|
|
2422
|
-
amount: amountName,
|
|
2423
|
-
from: settlement.payer,
|
|
2424
|
-
to: settlement.holder,
|
|
2425
|
-
},
|
|
2426
|
-
],
|
|
2427
|
-
moneyEvent: eventKey,
|
|
2428
|
-
summary: `Reserve the ${amountName} against the ${settlement.payer.replaceAll("_", " ")}'s account`,
|
|
2429
|
-
to: "held",
|
|
2430
|
-
},
|
|
2431
|
-
[claim.name]: {
|
|
2432
|
-
from: ["held"],
|
|
2433
|
-
moves: [
|
|
2434
|
-
{
|
|
2435
|
-
key: "post",
|
|
2436
|
-
operation: "post",
|
|
2437
|
-
reservation: "place_deposit_reservation",
|
|
2438
|
-
},
|
|
2439
|
-
],
|
|
2440
|
-
summary: `Claim the deposit for the ${settlement.holder.replaceAll("_", " ")} through ${claim.name}`,
|
|
2441
|
-
to: "claimed",
|
|
2442
|
-
},
|
|
2443
|
-
[giveBack.name]: {
|
|
2444
|
-
from: ["held"],
|
|
2445
|
-
summary: `Return the deposit to the ${settlement.payer.replaceAll("_", " ")} through ${giveBack.name}`,
|
|
2446
|
-
to: "returned",
|
|
2447
|
-
moves: [
|
|
2448
|
-
{
|
|
2449
|
-
key: "void",
|
|
2450
|
-
operation: "void",
|
|
2451
|
-
reason: "Deposit returned in full",
|
|
2452
|
-
reservation: "place_deposit_reservation",
|
|
2453
|
-
},
|
|
2454
|
-
],
|
|
2455
|
-
},
|
|
2456
|
-
};
|
|
2457
|
-
const portRule = (port, verbLabel) => ({
|
|
2458
|
-
allowedActors: [...port.allowed],
|
|
2459
|
-
detail: `${port.allowed.map(titleize).join(" or ")} decides through the tenant backend`,
|
|
2460
|
-
dueDriven: false,
|
|
2461
|
-
enforcement: "tenant_app",
|
|
2462
|
-
gatesEvent: null,
|
|
2463
|
-
key: frameKey(`${noun}_${port.name}_gate`),
|
|
2464
|
-
kind: "release_condition",
|
|
2465
|
-
label: `${verbLabel} decided through ${port.name}`,
|
|
2466
|
-
tenantTunable: false,
|
|
2467
|
-
});
|
|
2468
|
-
return {
|
|
2469
|
-
design: [
|
|
2470
|
-
`${noun}: ${amountName} held as a reservation on the ${settlement.payer.replaceAll("_", " ")}'s account; claimed whole through ${claim.name} or returned whole through ${giveBack.name}`,
|
|
2471
|
-
],
|
|
2472
|
-
feeLines: [],
|
|
2473
|
-
moneyEvents: events,
|
|
2474
|
-
noun: {
|
|
2475
|
-
actors: {
|
|
2476
|
-
[settlement.payer]: "payer",
|
|
2477
|
-
[settlement.holder]: "beneficiary",
|
|
2478
|
-
},
|
|
2479
|
-
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`,
|
|
2480
|
-
fields: {
|
|
2481
|
-
[amountName]: moneyFieldSpec(`The deposit amount in ${settlement.amount.currency} minor units, reserved in full and fully accounted on claim or return`),
|
|
2482
|
-
},
|
|
2483
|
-
id: noun,
|
|
2484
|
-
summary: `Refundable deposit from ${settlement.payer.replaceAll("_", " ")} held for ${settlement.holder.replaceAll("_", " ")}`,
|
|
2485
|
-
title: titleize(noun),
|
|
2486
|
-
verbs,
|
|
2487
|
-
},
|
|
2488
|
-
rules: [portRule(claim, "Claim"), portRule(giveBack, "Return")],
|
|
2489
|
-
settlement: { name: noun, pieces: [] },
|
|
2490
|
-
};
|
|
2491
|
-
}
|
|
2492
|
-
// ---------------------------------------------------------------------------
|
|
2493
|
-
// scheduled and advance: finite due-driven anchors
|
|
2494
|
-
/** Equal N-way piece widths in bps; the first anchor absorbs the remainder. */
|
|
2495
|
-
function evenPieceBps(count) {
|
|
2496
|
-
const base = Math.floor(Number(TOTAL_BPS) / count);
|
|
2497
|
-
const widths = Array.from({ length: count }, () => base);
|
|
2498
|
-
widths[0] = Number(TOTAL_BPS) - base * (count - 1);
|
|
2499
|
-
return widths;
|
|
2500
|
-
}
|
|
2501
|
-
// ---------------------------------------------------------------------------
|
|
2502
|
-
// funding_round: aggregate commitments with threshold close and whole unwind
|
|
2503
|
-
function lowerFundingRound(settlement) {
|
|
2504
|
-
const noun = settlement.name;
|
|
2505
|
-
const child = `${noun}_commitment`;
|
|
2506
|
-
const parentRef = `${camelize(noun)}Id`;
|
|
2507
|
-
const commitEvent = frameKey(`${noun}_commit`);
|
|
2508
|
-
const cancelEvent = frameKey(`${noun}_cancel`);
|
|
2509
|
-
const collectEvent = frameKey(`${noun}_collect`);
|
|
2510
|
-
const refundEvent = frameKey(`${noun}_refund`);
|
|
2511
|
-
const closeRule = frameKey(`${noun}_close`);
|
|
2512
|
-
const aggregate = (kind) => [
|
|
2513
|
-
{
|
|
2514
|
-
check: {
|
|
2515
|
-
amountField: "amount",
|
|
2516
|
-
kind,
|
|
2517
|
-
targetField: settlement.target.name,
|
|
2518
|
-
},
|
|
2519
|
-
nounId: child,
|
|
2520
|
-
over: "children",
|
|
2521
|
-
refField: parentRef,
|
|
2522
|
-
statuses: ["committed"],
|
|
2523
|
-
},
|
|
2524
|
-
];
|
|
2525
|
-
const parentRequirement = (statuses) => ({
|
|
2526
|
-
[parentRef]: {
|
|
2527
|
-
bind: {
|
|
2528
|
-
currency: "fields.currency",
|
|
2529
|
-
[`${camelize(settlement.beneficiary)}AccountId`]: `fields.${camelize(settlement.beneficiary)}AccountId`,
|
|
2530
|
-
},
|
|
2531
|
-
statuses,
|
|
2532
|
-
},
|
|
2533
|
-
});
|
|
2534
|
-
const transitionRequirement = (statuses) => ({
|
|
2535
|
-
[parentRef]: {
|
|
2536
|
-
match: {
|
|
2537
|
-
"fields.currency": "fields.currency",
|
|
2538
|
-
[`fields.${camelize(settlement.beneficiary)}AccountId`]: `fields.${camelize(settlement.beneficiary)}AccountId`,
|
|
2539
|
-
},
|
|
2540
|
-
statuses,
|
|
2541
|
-
},
|
|
2542
|
-
});
|
|
2543
|
-
return {
|
|
2544
|
-
design: [
|
|
2545
|
-
`${noun}: reuses the catalog funding round and commitment mechanism; the parent lock caps committed rows by target and contributor count`,
|
|
2546
|
-
`${noun}: the stored close anchor chooses threshold activation or failure; each commitment then moves whole from its own custody`,
|
|
2547
|
-
],
|
|
2548
|
-
extraNouns: [
|
|
2549
|
-
{
|
|
2550
|
-
actors: {
|
|
2551
|
-
[settlement.beneficiary]: "beneficiary",
|
|
2552
|
-
[settlement.contributor]: "payer",
|
|
2553
|
-
},
|
|
2554
|
-
desc: `One whole commitment linked to ${noun}`,
|
|
2555
|
-
escrow: true,
|
|
2556
|
-
fields: {
|
|
2557
|
-
amount: moneyFieldSpec("One whole commitment amount"),
|
|
2558
|
-
currency: {
|
|
2559
|
-
desc: "Currency derived from the funding round",
|
|
2560
|
-
type: "currency",
|
|
2561
|
-
},
|
|
2562
|
-
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
2563
|
-
},
|
|
2564
|
-
id: child,
|
|
2565
|
-
summary: `Whole commitment to ${noun}`,
|
|
2566
|
-
title: `${titleize(noun)} Commitment`,
|
|
2567
|
-
verbs: {
|
|
2568
|
-
create: {
|
|
2569
|
-
moneyEvent: commitEvent,
|
|
2570
|
-
moves: [
|
|
2571
|
-
{
|
|
2572
|
-
amount: "amount",
|
|
2573
|
-
from: settlement.contributor,
|
|
2574
|
-
key: "commit",
|
|
2575
|
-
operation: "create",
|
|
2576
|
-
to: "escrow",
|
|
2577
|
-
},
|
|
2578
|
-
],
|
|
2579
|
-
requires: parentRequirement(["open"]),
|
|
2580
|
-
requiresExposure: [
|
|
2581
|
-
{
|
|
2582
|
-
amountField: "amount",
|
|
2583
|
-
anchorField: parentRef,
|
|
2584
|
-
capField: settlement.target.name,
|
|
2585
|
-
capOnAnchor: true,
|
|
2586
|
-
childNounId: child,
|
|
2587
|
-
statuses: ["committed"],
|
|
2588
|
-
},
|
|
2589
|
-
],
|
|
2590
|
-
summary: "Store one whole commitment without exceeding the round target",
|
|
2591
|
-
to: "committed",
|
|
2592
|
-
},
|
|
2593
|
-
cancel: {
|
|
2594
|
-
from: ["committed"],
|
|
2595
|
-
moneyEvent: cancelEvent,
|
|
2596
|
-
moves: [
|
|
2597
|
-
{
|
|
2598
|
-
amount: "amount",
|
|
2599
|
-
from: "escrow",
|
|
2600
|
-
key: "cancel",
|
|
2601
|
-
operation: "create",
|
|
2602
|
-
to: settlement.contributor,
|
|
2603
|
-
},
|
|
2604
|
-
],
|
|
2605
|
-
requires: transitionRequirement(["open"]),
|
|
2606
|
-
summary: "Cancel one commitment while the round is open",
|
|
2607
|
-
to: "cancelled",
|
|
2608
|
-
},
|
|
2609
|
-
collect: {
|
|
2610
|
-
from: ["committed"],
|
|
2611
|
-
moneyEvent: collectEvent,
|
|
2612
|
-
moves: [
|
|
2613
|
-
{
|
|
2614
|
-
amount: "amount",
|
|
2615
|
-
from: "escrow",
|
|
2616
|
-
key: "collect",
|
|
2617
|
-
operation: "create",
|
|
2618
|
-
to: settlement.beneficiary,
|
|
2619
|
-
},
|
|
2620
|
-
],
|
|
2621
|
-
requires: transitionRequirement(["active"]),
|
|
2622
|
-
summary: "Collect one successful commitment whole",
|
|
2623
|
-
to: "collected",
|
|
2624
|
-
},
|
|
2625
|
-
refund: {
|
|
2626
|
-
from: ["committed"],
|
|
2627
|
-
moneyEvent: refundEvent,
|
|
2628
|
-
moves: [
|
|
2629
|
-
{
|
|
2630
|
-
amount: "amount",
|
|
2631
|
-
from: "escrow",
|
|
2632
|
-
key: "refund",
|
|
2633
|
-
operation: "create",
|
|
2634
|
-
to: settlement.contributor,
|
|
2635
|
-
},
|
|
2636
|
-
],
|
|
2637
|
-
requires: transitionRequirement(["failed"]),
|
|
2638
|
-
summary: "Refund one failed-round commitment whole",
|
|
2639
|
-
to: "refunded",
|
|
2640
|
-
},
|
|
2641
|
-
},
|
|
2642
|
-
},
|
|
2643
|
-
],
|
|
2644
|
-
generatedPrefixNounIds: [child],
|
|
2645
|
-
feeLines: [],
|
|
2646
|
-
moneyEvents: [
|
|
2647
|
-
mintEvent({
|
|
2648
|
-
amount: "One stored commitment",
|
|
2649
|
-
fromActor: settlement.contributor,
|
|
2650
|
-
key: commitEvent,
|
|
2651
|
-
kind: "charge",
|
|
2652
|
-
occurrence: "repeatable",
|
|
2653
|
-
toActor: "escrow",
|
|
2654
|
-
trigger: "Create one target-capped commitment",
|
|
2655
|
-
}),
|
|
2656
|
-
mintEvent({
|
|
2657
|
-
amount: "One stored commitment whole",
|
|
2658
|
-
fromActor: "escrow",
|
|
2659
|
-
key: cancelEvent,
|
|
2660
|
-
kind: "refund",
|
|
2661
|
-
occurrence: "repeatable",
|
|
2662
|
-
toActor: settlement.contributor,
|
|
2663
|
-
trigger: "Cancel before close",
|
|
2664
|
-
}),
|
|
2665
|
-
mintEvent({
|
|
2666
|
-
amount: "One stored commitment whole",
|
|
2667
|
-
fromActor: "escrow",
|
|
2668
|
-
key: collectEvent,
|
|
2669
|
-
kind: "payout",
|
|
2670
|
-
occurrence: "repeatable",
|
|
2671
|
-
toActor: settlement.beneficiary,
|
|
2672
|
-
trigger: "Collect after threshold close",
|
|
2673
|
-
}),
|
|
2674
|
-
mintEvent({
|
|
2675
|
-
amount: "One stored commitment whole",
|
|
2676
|
-
fromActor: "escrow",
|
|
2677
|
-
key: refundEvent,
|
|
2678
|
-
kind: "refund",
|
|
2679
|
-
occurrence: "repeatable",
|
|
2680
|
-
toActor: settlement.contributor,
|
|
2681
|
-
trigger: "Refund after failed close",
|
|
2682
|
-
}),
|
|
2683
|
-
],
|
|
2684
|
-
noun: {
|
|
2685
|
-
actors: { [settlement.beneficiary]: "beneficiary" },
|
|
2686
|
-
aggregateInvariants: [
|
|
2687
|
-
{
|
|
2688
|
-
childField: "amount",
|
|
2689
|
-
childNounId: child,
|
|
2690
|
-
childRefField: parentRef,
|
|
2691
|
-
childStatuses: ["committed"],
|
|
2692
|
-
parentField: settlement.target.name,
|
|
2693
|
-
},
|
|
2694
|
-
{
|
|
2695
|
-
count: true,
|
|
2696
|
-
childNounId: child,
|
|
2697
|
-
childRefField: parentRef,
|
|
2698
|
-
childStatuses: ["committed"],
|
|
2699
|
-
parentField: "maxContributors",
|
|
2700
|
-
},
|
|
2701
|
-
],
|
|
2702
|
-
desc: "All-or-nothing aggregate funding threshold",
|
|
2703
|
-
fields: {
|
|
2704
|
-
currency: {
|
|
2705
|
-
desc: `Currency fixed to ${settlement.target.currency}`,
|
|
2706
|
-
type: "currency",
|
|
2707
|
-
},
|
|
2708
|
-
[settlement.target.name]: moneyFieldSpec(`Funding target in ${settlement.target.currency} minor units`),
|
|
2709
|
-
[settlement.closeByField]: dateFieldSpec("Stored close anchor"),
|
|
2710
|
-
maxContributors: {
|
|
2711
|
-
desc: `Exactly ${settlement.maxContributors} admitted contributors`,
|
|
2712
|
-
type: `const:${settlement.maxContributors}`,
|
|
2713
|
-
},
|
|
2714
|
-
},
|
|
2715
|
-
id: noun,
|
|
2716
|
-
summary: `Threshold funding round for ${settlement.beneficiary.replaceAll("_", " ")}`,
|
|
2717
|
-
title: titleize(noun),
|
|
2718
|
-
verbs: {
|
|
2719
|
-
create: { summary: "Open the funding round", to: "open" },
|
|
2720
|
-
activate: {
|
|
2721
|
-
due: { field: settlement.closeByField, rule: closeRule },
|
|
2722
|
-
from: ["open"],
|
|
2723
|
-
requiresAggregate: aggregate("sum_at_least"),
|
|
2724
|
-
summary: "Activate when commitments meet the target",
|
|
2725
|
-
to: "active",
|
|
2726
|
-
},
|
|
2727
|
-
fail: {
|
|
2728
|
-
due: { field: settlement.closeByField, rule: closeRule },
|
|
2729
|
-
from: ["open"],
|
|
2730
|
-
requiresAggregate: aggregate("sum_below"),
|
|
2731
|
-
summary: "Fail when commitments remain below target",
|
|
2732
|
-
to: "failed",
|
|
2733
|
-
},
|
|
2734
|
-
close: {
|
|
2735
|
-
from: ["active"],
|
|
2736
|
-
requiresAggregate: [
|
|
2737
|
-
{
|
|
2738
|
-
check: { kind: "all_in" },
|
|
2739
|
-
nounId: child,
|
|
2740
|
-
over: "children",
|
|
2741
|
-
refField: parentRef,
|
|
2742
|
-
statuses: ["cancelled", "collected"],
|
|
2743
|
-
},
|
|
2744
|
-
],
|
|
2745
|
-
summary: "Settle after every admitted row is collected or was cancelled before activation",
|
|
2746
|
-
to: "settled",
|
|
2747
|
-
},
|
|
2748
|
-
},
|
|
2749
|
-
},
|
|
2750
|
-
rules: [
|
|
2751
|
-
{
|
|
2752
|
-
allowedActors: [],
|
|
2753
|
-
detail: "The stored close anchor compares committed rows with the target",
|
|
2754
|
-
dueDriven: true,
|
|
2755
|
-
enforcement: "platform",
|
|
2756
|
-
gatesEvent: null,
|
|
2757
|
-
key: closeRule,
|
|
2758
|
-
kind: "deadline",
|
|
2759
|
-
label: "Round closes against its stored threshold",
|
|
2760
|
-
tenantTunable: false,
|
|
2761
|
-
},
|
|
2762
|
-
],
|
|
2763
|
-
settlement: { name: noun, pieces: [] },
|
|
2764
|
-
};
|
|
2765
|
-
}
|
|
2766
|
-
// ---------------------------------------------------------------------------
|
|
2767
|
-
// weighted_distribution: frozen weights with deterministic largest remainder
|
|
2768
|
-
function lowerWeightedDistribution(settlement, snapshot) {
|
|
2769
|
-
const noun = settlement.name;
|
|
2770
|
-
const child = `${noun}_entitlement`;
|
|
2771
|
-
const parentRef = `${camelize(noun)}Id`;
|
|
2772
|
-
const payoutEvent = frameKey(`${noun}_payout`);
|
|
2773
|
-
const parentRequirement = (statuses) => ({
|
|
2774
|
-
[parentRef]: {
|
|
2775
|
-
bind: {
|
|
2776
|
-
currency: "fields.currency",
|
|
2777
|
-
[`${camelize(settlement.source)}AccountId`]: `fields.${camelize(settlement.source)}AccountId`,
|
|
2778
|
-
},
|
|
2779
|
-
statuses,
|
|
2780
|
-
},
|
|
2781
|
-
});
|
|
2782
|
-
return {
|
|
2783
|
-
design: [
|
|
2784
|
-
`${noun}: reuses the catalog largest-remainder distribution; the evidence port freezes the claimant set before any payout`,
|
|
2785
|
-
],
|
|
2786
|
-
extraNouns: [
|
|
2787
|
-
{
|
|
2788
|
-
actors: {
|
|
2789
|
-
[settlement.recipient]: "beneficiary",
|
|
2790
|
-
[settlement.source]: "payer",
|
|
2791
|
-
},
|
|
2792
|
-
desc: `One frozen weighted entitlement in ${noun}`,
|
|
2793
|
-
fields: {
|
|
2794
|
-
currency: {
|
|
2795
|
-
desc: "Currency derived from the distribution",
|
|
2796
|
-
type: "currency",
|
|
2797
|
-
},
|
|
2798
|
-
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
2799
|
-
[settlement.weight.name]: moneyFieldSpec("Stored non-negative entitlement weight"),
|
|
2800
|
-
},
|
|
2801
|
-
id: child,
|
|
2802
|
-
summary: `Frozen entitlement in ${noun}`,
|
|
2803
|
-
title: `${titleize(noun)} Entitlement`,
|
|
2804
|
-
verbs: {
|
|
2805
|
-
create: {
|
|
2806
|
-
requires: parentRequirement(["open"]),
|
|
2807
|
-
summary: "Record one entitlement before snapshot",
|
|
2808
|
-
to: "recorded",
|
|
2809
|
-
},
|
|
2810
|
-
payout: {
|
|
2811
|
-
distribute: {
|
|
2812
|
-
amountRef: "payoutShare",
|
|
2813
|
-
onZero: "skip_steps",
|
|
2814
|
-
pool: {
|
|
2815
|
-
from: "parent",
|
|
2816
|
-
path: `fields.${settlement.amount.name}`,
|
|
2817
|
-
},
|
|
2818
|
-
refField: parentRef,
|
|
2819
|
-
statuses: ["recorded", "paid"],
|
|
2820
|
-
weightField: settlement.weight.name,
|
|
2821
|
-
},
|
|
2822
|
-
from: ["recorded"],
|
|
2823
|
-
moneyEvent: payoutEvent,
|
|
2824
|
-
moves: [
|
|
2825
|
-
{
|
|
2826
|
-
amount: "refs.payoutShare",
|
|
2827
|
-
from: settlement.source,
|
|
2828
|
-
key: "payout",
|
|
2829
|
-
operation: "create",
|
|
2830
|
-
to: settlement.recipient,
|
|
2831
|
-
},
|
|
2832
|
-
],
|
|
2833
|
-
requires: {
|
|
2834
|
-
[parentRef]: {
|
|
2835
|
-
match: {
|
|
2836
|
-
"fields.currency": "fields.currency",
|
|
2837
|
-
[`fields.${camelize(settlement.source)}AccountId`]: `fields.${camelize(settlement.source)}AccountId`,
|
|
2838
|
-
},
|
|
2839
|
-
statuses: ["snapshotted"],
|
|
2840
|
-
},
|
|
2841
|
-
},
|
|
2842
|
-
summary: "Pay the deterministic largest-remainder share once",
|
|
2843
|
-
to: "paid",
|
|
2844
|
-
},
|
|
2845
|
-
},
|
|
2846
|
-
},
|
|
2847
|
-
],
|
|
2848
|
-
generatedPrefixNounIds: [child],
|
|
2849
|
-
feeLines: [],
|
|
2850
|
-
moneyEvents: [
|
|
2851
|
-
mintEvent({
|
|
2852
|
-
amount: "A deterministic largest-remainder share of the stored pool",
|
|
2853
|
-
fromActor: settlement.source,
|
|
2854
|
-
key: payoutEvent,
|
|
2855
|
-
kind: "payout",
|
|
2856
|
-
occurrence: "repeatable",
|
|
2857
|
-
toActor: settlement.recipient,
|
|
2858
|
-
trigger: "Pay one frozen entitlement",
|
|
2859
|
-
}),
|
|
2860
|
-
],
|
|
2861
|
-
noun: {
|
|
2862
|
-
actors: { [settlement.source]: "payer" },
|
|
2863
|
-
aggregateInvariants: [
|
|
2864
|
-
{
|
|
2865
|
-
count: true,
|
|
2866
|
-
childNounId: child,
|
|
2867
|
-
childRefField: parentRef,
|
|
2868
|
-
childStatuses: ["recorded", "paid"],
|
|
2869
|
-
parentField: "maxRecipients",
|
|
2870
|
-
},
|
|
2871
|
-
],
|
|
2872
|
-
desc: "Evidence-frozen weighted distribution",
|
|
2873
|
-
fields: {
|
|
2874
|
-
currency: {
|
|
2875
|
-
desc: `Currency fixed to ${settlement.amount.currency}`,
|
|
2876
|
-
type: "currency",
|
|
2877
|
-
},
|
|
2878
|
-
[settlement.amount.name]: moneyFieldSpec(`Distribution pool in ${settlement.amount.currency} minor units`),
|
|
2879
|
-
[settlement.recordAtField]: dateFieldSpec("Stored record date"),
|
|
2880
|
-
maxRecipients: {
|
|
2881
|
-
desc: `Exactly ${settlement.maxRecipients} frozen entitlement rows`,
|
|
2882
|
-
type: `const:${settlement.maxRecipients}`,
|
|
2883
|
-
},
|
|
2884
|
-
},
|
|
2885
|
-
id: noun,
|
|
2886
|
-
summary: "Frozen largest-remainder distribution",
|
|
2887
|
-
title: titleize(noun),
|
|
2888
|
-
verbs: {
|
|
2889
|
-
create: { summary: "Open entitlement recording", to: "open" },
|
|
2890
|
-
[settlement.snapshot.port]: {
|
|
2891
|
-
captureInput: { snapshotEvidenceReference: "evidenceReference" },
|
|
2892
|
-
from: ["open"],
|
|
2893
|
-
port: {
|
|
2894
|
-
allowed: snapshot.allowed,
|
|
2895
|
-
fields: { evidenceReference: "text" },
|
|
2896
|
-
},
|
|
2897
|
-
summary: "Freeze the entitlement set from stored evidence",
|
|
2898
|
-
to: "snapshotted",
|
|
2899
|
-
},
|
|
2900
|
-
},
|
|
2901
|
-
},
|
|
2902
|
-
rules: [],
|
|
2903
|
-
settlement: { name: noun, pieces: [] },
|
|
2904
|
-
};
|
|
2905
|
-
}
|
|
2906
|
-
// ---------------------------------------------------------------------------
|
|
2907
|
-
// credit_facility: draw capacity only, repayment remains on scheduled obligation
|
|
2908
|
-
function lowerCreditFacility(settlement) {
|
|
2909
|
-
const noun = settlement.name;
|
|
2910
|
-
const child = `${noun}_draw`;
|
|
2911
|
-
const facilityRef = `${camelize(noun)}Id`;
|
|
2912
|
-
const obligationRef = `${camelize(settlement.obligation.settlement)}Id`;
|
|
2913
|
-
const drawEvent = frameKey(`${noun}_draw`);
|
|
2914
|
-
const expiryRule = frameKey(`${noun}_expiry`);
|
|
2915
|
-
const countedStatuses = settlement.availabilityPolicy === "revolving"
|
|
2916
|
-
? ["drawn"]
|
|
2917
|
-
: ["drawn", "resolved"];
|
|
2918
|
-
return {
|
|
2919
|
-
design: [
|
|
2920
|
-
`${noun}: owns reusable draw capacity only; ${settlement.obligation.settlement} remains the sole repayment and delinquency owner`,
|
|
2921
|
-
],
|
|
2922
|
-
extraNouns: [
|
|
2923
|
-
{
|
|
2924
|
-
actors: {
|
|
2925
|
-
[settlement.drawDestination]: "beneficiary",
|
|
2926
|
-
[settlement.lender]: "payer",
|
|
2927
|
-
},
|
|
2928
|
-
desc: `One capacity-capped draw linked to ${settlement.obligation.settlement}`,
|
|
2929
|
-
fields: {
|
|
2930
|
-
amount: moneyFieldSpec("One draw amount"),
|
|
2931
|
-
currency: {
|
|
2932
|
-
desc: "Currency derived from the facility",
|
|
2933
|
-
type: "currency",
|
|
2934
|
-
},
|
|
2935
|
-
[facilityRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
2936
|
-
[obligationRef]: {
|
|
2937
|
-
desc: "The sole repayment obligation",
|
|
2938
|
-
type: `ref:${settlement.obligation.settlement}`,
|
|
2939
|
-
},
|
|
2940
|
-
},
|
|
2941
|
-
id: child,
|
|
2942
|
-
summary: `Draw from ${noun}`,
|
|
2943
|
-
title: `${titleize(noun)} Draw`,
|
|
2944
|
-
verbs: {
|
|
2945
|
-
create: {
|
|
2946
|
-
moneyEvent: drawEvent,
|
|
2947
|
-
moves: [
|
|
2948
|
-
{
|
|
2949
|
-
amount: "amount",
|
|
2950
|
-
from: settlement.lender,
|
|
2951
|
-
key: "draw",
|
|
2952
|
-
operation: "create",
|
|
2953
|
-
to: settlement.drawDestination,
|
|
2954
|
-
},
|
|
2955
|
-
],
|
|
2956
|
-
requires: {
|
|
2957
|
-
[facilityRef]: {
|
|
2958
|
-
bind: {
|
|
2959
|
-
currency: "fields.currency",
|
|
2960
|
-
[`${camelize(settlement.drawDestination)}AccountId`]: `fields.${camelize(settlement.drawDestination)}AccountId`,
|
|
2961
|
-
[`${camelize(settlement.lender)}AccountId`]: `fields.${camelize(settlement.lender)}AccountId`,
|
|
2962
|
-
},
|
|
2963
|
-
statuses: ["active"],
|
|
2964
|
-
},
|
|
2965
|
-
[obligationRef]: { statuses: ["active"], unique: true },
|
|
2966
|
-
},
|
|
2967
|
-
requiresExposure: [
|
|
2968
|
-
{
|
|
2969
|
-
amountField: "amount",
|
|
2970
|
-
anchorField: facilityRef,
|
|
2971
|
-
capField: settlement.limit.name,
|
|
2972
|
-
capOnAnchor: true,
|
|
2973
|
-
childNounId: child,
|
|
2974
|
-
statuses: countedStatuses,
|
|
2975
|
-
},
|
|
2976
|
-
],
|
|
2977
|
-
summary: "Create one draw under the locked facility capacity",
|
|
2978
|
-
to: "drawn",
|
|
2979
|
-
},
|
|
2980
|
-
resolve: {
|
|
2981
|
-
from: ["drawn"],
|
|
2982
|
-
requires: {
|
|
2983
|
-
[obligationRef]: ["repaid", "written_off"],
|
|
2984
|
-
},
|
|
2985
|
-
summary: "Release revolving capacity only after the linked obligation resolves",
|
|
2986
|
-
to: "resolved",
|
|
2987
|
-
},
|
|
2988
|
-
},
|
|
2989
|
-
},
|
|
2990
|
-
],
|
|
2991
|
-
generatedPrefixNounIds: [child],
|
|
2992
|
-
feeLines: [],
|
|
2993
|
-
moneyEvents: [
|
|
2994
|
-
mintEvent({
|
|
2995
|
-
amount: "One draw under the stored facility limit",
|
|
2996
|
-
fromActor: settlement.lender,
|
|
2997
|
-
key: drawEvent,
|
|
2998
|
-
kind: "payout",
|
|
2999
|
-
occurrence: "repeatable",
|
|
3000
|
-
toActor: settlement.drawDestination,
|
|
3001
|
-
trigger: "Admit one linked draw",
|
|
3002
|
-
}),
|
|
3003
|
-
],
|
|
3004
|
-
noun: {
|
|
3005
|
-
actors: {
|
|
3006
|
-
[settlement.borrower]: "party",
|
|
3007
|
-
[settlement.drawDestination]: "beneficiary",
|
|
3008
|
-
[settlement.lender]: "payer",
|
|
3009
|
-
},
|
|
3010
|
-
desc: "Reusable capacity with repayment delegated to one scheduled obligation",
|
|
3011
|
-
fields: {
|
|
3012
|
-
currency: {
|
|
3013
|
-
desc: `Currency fixed to ${settlement.limit.currency}`,
|
|
3014
|
-
type: "currency",
|
|
3015
|
-
},
|
|
3016
|
-
[settlement.limit.name]: moneyFieldSpec(`Facility limit in ${settlement.limit.currency} minor units`),
|
|
3017
|
-
[settlement.expiresAtField]: dateFieldSpec("Stored draw expiry"),
|
|
3018
|
-
},
|
|
3019
|
-
id: noun,
|
|
3020
|
-
summary: `Draw capacity for ${settlement.borrower.replaceAll("_", " ")}`,
|
|
3021
|
-
title: titleize(noun),
|
|
3022
|
-
verbs: {
|
|
3023
|
-
create: { summary: "Open the facility", to: "active" },
|
|
3024
|
-
freeze: {
|
|
3025
|
-
due: { field: settlement.expiresAtField, rule: expiryRule },
|
|
3026
|
-
from: ["active"],
|
|
3027
|
-
summary: "Freeze new draws at expiry",
|
|
3028
|
-
to: "frozen",
|
|
3029
|
-
},
|
|
3030
|
-
close: {
|
|
3031
|
-
from: ["active", "frozen"],
|
|
3032
|
-
requiresAggregate: [
|
|
3033
|
-
{
|
|
3034
|
-
check: { kind: "all_in" },
|
|
3035
|
-
nounId: child,
|
|
3036
|
-
over: "children",
|
|
3037
|
-
refField: facilityRef,
|
|
3038
|
-
statuses: ["resolved"],
|
|
3039
|
-
},
|
|
3040
|
-
],
|
|
3041
|
-
summary: "Close only when every admitted draw resolved",
|
|
3042
|
-
to: "closed",
|
|
3043
|
-
},
|
|
3044
|
-
},
|
|
3045
|
-
},
|
|
3046
|
-
rules: [
|
|
3047
|
-
{
|
|
3048
|
-
allowedActors: [],
|
|
3049
|
-
detail: "The stored expiry freezes new draws without changing repayment state",
|
|
3050
|
-
dueDriven: true,
|
|
3051
|
-
enforcement: "platform",
|
|
3052
|
-
gatesEvent: null,
|
|
3053
|
-
key: expiryRule,
|
|
3054
|
-
kind: "deadline",
|
|
3055
|
-
label: "Facility freezes at expiry",
|
|
3056
|
-
tenantTunable: false,
|
|
3057
|
-
},
|
|
3058
|
-
],
|
|
3059
|
-
settlement: { name: noun, pieces: [] },
|
|
3060
|
-
};
|
|
3061
|
-
}
|
|
3062
|
-
// ---------------------------------------------------------------------------
|
|
3063
|
-
// conditional_disbursement: one evidence-gated amount under a stored cap
|
|
3064
|
-
function lowerConditionalDisbursement(settlement, decision) {
|
|
3065
|
-
const noun = settlement.name;
|
|
3066
|
-
const child = `${noun}_approved_amount`;
|
|
3067
|
-
const parentRef = `${camelize(noun)}Id`;
|
|
3068
|
-
const payoutEvent = frameKey(`${noun}_payout`);
|
|
3069
|
-
const sourceAccountField = `${camelize(settlement.source)}AccountId`;
|
|
3070
|
-
const destinationAccountField = `${camelize(settlement.destination)}AccountId`;
|
|
3071
|
-
const parentTransitionRequirement = {
|
|
3072
|
-
[parentRef]: {
|
|
3073
|
-
match: {
|
|
3074
|
-
"fields.currency": "fields.currency",
|
|
3075
|
-
[`fields.${destinationAccountField}`]: `fields.${destinationAccountField}`,
|
|
3076
|
-
[`fields.${sourceAccountField}`]: `fields.${sourceAccountField}`,
|
|
3077
|
-
},
|
|
3078
|
-
statuses: ["submitted"],
|
|
3079
|
-
},
|
|
3080
|
-
};
|
|
3081
|
-
return {
|
|
3082
|
-
design: [
|
|
3083
|
-
`${noun}: a stored external decision may approve one amount under the cap; recovery requires a separate transfer`,
|
|
3084
|
-
],
|
|
3085
|
-
extraNouns: [
|
|
3086
|
-
{
|
|
3087
|
-
actors: {
|
|
3088
|
-
[settlement.destination]: "beneficiary",
|
|
3089
|
-
[settlement.source]: "payer",
|
|
3090
|
-
},
|
|
3091
|
-
desc: `One evidence-gated amount under ${noun}`,
|
|
3092
|
-
fields: {
|
|
3093
|
-
amount: moneyFieldSpec("Approved amount under the parent cap"),
|
|
3094
|
-
currency: {
|
|
3095
|
-
desc: "Currency derived from the parent cap",
|
|
3096
|
-
type: "currency",
|
|
3097
|
-
},
|
|
3098
|
-
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
3099
|
-
},
|
|
3100
|
-
id: child,
|
|
3101
|
-
summary: `Approved amount under ${noun}`,
|
|
3102
|
-
title: `${titleize(noun)} Approved Amount`,
|
|
3103
|
-
verbs: {
|
|
3104
|
-
create: {
|
|
3105
|
-
requires: {
|
|
3106
|
-
[parentRef]: {
|
|
3107
|
-
bind: {
|
|
3108
|
-
currency: "fields.currency",
|
|
3109
|
-
[destinationAccountField]: `fields.${destinationAccountField}`,
|
|
3110
|
-
[sourceAccountField]: `fields.${sourceAccountField}`,
|
|
3111
|
-
},
|
|
3112
|
-
statuses: ["submitted"],
|
|
3113
|
-
unique: true,
|
|
3114
|
-
},
|
|
3115
|
-
},
|
|
3116
|
-
summary: "Create one candidate amount under the parent",
|
|
3117
|
-
to: "created",
|
|
3118
|
-
},
|
|
3119
|
-
approve: {
|
|
3120
|
-
captureInput: { decisionEvidenceReference: "evidenceReference" },
|
|
3121
|
-
from: ["created"],
|
|
3122
|
-
port: {
|
|
3123
|
-
allowed: decision.allowed,
|
|
3124
|
-
fields: { evidenceReference: "text" },
|
|
3125
|
-
},
|
|
3126
|
-
requires: parentTransitionRequirement,
|
|
3127
|
-
requiresExposure: [
|
|
3128
|
-
{
|
|
3129
|
-
amountField: "amount",
|
|
3130
|
-
anchorField: parentRef,
|
|
3131
|
-
capField: settlement.cap.name,
|
|
3132
|
-
capOnAnchor: true,
|
|
3133
|
-
childNounId: child,
|
|
3134
|
-
statuses: ["approved", "paid"],
|
|
3135
|
-
},
|
|
3136
|
-
],
|
|
3137
|
-
summary: "Store one externally approved amount under the cap",
|
|
3138
|
-
to: "approved",
|
|
3139
|
-
},
|
|
3140
|
-
pay: {
|
|
3141
|
-
from: ["approved"],
|
|
3142
|
-
moneyEvent: payoutEvent,
|
|
3143
|
-
moves: [
|
|
3144
|
-
{
|
|
3145
|
-
amount: "amount",
|
|
3146
|
-
from: settlement.source,
|
|
3147
|
-
key: "payout",
|
|
3148
|
-
operation: "create",
|
|
3149
|
-
to: settlement.destination,
|
|
3150
|
-
},
|
|
3151
|
-
],
|
|
3152
|
-
requires: parentTransitionRequirement,
|
|
3153
|
-
summary: "Pay the stored approved amount once",
|
|
3154
|
-
to: "paid",
|
|
3155
|
-
},
|
|
3156
|
-
},
|
|
3157
|
-
},
|
|
3158
|
-
],
|
|
3159
|
-
generatedPrefixNounIds: [child],
|
|
3160
|
-
feeLines: [],
|
|
3161
|
-
moneyEvents: [
|
|
3162
|
-
mintEvent({
|
|
3163
|
-
amount: "The stored approved amount under the cap",
|
|
3164
|
-
fromActor: settlement.source,
|
|
3165
|
-
key: payoutEvent,
|
|
3166
|
-
kind: "payout",
|
|
3167
|
-
occurrence: "repeatable",
|
|
3168
|
-
toActor: settlement.destination,
|
|
3169
|
-
trigger: "Pay one approved amount",
|
|
3170
|
-
}),
|
|
3171
|
-
],
|
|
3172
|
-
noun: {
|
|
3173
|
-
actors: {
|
|
3174
|
-
[settlement.destination]: "beneficiary",
|
|
3175
|
-
[settlement.source]: "payer",
|
|
3176
|
-
},
|
|
3177
|
-
desc: "Capped disbursement controlled by stored external evidence",
|
|
3178
|
-
fields: {
|
|
3179
|
-
currency: {
|
|
3180
|
-
desc: `Currency fixed to ${settlement.cap.currency}`,
|
|
3181
|
-
type: "currency",
|
|
3182
|
-
},
|
|
3183
|
-
[settlement.cap.name]: moneyFieldSpec(`Disbursement cap in ${settlement.cap.currency} minor units`),
|
|
3184
|
-
},
|
|
3185
|
-
id: noun,
|
|
3186
|
-
summary: `Capped disbursement to ${settlement.destination.replaceAll("_", " ")}`,
|
|
3187
|
-
title: titleize(noun),
|
|
3188
|
-
verbs: {
|
|
3189
|
-
create: { summary: "Submit the capped disbursement", to: "submitted" },
|
|
3190
|
-
deny: {
|
|
3191
|
-
captureInput: { decisionEvidenceReference: "evidenceReference" },
|
|
3192
|
-
from: ["submitted"],
|
|
3193
|
-
port: {
|
|
3194
|
-
allowed: decision.allowed,
|
|
3195
|
-
fields: { evidenceReference: "text" },
|
|
3196
|
-
},
|
|
3197
|
-
requiresAggregate: [
|
|
3198
|
-
{
|
|
3199
|
-
check: { kind: "all_in" },
|
|
3200
|
-
nounId: child,
|
|
3201
|
-
over: "children",
|
|
3202
|
-
refField: parentRef,
|
|
3203
|
-
statuses: ["created"],
|
|
3204
|
-
},
|
|
3205
|
-
],
|
|
3206
|
-
summary: "Record a denial without moving money",
|
|
3207
|
-
to: "denied",
|
|
3208
|
-
},
|
|
3209
|
-
},
|
|
3210
|
-
},
|
|
3211
|
-
rules: [],
|
|
3212
|
-
settlement: { name: noun, pieces: [] },
|
|
3213
|
-
};
|
|
3214
|
-
}
|
|
3215
|
-
// ---------------------------------------------------------------------------
|
|
3216
|
-
// rotating_pool: fixed roster, one contribution per member and cycle
|
|
3217
|
-
function lowerRotatingPool(settlement) {
|
|
3218
|
-
const noun = settlement.name;
|
|
3219
|
-
const parentRef = `${camelize(noun)}Id`;
|
|
3220
|
-
const contributionEvent = frameKey(`${noun}_contribution`);
|
|
3221
|
-
const guaranteeEvent = frameKey(`${noun}_guarantee_contribution`);
|
|
3222
|
-
const payoutEvent = frameKey(`${noun}_payout`);
|
|
3223
|
-
const fixedActors = [
|
|
3224
|
-
...new Set([
|
|
3225
|
-
...settlement.members,
|
|
3226
|
-
...settlement.payoutOrder,
|
|
3227
|
-
...(settlement.guarantor ? [settlement.guarantor] : []),
|
|
3228
|
-
]),
|
|
3229
|
-
];
|
|
3230
|
-
const fixedActorBindings = Object.fromEntries(fixedActors.map((actor) => {
|
|
3231
|
-
const accountField = `${camelize(actor)}AccountId`;
|
|
3232
|
-
return [accountField, `fields.${accountField}`];
|
|
3233
|
-
}));
|
|
3234
|
-
const childIds = settlement.members.map((member) => `${noun}_${member}_contribution`);
|
|
3235
|
-
const childNouns = settlement.members.map((member, memberIndex) => {
|
|
3236
|
-
const id = childIds[memberIndex];
|
|
3237
|
-
const verbs = {
|
|
3238
|
-
create: {
|
|
3239
|
-
requires: {
|
|
3240
|
-
[parentRef]: {
|
|
3241
|
-
bind: {
|
|
3242
|
-
currency: "fields.currency",
|
|
3243
|
-
...fixedActorBindings,
|
|
3244
|
-
[settlement.schedule.firstDueField]: `fields.${settlement.schedule.firstDueField}`,
|
|
3245
|
-
[settlement.contribution.name]: `fields.${settlement.contribution.name}`,
|
|
3246
|
-
},
|
|
3247
|
-
statuses: ["forming"],
|
|
3248
|
-
unique: true,
|
|
3249
|
-
},
|
|
3250
|
-
},
|
|
3251
|
-
summary: `Create the fixed contribution row for ${member.replaceAll("_", " ")}`,
|
|
3252
|
-
to: "cycle_1_due",
|
|
3253
|
-
},
|
|
3254
|
-
};
|
|
3255
|
-
for (let index = 0; index < settlement.schedule.count; index += 1) {
|
|
3256
|
-
const cycle = index + 1;
|
|
3257
|
-
const dueState = `cycle_${cycle}_due`;
|
|
3258
|
-
const defaultState = `cycle_${cycle}_defaulted`;
|
|
3259
|
-
const fundedState = `cycle_${cycle}_funded`;
|
|
3260
|
-
const guaranteedState = `cycle_${cycle}_guaranteed`;
|
|
3261
|
-
const nextState = cycle === settlement.schedule.count
|
|
3262
|
-
? "final_paid"
|
|
3263
|
-
: `cycle_${cycle + 1}_due`;
|
|
3264
|
-
const dueRule = frameKey(`${noun}_cycle_${cycle}_due`);
|
|
3265
|
-
const cycleAmount = settlement.contribution.name;
|
|
3266
|
-
const guaranteeAmount = settlement.contribution.name;
|
|
3267
|
-
const due = {
|
|
3268
|
-
field: settlement.schedule.firstDueField,
|
|
3269
|
-
rule: dueRule,
|
|
3270
|
-
...anchorOffset(settlement.schedule, index),
|
|
3271
|
-
};
|
|
3272
|
-
verbs[`contribute_cycle_${cycle}`] = {
|
|
3273
|
-
due,
|
|
3274
|
-
from: [dueState],
|
|
3275
|
-
moneyEvent: contributionEvent,
|
|
3276
|
-
moves: [
|
|
3277
|
-
{
|
|
3278
|
-
amount: cycleAmount,
|
|
3279
|
-
from: member,
|
|
3280
|
-
key: "contribution",
|
|
3281
|
-
operation: "create",
|
|
3282
|
-
to: "escrow",
|
|
3283
|
-
},
|
|
3284
|
-
],
|
|
3285
|
-
requires: { [parentRef]: [`active_cycle_${cycle}`] },
|
|
3286
|
-
summary: `Fund ${member.replaceAll("_", " ")}'s cycle ${cycle} contribution`,
|
|
3287
|
-
to: fundedState,
|
|
3288
|
-
};
|
|
3289
|
-
verbs[`mark_default_cycle_${cycle}`] = {
|
|
3290
|
-
due,
|
|
3291
|
-
from: [dueState],
|
|
3292
|
-
requires: { [parentRef]: [`active_cycle_${cycle}`] },
|
|
3293
|
-
summary: `Mark the stored cycle ${cycle} due condition`,
|
|
3294
|
-
to: defaultState,
|
|
3295
|
-
};
|
|
3296
|
-
if (settlement.guarantor) {
|
|
3297
|
-
verbs[`guarantee_cycle_${cycle}`] = {
|
|
3298
|
-
from: [defaultState],
|
|
3299
|
-
moneyEvent: guaranteeEvent,
|
|
3300
|
-
moves: [
|
|
3301
|
-
{
|
|
3302
|
-
amount: guaranteeAmount,
|
|
3303
|
-
from: settlement.guarantor,
|
|
3304
|
-
key: "guarantee",
|
|
3305
|
-
operation: "create",
|
|
3306
|
-
to: "escrow",
|
|
3307
|
-
},
|
|
3308
|
-
],
|
|
3309
|
-
requires: { [parentRef]: [`active_cycle_${cycle}`] },
|
|
3310
|
-
summary: `Fund the defaulted cycle ${cycle} amount before payout`,
|
|
3311
|
-
to: guaranteedState,
|
|
3312
|
-
};
|
|
3313
|
-
}
|
|
3314
|
-
verbs[`pay_cycle_${cycle}`] = {
|
|
3315
|
-
from: [fundedState],
|
|
3316
|
-
moneyEvent: payoutEvent,
|
|
3317
|
-
moves: [
|
|
3318
|
-
{
|
|
3319
|
-
amount: cycleAmount,
|
|
3320
|
-
from: "escrow",
|
|
3321
|
-
key: "payout",
|
|
3322
|
-
operation: "create",
|
|
3323
|
-
to: settlement.payoutOrder[index],
|
|
3324
|
-
},
|
|
3325
|
-
],
|
|
3326
|
-
requires: { [parentRef]: [`cycle_${cycle}_ready`] },
|
|
3327
|
-
summary: `Pay this member's stored contribution into cycle ${cycle}'s shared pot recipient`,
|
|
3328
|
-
to: nextState,
|
|
3329
|
-
};
|
|
3330
|
-
if (settlement.guarantor) {
|
|
3331
|
-
verbs[`pay_guaranteed_cycle_${cycle}`] = {
|
|
3332
|
-
from: [guaranteedState],
|
|
3333
|
-
moneyEvent: payoutEvent,
|
|
3334
|
-
moves: [
|
|
3335
|
-
{
|
|
3336
|
-
amount: guaranteeAmount,
|
|
3337
|
-
from: "escrow",
|
|
3338
|
-
key: "payout",
|
|
3339
|
-
operation: "create",
|
|
3340
|
-
to: settlement.payoutOrder[index],
|
|
3341
|
-
},
|
|
3342
|
-
],
|
|
3343
|
-
requires: { [parentRef]: [`cycle_${cycle}_ready`] },
|
|
3344
|
-
summary: `Pay the funded default into cycle ${cycle}'s stored recipient`,
|
|
3345
|
-
to: nextState,
|
|
3346
|
-
};
|
|
3347
|
-
}
|
|
3348
|
-
}
|
|
3349
|
-
verbs.close = {
|
|
3350
|
-
from: ["final_paid"],
|
|
3351
|
-
requiresDrainedAccount: { path: "refs.escrowAccountId" },
|
|
3352
|
-
summary: "Complete after the final payout drains this member custody",
|
|
3353
|
-
to: "completed",
|
|
3354
|
-
};
|
|
3355
|
-
return {
|
|
3356
|
-
actors: Object.fromEntries([
|
|
3357
|
-
[member, "payer"],
|
|
3358
|
-
...(settlement.guarantor ? [[settlement.guarantor, "payer"]] : []),
|
|
3359
|
-
...settlement.payoutOrder.map((recipient) => [
|
|
3360
|
-
recipient,
|
|
3361
|
-
"beneficiary",
|
|
3362
|
-
]),
|
|
3363
|
-
]),
|
|
3364
|
-
desc: `Fixed contribution row for ${member.replaceAll("_", " ")}`,
|
|
3365
|
-
escrow: true,
|
|
3366
|
-
fields: {
|
|
3367
|
-
[settlement.contribution.name]: moneyFieldSpec("Exact contribution amount shared by every cycle"),
|
|
3368
|
-
currency: { desc: "Currency derived from the pool", type: "currency" },
|
|
3369
|
-
[settlement.schedule.firstDueField]: dateFieldSpec("First due anchor derived from the pool"),
|
|
3370
|
-
[parentRef]: { desc: `The exact ${noun}`, type: `ref:${noun}` },
|
|
3371
|
-
},
|
|
3372
|
-
id,
|
|
3373
|
-
summary: `${member.replaceAll("_", " ")} contribution row`,
|
|
3374
|
-
title: `${titleize(noun)} ${titleize(member)} Contribution`,
|
|
3375
|
-
verbs,
|
|
3376
|
-
};
|
|
3377
|
-
});
|
|
3378
|
-
const parentVerbs = {
|
|
3379
|
-
create: {
|
|
3380
|
-
summary: "Create the fixed roster before activation",
|
|
3381
|
-
to: "forming",
|
|
3382
|
-
},
|
|
3383
|
-
cancel: {
|
|
3384
|
-
from: ["forming"],
|
|
3385
|
-
summary: "Cancel before activation without moving money",
|
|
3386
|
-
to: "cancelled",
|
|
3387
|
-
},
|
|
3388
|
-
activate: {
|
|
3389
|
-
from: ["forming"],
|
|
3390
|
-
requiresAggregate: childIds.map((childId) => ({
|
|
3391
|
-
check: { kind: "count_equals_field", field: "one" },
|
|
3392
|
-
nounId: childId,
|
|
3393
|
-
over: "children",
|
|
3394
|
-
refField: parentRef,
|
|
3395
|
-
statuses: ["cycle_1_due"],
|
|
3396
|
-
})),
|
|
3397
|
-
summary: "Activate only after every fixed member row exists once",
|
|
3398
|
-
to: "active_cycle_1",
|
|
3399
|
-
},
|
|
3400
|
-
};
|
|
3401
|
-
for (let index = 0; index < settlement.schedule.count; index += 1) {
|
|
3402
|
-
const cycle = index + 1;
|
|
3403
|
-
parentVerbs[`ready_cycle_${cycle}`] = {
|
|
3404
|
-
from: [`active_cycle_${cycle}`],
|
|
3405
|
-
requiresAggregate: childIds.map((childId) => ({
|
|
3406
|
-
check: { kind: "all_in" },
|
|
3407
|
-
nounId: childId,
|
|
3408
|
-
over: "children",
|
|
3409
|
-
refField: parentRef,
|
|
3410
|
-
statuses: [
|
|
3411
|
-
`cycle_${cycle}_funded`,
|
|
3412
|
-
...(settlement.guarantor ? [`cycle_${cycle}_guaranteed`] : []),
|
|
3413
|
-
],
|
|
3414
|
-
})),
|
|
3415
|
-
summary: `Lock cycle ${cycle} only after every member row is funded or guaranteed`,
|
|
3416
|
-
to: `cycle_${cycle}_ready`,
|
|
3417
|
-
};
|
|
3418
|
-
parentVerbs[`advance_cycle_${cycle}`] = {
|
|
3419
|
-
from: [`cycle_${cycle}_ready`],
|
|
3420
|
-
requiresAggregate: childIds.map((childId) => ({
|
|
3421
|
-
check: { kind: "all_in" },
|
|
3422
|
-
nounId: childId,
|
|
3423
|
-
over: "children",
|
|
3424
|
-
refField: parentRef,
|
|
3425
|
-
statuses: [
|
|
3426
|
-
cycle === settlement.schedule.count
|
|
3427
|
-
? "completed"
|
|
3428
|
-
: `cycle_${cycle + 1}_due`,
|
|
3429
|
-
],
|
|
3430
|
-
})),
|
|
3431
|
-
summary: cycle === settlement.schedule.count
|
|
3432
|
-
? "Complete after the final shared pot pays"
|
|
3433
|
-
: `Advance after every cycle ${cycle} contribution pays`,
|
|
3434
|
-
to: cycle === settlement.schedule.count
|
|
3435
|
-
? "completed"
|
|
3436
|
-
: `active_cycle_${cycle + 1}`,
|
|
3437
|
-
};
|
|
3438
|
-
}
|
|
3439
|
-
return {
|
|
3440
|
-
design: [
|
|
3441
|
-
`${noun}: fixed roster and stored payout order; one member-specific row per member avoids tuple identity and keeps each cycle idempotent`,
|
|
3442
|
-
],
|
|
3443
|
-
extraNouns: childNouns,
|
|
3444
|
-
generatedPrefixNounIds: childIds,
|
|
3445
|
-
feeLines: [],
|
|
3446
|
-
moneyEvents: [
|
|
3447
|
-
mintEvent({
|
|
3448
|
-
amount: "One exact member contribution",
|
|
3449
|
-
fromActor: settlement.members[0],
|
|
3450
|
-
key: contributionEvent,
|
|
3451
|
-
kind: "charge",
|
|
3452
|
-
occurrence: "repeatable",
|
|
3453
|
-
toActor: "escrow",
|
|
3454
|
-
trigger: "Fund one member and cycle",
|
|
3455
|
-
}),
|
|
3456
|
-
...(settlement.guarantor
|
|
3457
|
-
? [
|
|
3458
|
-
mintEvent({
|
|
3459
|
-
amount: "One exact defaulted contribution",
|
|
3460
|
-
fromActor: settlement.guarantor,
|
|
3461
|
-
key: guaranteeEvent,
|
|
3462
|
-
kind: "charge",
|
|
3463
|
-
occurrence: "repeatable",
|
|
3464
|
-
toActor: "escrow",
|
|
3465
|
-
trigger: "Fund one defaulted member and cycle",
|
|
3466
|
-
}),
|
|
3467
|
-
]
|
|
3468
|
-
: []),
|
|
3469
|
-
mintEvent({
|
|
3470
|
-
amount: "One exact member contribution from the cycle pot",
|
|
3471
|
-
fromActor: "escrow",
|
|
3472
|
-
key: payoutEvent,
|
|
3473
|
-
kind: "payout",
|
|
3474
|
-
occurrence: "repeatable",
|
|
3475
|
-
toActor: settlement.payoutOrder[0],
|
|
3476
|
-
trigger: "Pay the stored cycle recipient",
|
|
3477
|
-
}),
|
|
3478
|
-
],
|
|
3479
|
-
noun: {
|
|
3480
|
-
actors: Object.fromEntries([
|
|
3481
|
-
...settlement.members.map((member) => [member, "party"]),
|
|
3482
|
-
...(settlement.guarantor ? [[settlement.guarantor, "payer"]] : []),
|
|
3483
|
-
]),
|
|
3484
|
-
desc: "Fixed rotating contribution and payout order",
|
|
3485
|
-
fields: {
|
|
3486
|
-
currency: {
|
|
3487
|
-
desc: `Currency fixed to ${settlement.contribution.currency}`,
|
|
3488
|
-
type: "currency",
|
|
3489
|
-
},
|
|
3490
|
-
[settlement.contribution.name]: moneyFieldSpec(`Exact contribution in ${settlement.contribution.currency} minor units`),
|
|
3491
|
-
[settlement.schedule.firstDueField]: dateFieldSpec("Stored first contribution due date"),
|
|
3492
|
-
one: { desc: "Exact fixed member-row count", type: "const:1" },
|
|
3493
|
-
},
|
|
3494
|
-
id: noun,
|
|
3495
|
-
summary: `${settlement.members.length}-member rotating pool`,
|
|
3496
|
-
title: titleize(noun),
|
|
3497
|
-
verbs: parentVerbs,
|
|
3498
|
-
},
|
|
3499
|
-
rules: Array.from({ length: settlement.schedule.count }, (_, index) => ({
|
|
3500
|
-
allowedActors: [],
|
|
3501
|
-
detail: `Cycle ${index + 1} default follows its stored due condition`,
|
|
3502
|
-
dueDriven: true,
|
|
3503
|
-
enforcement: "platform",
|
|
3504
|
-
gatesEvent: null,
|
|
3505
|
-
key: frameKey(`${noun}_cycle_${index + 1}_due`),
|
|
3506
|
-
kind: "deadline",
|
|
3507
|
-
label: `Cycle ${index + 1} due condition`,
|
|
3508
|
-
tenantTunable: false,
|
|
3509
|
-
})),
|
|
3510
|
-
settlement: { name: noun, pieces: [] },
|
|
3511
|
-
};
|
|
3512
|
-
}
|
|
3513
|
-
function anchorOffset(schedule, index) {
|
|
3514
|
-
return index === 0 ? {} : { offset: `P${schedule.every.days * index}D` };
|
|
3515
|
-
}
|
|
3516
|
-
/**
|
|
3517
|
-
* Obligation mode extends the existing schedule instead of minting a second
|
|
3518
|
-
* repayment archetype. The parent stores every anchor amount and date. One
|
|
3519
|
-
* generated payment noun per anchor makes matching exact at the operation
|
|
3520
|
-
* boundary and lets the generic aggregate lock cap concurrent partial pays.
|
|
3521
|
-
*/
|
|
3522
|
-
function lowerScheduledObligation(settlement, collection, collectionMandate) {
|
|
3523
|
-
const noun = settlement.name;
|
|
3524
|
-
const amountName = settlement.amount.name;
|
|
3525
|
-
const obligationIdField = `${noun.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase())}Id`;
|
|
3526
|
-
const widths = evenPieceBps(settlement.schedule.count);
|
|
3527
|
-
const installmentFields = widths.map((_, index) => `installment${index + 1}Amount`);
|
|
3528
|
-
const paymentNouns = widths.map((_, index) => `${noun}_installment_${index + 1}_payment`);
|
|
3529
|
-
const activeState = "active";
|
|
3530
|
-
const delinquentState = (index) => `installment_${index + 1}_delinquent`;
|
|
3531
|
-
const delinquentStates = widths.map((_, index) => delinquentState(index));
|
|
3532
|
-
const liveStates = [activeState, ...delinquentStates];
|
|
3533
|
-
const repaymentEvent = frameKey(`${noun}_repayment`);
|
|
3534
|
-
const refundEvent = frameKey(`${noun}_refund`);
|
|
3535
|
-
const advanceEvent = frameKey(`${noun}_advance`);
|
|
3536
|
-
const fields = {
|
|
3537
|
-
currency: {
|
|
3538
|
-
desc: `Currency fixed to ${settlement.amount.currency}`,
|
|
3539
|
-
type: "currency",
|
|
3540
|
-
},
|
|
3541
|
-
[amountName]: moneyFieldSpec(`The principal in ${settlement.amount.currency} minor units; the stored installment anchors partition it exactly`),
|
|
3542
|
-
[settlement.schedule.firstDueField]: dateFieldSpec(`Due date of the first installment; later anchors use fixed offsets of ${settlement.schedule.every.raw}`),
|
|
3543
|
-
};
|
|
3544
|
-
for (const [index, field] of installmentFields.entries()) {
|
|
3545
|
-
fields[field] = moneyFieldSpec(`Stored amount for installment ${index + 1} of ${settlement.schedule.count}${index === 0 ? " (carries the integer-division remainder)" : ""}`);
|
|
3546
|
-
fields[`installment${index + 1}DelinquentAfter`] = optionalDateFieldSpec(`Machine-set marker proving installment ${index + 1} reached its stored due date while unpaid`);
|
|
3547
|
-
}
|
|
3548
|
-
const aggregateInvariants = paymentNouns.map((paymentNoun, index) => ({
|
|
3549
|
-
childField: "amount",
|
|
3550
|
-
childNounId: paymentNoun,
|
|
3551
|
-
childRefField: obligationIdField,
|
|
3552
|
-
childStatuses: ["paid"],
|
|
3553
|
-
parentField: installmentFields[index],
|
|
3554
|
-
}));
|
|
3555
|
-
const verbs = {
|
|
3556
|
-
create: {
|
|
3557
|
-
summary: `Create a ${titleize(noun).toLowerCase()} obligation`,
|
|
3558
|
-
to: "draft",
|
|
3559
|
-
},
|
|
3560
|
-
approve: {
|
|
3561
|
-
from: ["draft"],
|
|
3562
|
-
summary: "Approve the immutable principal partition and stored anchors",
|
|
3563
|
-
to: settlement.advanceTo ? "approved" : activeState,
|
|
3564
|
-
},
|
|
3565
|
-
...(settlement.advanceTo
|
|
3566
|
-
? {
|
|
3567
|
-
advance: {
|
|
3568
|
-
from: ["approved"],
|
|
3569
|
-
moneyEvent: advanceEvent,
|
|
3570
|
-
moves: [
|
|
3571
|
-
{
|
|
3572
|
-
amount: amountName,
|
|
3573
|
-
from: settlement.payee,
|
|
3574
|
-
key: "advance",
|
|
3575
|
-
operation: "create",
|
|
3576
|
-
to: settlement.advanceTo,
|
|
3577
|
-
},
|
|
3578
|
-
],
|
|
3579
|
-
summary: `Advance the principal to the ${settlement.advanceTo.replaceAll("_", " ")}; the internal ledger receipt is the confirmation`,
|
|
3580
|
-
to: activeState,
|
|
3581
|
-
},
|
|
3582
|
-
}
|
|
3583
|
-
: {}),
|
|
3584
|
-
write_off: {
|
|
3585
|
-
from: [
|
|
3586
|
-
"draft",
|
|
3587
|
-
...(settlement.advanceTo ? ["approved"] : []),
|
|
3588
|
-
...liveStates,
|
|
3589
|
-
],
|
|
3590
|
-
summary: "Write off the remaining exposure without moving money",
|
|
3591
|
-
to: "written_off",
|
|
3592
|
-
},
|
|
3593
|
-
};
|
|
3594
|
-
const rules = [];
|
|
3595
|
-
for (const [index, paymentNoun] of paymentNouns.entries()) {
|
|
3596
|
-
const anchor = index + 1;
|
|
3597
|
-
const ruleKey = frameKey(`${noun}_installment_${anchor}_due`);
|
|
3598
|
-
const due = {
|
|
3599
|
-
field: settlement.schedule.firstDueField,
|
|
3600
|
-
rule: ruleKey,
|
|
3601
|
-
...anchorOffset(settlement.schedule, index),
|
|
3602
|
-
};
|
|
3603
|
-
const aggregate = (kind) => [
|
|
3604
|
-
{
|
|
3605
|
-
check: {
|
|
3606
|
-
amountField: "amount",
|
|
3607
|
-
kind,
|
|
3608
|
-
targetField: installmentFields[index],
|
|
3609
|
-
},
|
|
3610
|
-
nounId: paymentNoun,
|
|
3611
|
-
over: "children",
|
|
3612
|
-
refField: obligationIdField,
|
|
3613
|
-
statuses: ["paid"],
|
|
3614
|
-
},
|
|
3615
|
-
];
|
|
3616
|
-
verbs[`mark_installment_${anchor}_delinquent`] = {
|
|
3617
|
-
due,
|
|
3618
|
-
from: liveStates.filter((state) => state !== delinquentState(index)),
|
|
3619
|
-
requiresAggregate: aggregate("sum_below"),
|
|
3620
|
-
setsAt: {
|
|
3621
|
-
field: `installment${anchor}DelinquentAfter`,
|
|
3622
|
-
marker: true,
|
|
3623
|
-
offset: "PT1S",
|
|
3624
|
-
},
|
|
3625
|
-
summary: `Mark installment ${anchor} delinquent only when its due anchor is unmet`,
|
|
3626
|
-
to: delinquentState(index),
|
|
3627
|
-
};
|
|
3628
|
-
verbs[`collect_installment_${anchor}`] = {
|
|
3629
|
-
due,
|
|
3630
|
-
from: delinquentStates,
|
|
3631
|
-
requiresAggregate: aggregate("sum_exactly"),
|
|
3632
|
-
summary: `Close delinquent installment ${anchor} after linked payments reach its stored amount`,
|
|
3633
|
-
to: activeState,
|
|
3634
|
-
};
|
|
3635
|
-
rules.push({
|
|
3636
|
-
allowedActors: [],
|
|
3637
|
-
detail: `At stored anchor ${anchor}, the platform compares paid child rows with ${installmentFields[index]} and chooses paid or delinquent`,
|
|
3638
|
-
dueDriven: true,
|
|
3639
|
-
enforcement: "platform",
|
|
3640
|
-
gatesEvent: null,
|
|
3641
|
-
key: ruleKey,
|
|
3642
|
-
kind: "deadline",
|
|
3643
|
-
label: `Installment ${anchor} resolves from its stored due condition`,
|
|
3644
|
-
tenantTunable: false,
|
|
3645
|
-
});
|
|
3646
|
-
}
|
|
3647
|
-
const completionRuleKey = frameKey(`${noun}_completion_due`);
|
|
3648
|
-
verbs.complete = {
|
|
3649
|
-
due: {
|
|
3650
|
-
field: settlement.schedule.firstDueField,
|
|
3651
|
-
rule: completionRuleKey,
|
|
3652
|
-
...anchorOffset(settlement.schedule, widths.length - 1),
|
|
3653
|
-
},
|
|
3654
|
-
from: liveStates,
|
|
3655
|
-
requiresAggregate: paymentNouns.map((paymentNoun, index) => ({
|
|
3656
|
-
check: {
|
|
3657
|
-
amountField: "amount",
|
|
3658
|
-
kind: "sum_exactly",
|
|
3659
|
-
targetField: installmentFields[index],
|
|
3660
|
-
},
|
|
3661
|
-
nounId: paymentNoun,
|
|
3662
|
-
over: "children",
|
|
3663
|
-
refField: obligationIdField,
|
|
3664
|
-
statuses: ["paid"],
|
|
3665
|
-
})),
|
|
3666
|
-
summary: "Close the obligation only after every stored anchor is paid exactly",
|
|
3667
|
-
to: "repaid",
|
|
3668
|
-
};
|
|
3669
|
-
rules.push({
|
|
3670
|
-
allowedActors: [],
|
|
3671
|
-
detail: "After the final stored anchor, the platform closes only when every anchor is paid exactly",
|
|
3672
|
-
dueDriven: true,
|
|
3673
|
-
enforcement: "platform",
|
|
3674
|
-
gatesEvent: null,
|
|
3675
|
-
key: completionRuleKey,
|
|
3676
|
-
kind: "deadline",
|
|
3677
|
-
label: "Obligation completion follows exact aggregate repayment",
|
|
3678
|
-
tenantTunable: false,
|
|
3679
|
-
});
|
|
3680
|
-
const paymentNoun = (index) => {
|
|
3681
|
-
const anchor = index + 1;
|
|
3682
|
-
const id = paymentNouns[index];
|
|
3683
|
-
const permittedParentStates = liveStates;
|
|
3684
|
-
const payerAccountField = `${settlement.payer.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase())}AccountId`;
|
|
3685
|
-
const payeeAccountField = `${settlement.payee.replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase())}AccountId`;
|
|
3686
|
-
const createRequirement = {
|
|
3687
|
-
[obligationIdField]: {
|
|
3688
|
-
bind: {
|
|
3689
|
-
currency: "fields.currency",
|
|
3690
|
-
[payerAccountField]: `fields.${payerAccountField}`,
|
|
3691
|
-
[payeeAccountField]: `fields.${payeeAccountField}`,
|
|
3692
|
-
},
|
|
3693
|
-
statuses: permittedParentStates,
|
|
3694
|
-
},
|
|
3695
|
-
};
|
|
3696
|
-
const transitionRequirement = {
|
|
3697
|
-
[obligationIdField]: {
|
|
3698
|
-
match: {
|
|
3699
|
-
"fields.currency": "fields.currency",
|
|
3700
|
-
[`fields.${payerAccountField}`]: `fields.${payerAccountField}`,
|
|
3701
|
-
[`fields.${payeeAccountField}`]: `fields.${payeeAccountField}`,
|
|
3702
|
-
},
|
|
3703
|
-
statuses: permittedParentStates,
|
|
3704
|
-
},
|
|
3705
|
-
};
|
|
3706
|
-
return {
|
|
3707
|
-
actors: {
|
|
3708
|
-
[settlement.payee]: "beneficiary",
|
|
3709
|
-
[settlement.payer]: "payer",
|
|
3710
|
-
},
|
|
3711
|
-
desc: `One partial or full payment bound to installment ${anchor} of ${noun}; the operation name fixes the anchor and ${obligationIdField} fixes the obligation`,
|
|
3712
|
-
fields: {
|
|
3713
|
-
amount: moneyFieldSpec(`Positive payment amount capped with its paid siblings at ${installmentFields[index]}`),
|
|
3714
|
-
currency: {
|
|
3715
|
-
desc: "ISO 4217 currency derived from the obligation",
|
|
3716
|
-
type: "currency",
|
|
3717
|
-
},
|
|
3718
|
-
[obligationIdField]: {
|
|
3719
|
-
desc: `The exact ${noun.replaceAll("_", " ")} this payment belongs to`,
|
|
3720
|
-
type: `ref:${noun}`,
|
|
3721
|
-
},
|
|
3722
|
-
},
|
|
3723
|
-
id,
|
|
3724
|
-
summary: `Anchor-bound payment for installment ${anchor}`,
|
|
3725
|
-
title: `${titleize(noun)} Installment ${anchor} Payment`,
|
|
3726
|
-
verbs: {
|
|
3727
|
-
create: {
|
|
3728
|
-
requires: createRequirement,
|
|
3729
|
-
summary: `Create a payment record for installment ${anchor}`,
|
|
3730
|
-
to: "created",
|
|
3731
|
-
},
|
|
3732
|
-
repay: {
|
|
3733
|
-
...(collection && collectionMandate
|
|
3734
|
-
? {
|
|
3735
|
-
captureInput: {
|
|
3736
|
-
mandateEvidenceReference: "evidenceReference",
|
|
3737
|
-
},
|
|
3738
|
-
port: {
|
|
3739
|
-
allowed: collectionMandate.allowed,
|
|
3740
|
-
fields: { evidenceReference: "text" },
|
|
3741
|
-
},
|
|
3742
|
-
}
|
|
3743
|
-
: {}),
|
|
3744
|
-
from: ["created"],
|
|
3745
|
-
moneyEvent: repaymentEvent,
|
|
3746
|
-
moves: [
|
|
3747
|
-
{
|
|
3748
|
-
amount: "amount",
|
|
3749
|
-
from: settlement.payer,
|
|
3750
|
-
key: "repayment",
|
|
3751
|
-
operation: "create",
|
|
3752
|
-
to: settlement.payee,
|
|
3753
|
-
},
|
|
3754
|
-
],
|
|
3755
|
-
requires: transitionRequirement,
|
|
3756
|
-
requiresExposure: [
|
|
3757
|
-
{
|
|
3758
|
-
amountField: "amount",
|
|
3759
|
-
anchorField: obligationIdField,
|
|
3760
|
-
capField: installmentFields[index],
|
|
3761
|
-
capOnAnchor: true,
|
|
3762
|
-
childNounId: id,
|
|
3763
|
-
statuses: ["paid"],
|
|
3764
|
-
},
|
|
3765
|
-
],
|
|
3766
|
-
summary: `Pay a partial or full amount against installment ${anchor}`,
|
|
3767
|
-
to: "paid",
|
|
3768
|
-
},
|
|
3769
|
-
refund: {
|
|
3770
|
-
from: ["paid"],
|
|
3771
|
-
moneyEvent: refundEvent,
|
|
3772
|
-
moves: [
|
|
3773
|
-
{
|
|
3774
|
-
amount: "amount",
|
|
3775
|
-
from: settlement.payee,
|
|
3776
|
-
key: "refund",
|
|
3777
|
-
operation: "create",
|
|
3778
|
-
to: settlement.payer,
|
|
3779
|
-
},
|
|
3780
|
-
],
|
|
3781
|
-
requires: transitionRequirement,
|
|
3782
|
-
summary: `Refund this one stored installment ${anchor} payment whole`,
|
|
3783
|
-
to: "refunded",
|
|
3784
|
-
},
|
|
3785
|
-
},
|
|
3786
|
-
};
|
|
3787
|
-
};
|
|
3788
|
-
return {
|
|
3789
|
-
design: [
|
|
3790
|
-
`${noun}: existing scheduled mechanism in obligation mode; principal partitions into ${settlement.schedule.count} stored anchors; each payment operation names one anchor and one obligation`,
|
|
3791
|
-
`${noun}: partial and early payments serialize under per-anchor aggregate caps; each refund reverses one paid row whole; rescheduling is refused by the checker`,
|
|
3792
|
-
`${noun}: due-only delinquency and write-off change state without money; ${settlement.advanceTo ? "advance is an internal ledger movement with no provider claim" : "no advance is emitted"}`,
|
|
3793
|
-
...(collection
|
|
3794
|
-
? [
|
|
3795
|
-
`${collection.name}: explicit collection attempts reuse ${noun}'s anchor-bound repayment verbs; mandate evidence is captured per attempt; failures remain receipted failures and delinquency stays on ${noun}`,
|
|
3796
|
-
]
|
|
3797
|
-
: []),
|
|
3798
|
-
],
|
|
3799
|
-
extraNouns: paymentNouns.map((_, index) => paymentNoun(index)),
|
|
3800
|
-
generatedPrefixNounIds: paymentNouns,
|
|
3801
|
-
feeLines: [],
|
|
3802
|
-
moneyEvents: [
|
|
3803
|
-
...(settlement.advanceTo
|
|
3804
|
-
? [
|
|
3805
|
-
mintEvent({
|
|
3806
|
-
amount: `The full ${amountName}`,
|
|
3807
|
-
fromActor: settlement.payee,
|
|
3808
|
-
key: advanceEvent,
|
|
3809
|
-
kind: "payout",
|
|
3810
|
-
toActor: settlement.advanceTo,
|
|
3811
|
-
trigger: "Advance the approved principal once",
|
|
3812
|
-
}),
|
|
3813
|
-
]
|
|
3814
|
-
: []),
|
|
3815
|
-
mintEvent({
|
|
3816
|
-
amount: "A positive amount capped by its stored installment anchor",
|
|
3817
|
-
fromActor: settlement.payer,
|
|
3818
|
-
key: repaymentEvent,
|
|
3819
|
-
kind: "installment",
|
|
3820
|
-
occurrence: "repeatable",
|
|
3821
|
-
toActor: settlement.payee,
|
|
3822
|
-
trigger: "Pay one anchor-bound partial or full installment amount",
|
|
3823
|
-
}),
|
|
3824
|
-
mintEvent({
|
|
3825
|
-
amount: "Exactly one stored paid installment payment",
|
|
3826
|
-
fromActor: settlement.payee,
|
|
3827
|
-
key: refundEvent,
|
|
3828
|
-
kind: "refund",
|
|
3829
|
-
occurrence: "repeatable",
|
|
3830
|
-
toActor: settlement.payer,
|
|
3831
|
-
trigger: "Refund one linked paid installment payment whole",
|
|
3832
|
-
}),
|
|
3833
|
-
],
|
|
3834
|
-
noun: {
|
|
3835
|
-
actors: {
|
|
3836
|
-
...(settlement.advanceTo
|
|
3837
|
-
? { [settlement.advanceTo]: "beneficiary" }
|
|
3838
|
-
: {}),
|
|
3839
|
-
[settlement.payee]: "beneficiary",
|
|
3840
|
-
[settlement.payer]: "payer",
|
|
3841
|
-
[settlement.debtor]: "party",
|
|
3842
|
-
},
|
|
3843
|
-
aggregateInvariants,
|
|
3844
|
-
desc: `Installment obligation for ${settlement.debtor.replaceAll("_", " ")}; ${settlement.payer.replaceAll("_", " ")} pays ${settlement.payee.replaceAll("_", " ")} against exact stored anchors`,
|
|
3845
|
-
fields,
|
|
3846
|
-
id: noun,
|
|
3847
|
-
...partitionsSpread(partitionClause(amountName, installmentFields)),
|
|
3848
|
-
summary: `${settlement.schedule.count}-anchor obligation for ${settlement.debtor.replaceAll("_", " ")}`,
|
|
3849
|
-
title: titleize(noun),
|
|
3850
|
-
verbs,
|
|
3851
|
-
},
|
|
3852
|
-
rules,
|
|
3853
|
-
settlement: { name: noun, pieces: [] },
|
|
3854
|
-
};
|
|
3855
|
-
}
|
|
3856
|
-
function lowerScheduled(settlement) {
|
|
3857
|
-
const noun = settlement.name;
|
|
3858
|
-
const amountName = settlement.amount.name;
|
|
3859
|
-
const { schedule } = settlement;
|
|
3860
|
-
const ruleKey = `${noun}_schedule`;
|
|
3861
|
-
const widths = evenPieceBps(schedule.count);
|
|
3862
|
-
const fields = {
|
|
3863
|
-
[amountName]: moneyFieldSpec(`The total scheduled amount in ${settlement.amount.currency} minor units; the installment fields below partition it exactly`),
|
|
3864
|
-
[schedule.firstDueField]: dateFieldSpec(`Due date of the first installment; installment k falls ${schedule.every.raw} after its predecessor`),
|
|
3865
|
-
};
|
|
3866
|
-
const installmentFields = widths.map((_, index) => {
|
|
3867
|
-
const field = `installment${index + 1}Amount`;
|
|
3868
|
-
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`);
|
|
3869
|
-
return field;
|
|
3870
|
-
});
|
|
3871
|
-
const payVerbs = widths.map((_, index) => `pay_installment_${index + 1}`);
|
|
3872
|
-
const payStates = chain(payVerbs, "active", "settled", "collecting");
|
|
3873
|
-
// The whole schedule is ONE money event (occurrence: repeatable): the
|
|
3874
|
-
// budget counts money BEHAVIORS, not anchors, so a longer schedule never
|
|
3875
|
-
// crowds out a composite program's other settlements. The document still
|
|
3876
|
-
// unrolls to one idempotent anchor verb per installment, all implementing
|
|
3877
|
-
// the same event key.
|
|
3878
|
-
const eventKey = `${noun}_installments`;
|
|
3879
|
-
const events = [
|
|
3880
|
-
mintEvent({
|
|
3881
|
-
amount: `The ${amountName}, partitioned into ${schedule.count} installments`,
|
|
3882
|
-
fromActor: settlement.payer,
|
|
3883
|
-
key: eventKey,
|
|
3884
|
-
kind: "installment",
|
|
3885
|
-
occurrence: "repeatable",
|
|
3886
|
-
toActor: settlement.payee,
|
|
3887
|
-
trigger: `Collect each of the ${schedule.count} installments on its stored due date`,
|
|
3888
|
-
}),
|
|
3889
|
-
];
|
|
3890
|
-
const verbs = {
|
|
3891
|
-
create: {
|
|
3892
|
-
summary: `Create a ${titleize(noun).toLowerCase()} plan`,
|
|
3893
|
-
to: "active",
|
|
3894
|
-
},
|
|
3895
|
-
};
|
|
3896
|
-
for (const [index, field] of installmentFields.entries()) {
|
|
3897
|
-
verbs[payVerbs[index]] = {
|
|
3898
|
-
due: {
|
|
3899
|
-
field: schedule.firstDueField,
|
|
3900
|
-
rule: ruleKey,
|
|
3901
|
-
...anchorOffset(schedule, index),
|
|
3902
|
-
},
|
|
3903
|
-
from: [payStates[index]?.from],
|
|
3904
|
-
moneyEvent: eventKey,
|
|
3905
|
-
moves: [
|
|
3906
|
-
{
|
|
3907
|
-
key: "transfer",
|
|
3908
|
-
operation: "create",
|
|
3909
|
-
amount: field,
|
|
3910
|
-
from: settlement.payer,
|
|
3911
|
-
to: settlement.payee,
|
|
3912
|
-
},
|
|
3913
|
-
],
|
|
3914
|
-
summary: `Collect installment ${index + 1} of ${schedule.count}`,
|
|
3915
|
-
to: payStates[index]?.to,
|
|
3916
|
-
};
|
|
3917
|
-
}
|
|
3918
|
-
return {
|
|
3919
|
-
design: [
|
|
3920
|
-
`${noun}: ${schedule.count} installments every ${schedule.every.raw} from ${schedule.firstDueField}; finite by construction, one idempotent anchor per installment`,
|
|
3921
|
-
],
|
|
3922
|
-
feeLines: [],
|
|
3923
|
-
moneyEvents: events,
|
|
3924
|
-
noun: {
|
|
3925
|
-
actors: {
|
|
3926
|
-
[settlement.payer]: "payer",
|
|
3927
|
-
[settlement.payee]: "beneficiary",
|
|
3928
|
-
},
|
|
3929
|
-
desc: `Scheduled payment: the ${settlement.payer.replaceAll("_", " ")} pays ${amountName} to the ${settlement.payee.replaceAll("_", " ")} in ${schedule.count} installments, one every ${schedule.every.raw}`,
|
|
3930
|
-
fields,
|
|
3931
|
-
id: noun,
|
|
3932
|
-
...partitionsSpread(partitionClause(amountName, installmentFields)),
|
|
3933
|
-
summary: `${schedule.count}-installment schedule from ${settlement.payer.replaceAll("_", " ")} to ${settlement.payee.replaceAll("_", " ")}`,
|
|
3934
|
-
title: titleize(noun),
|
|
3935
|
-
verbs,
|
|
3936
|
-
},
|
|
3937
|
-
rules: [
|
|
3938
|
-
{
|
|
3939
|
-
allowedActors: [],
|
|
3940
|
-
detail: `Each of the ${schedule.count} installments is collected once from its stored due date`,
|
|
3941
|
-
dueDriven: true,
|
|
3942
|
-
enforcement: "platform",
|
|
3943
|
-
gatesEvent: null,
|
|
3944
|
-
key: ruleKey,
|
|
3945
|
-
kind: "deadline",
|
|
3946
|
-
label: "Installments collected on their stored due dates",
|
|
3947
|
-
tenantTunable: false,
|
|
3948
|
-
},
|
|
3949
|
-
],
|
|
3950
|
-
settlement: { name: noun, pieces: [] },
|
|
3951
|
-
};
|
|
3952
|
-
}
|
|
3953
|
-
function lowerAdvance(settlement, recourses) {
|
|
3954
|
-
return settlement.source.kind === "carve"
|
|
3955
|
-
? lowerCarvedAdvance(settlement, settlement.source.settlement, recourses)
|
|
3956
|
-
: lowerScheduledAdvance(settlement, settlement.source.schedule);
|
|
3957
|
-
}
|
|
3958
|
-
/**
|
|
3959
|
-
* `advance { against: <hold>.release }`. The repayment leg is not this noun's
|
|
3960
|
-
* to make: the hold releases the financed party's whole share straight to the
|
|
3961
|
-
* funder, so what stays here is the disbursement, the terms the funder is
|
|
3962
|
-
* owed on, and the close that records the carve landing. An advance carved
|
|
3963
|
-
* this way can never pay out more than the hold already holds.
|
|
3964
|
-
*/
|
|
3965
|
-
function lowerCarvedAdvance(settlement, hold, recourses) {
|
|
3966
|
-
const noun = settlement.name;
|
|
3967
|
-
const amountName = settlement.amount.name;
|
|
3968
|
-
const hasFee = settlement.feeBps > 0;
|
|
3969
|
-
const advancedWords = settlement.advanced.replaceAll("_", " ");
|
|
3970
|
-
const funderWords = settlement.funder.replaceAll("_", " ");
|
|
3971
|
-
const holdWords = hold.replaceAll("_", " ");
|
|
3972
|
-
const holdRefField = "carveHoldId";
|
|
3973
|
-
const referenceBindings = [
|
|
3974
|
-
{ field: holdRefField, statuses: ["funded"], target: hold },
|
|
3975
|
-
...recourses.map((recourse, index) => ({
|
|
3976
|
-
field: `carveRecourse${index + 1}Id`,
|
|
3977
|
-
statuses: ["active"],
|
|
3978
|
-
target: recourse.name,
|
|
3979
|
-
})),
|
|
3980
|
-
];
|
|
3981
|
-
const fields = {
|
|
3982
|
-
[amountName]: moneyFieldSpec(`The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${advancedWords} up front`),
|
|
3983
|
-
...Object.fromEntries(referenceBindings.map((binding) => [
|
|
3984
|
-
binding.field,
|
|
3985
|
-
{
|
|
3986
|
-
desc: `The ${binding.target.replaceAll("_", " ")} bound to this advance`,
|
|
3987
|
-
type: `ref:${binding.target}`,
|
|
3988
|
-
},
|
|
3989
|
-
])),
|
|
3990
|
-
...(hasFee
|
|
3991
|
-
? {
|
|
3992
|
-
feeAmount: moneyFieldSpec(`${formatBps(settlement.feeBps)} of ${amountName}, the funder's discount owed on top of the advance`),
|
|
3993
|
-
repayableAmount: moneyFieldSpec(`${amountName} + feeAmount: what the ${holdWords} release owes the ${funderWords}`),
|
|
3994
|
-
}
|
|
3995
|
-
: {}),
|
|
3996
|
-
};
|
|
3997
|
-
return {
|
|
3998
|
-
design: [
|
|
3999
|
-
`${noun}: ${amountName} advanced to the ${settlement.advanced} up front and repaid by carving the ${hold} release${hasFee ? `; repayableAmount = ${amountName} + ${formatBps(settlement.feeBps)} fee` : ""}`,
|
|
4000
|
-
],
|
|
4001
|
-
feeLines: hasFee
|
|
4002
|
-
? [
|
|
4003
|
-
{
|
|
4004
|
-
label: `${titleize(settlement.funder)} discount`,
|
|
4005
|
-
on: `each ${noun.replaceAll("_", " ")}`,
|
|
4006
|
-
structure: `${formatBps(settlement.feeBps)} of the ${amountName}, owed on top out of the ${holdWords} release`,
|
|
4007
|
-
},
|
|
4008
|
-
]
|
|
4009
|
-
: [],
|
|
4010
|
-
moneyEvents: [
|
|
4011
|
-
mintEvent({
|
|
4012
|
-
amount: `The full ${amountName}`,
|
|
4013
|
-
fromActor: settlement.funder,
|
|
4014
|
-
key: `${noun}_disburse`,
|
|
4015
|
-
kind: "payout",
|
|
4016
|
-
toActor: settlement.advanced,
|
|
4017
|
-
trigger: `Disburse the advance to the ${advancedWords}`,
|
|
4018
|
-
}),
|
|
4019
|
-
],
|
|
4020
|
-
noun: {
|
|
4021
|
-
actors: {
|
|
4022
|
-
[settlement.advanced]: "beneficiary",
|
|
4023
|
-
[settlement.funder]: "payer",
|
|
4024
|
-
},
|
|
4025
|
-
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" : ""}`,
|
|
4026
|
-
fields,
|
|
4027
|
-
id: noun,
|
|
4028
|
-
...partitionsSpread(hasFee
|
|
4029
|
-
? partitionClause("repayableAmount", [amountName, "feeAmount"])
|
|
4030
|
-
: []),
|
|
4031
|
-
summary: `Advance to the ${advancedWords} repaid by carving the ${holdWords} release`,
|
|
4032
|
-
title: titleize(noun),
|
|
4033
|
-
verbs: {
|
|
4034
|
-
create: {
|
|
4035
|
-
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
4036
|
-
to: "created",
|
|
4037
|
-
},
|
|
4038
|
-
disburse: {
|
|
4039
|
-
from: ["created"],
|
|
4040
|
-
moneyEvent: `${noun}_disburse`,
|
|
4041
|
-
moves: [
|
|
4042
|
-
{
|
|
4043
|
-
key: "transfer",
|
|
4044
|
-
operation: "create",
|
|
4045
|
-
amount: amountName,
|
|
4046
|
-
from: settlement.funder,
|
|
4047
|
-
to: settlement.advanced,
|
|
4048
|
-
},
|
|
4049
|
-
],
|
|
4050
|
-
requires: Object.fromEntries(referenceBindings.map((binding) => [
|
|
4051
|
-
binding.field,
|
|
4052
|
-
{
|
|
4053
|
-
match: {
|
|
4054
|
-
[`fields.${amountName}`]: `fields.${amountName}`,
|
|
4055
|
-
"fields.currency": "fields.currency",
|
|
4056
|
-
},
|
|
4057
|
-
statuses: binding.statuses,
|
|
4058
|
-
},
|
|
4059
|
-
])),
|
|
4060
|
-
summary: `Disburse the ${amountName} to the ${advancedWords}`,
|
|
4061
|
-
to: "advanced",
|
|
4062
|
-
},
|
|
4063
|
-
// Moneyless by construction: the repayment already moved, on the hold.
|
|
4064
|
-
// This verb only records that it did, so the advance has a close
|
|
4065
|
-
// instead of resting forever in the state it was disbursed in.
|
|
4066
|
-
settle: {
|
|
4067
|
-
from: ["advanced"],
|
|
4068
|
-
summary: `Close the advance once the ${holdWords} has released to the ${funderWords}`,
|
|
4069
|
-
to: "repaid",
|
|
4070
|
-
},
|
|
4071
|
-
},
|
|
4072
|
-
},
|
|
4073
|
-
rules: [
|
|
4074
|
-
{
|
|
4075
|
-
allowedActors: [],
|
|
4076
|
-
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`,
|
|
4077
|
-
dueDriven: false,
|
|
4078
|
-
enforcement: "platform",
|
|
4079
|
-
gatesEvent: null,
|
|
4080
|
-
key: `${noun}_carve`,
|
|
4081
|
-
kind: "release_condition",
|
|
4082
|
-
label: `Repaid by carving the ${holdWords} release`,
|
|
4083
|
-
tenantTunable: false,
|
|
4084
|
-
},
|
|
4085
|
-
],
|
|
4086
|
-
settlement: { name: noun, pieces: [] },
|
|
4087
|
-
};
|
|
4088
|
-
}
|
|
4089
|
-
function lowerScheduledAdvance(settlement, schedule) {
|
|
4090
|
-
const noun = settlement.name;
|
|
4091
|
-
const amountName = settlement.amount.name;
|
|
4092
|
-
const ruleKey = `${noun}_schedule`;
|
|
4093
|
-
const widths = evenPieceBps(schedule.count);
|
|
4094
|
-
const hasFee = settlement.feeBps > 0;
|
|
4095
|
-
const repayableField = hasFee ? "repayableAmount" : amountName;
|
|
4096
|
-
const fields = {
|
|
4097
|
-
[amountName]: moneyFieldSpec(`The advanced amount in ${settlement.amount.currency} minor units, disbursed to the ${settlement.advanced.replaceAll("_", " ")} up front`),
|
|
4098
|
-
...(hasFee
|
|
4099
|
-
? {
|
|
4100
|
-
feeAmount: moneyFieldSpec(`${formatBps(settlement.feeBps)} of ${amountName}, the funder's discount repaid on top of the advance`),
|
|
4101
|
-
repayableAmount: moneyFieldSpec(`${amountName} + feeAmount: the total the repayment fields below partition exactly`),
|
|
4102
|
-
}
|
|
4103
|
-
: {}),
|
|
4104
|
-
[schedule.firstDueField]: dateFieldSpec(`Due date of the first repayment; repayment k falls ${schedule.every.raw} after its predecessor`),
|
|
4105
|
-
};
|
|
4106
|
-
const repaymentFields = widths.map((_, index) => {
|
|
4107
|
-
const field = `repayment${index + 1}Amount`;
|
|
4108
|
-
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`);
|
|
4109
|
-
return field;
|
|
4110
|
-
});
|
|
4111
|
-
const repayVerbs = widths.map((_, index) => `collect_repayment_${index + 1}`);
|
|
4112
|
-
const repayStates = chain(repayVerbs, "advanced", "repaid", "repaying");
|
|
4113
|
-
const events = [
|
|
4114
|
-
mintEvent({
|
|
4115
|
-
amount: `The full ${amountName}`,
|
|
4116
|
-
fromActor: settlement.funder,
|
|
4117
|
-
key: `${noun}_disburse`,
|
|
4118
|
-
kind: "payout",
|
|
4119
|
-
toActor: settlement.advanced,
|
|
4120
|
-
trigger: `Disburse the advance to the ${settlement.advanced.replaceAll("_", " ")}`,
|
|
4121
|
-
}),
|
|
4122
|
-
];
|
|
4123
|
-
const verbs = {
|
|
4124
|
-
create: {
|
|
4125
|
-
summary: `Create a ${titleize(noun).toLowerCase()}`,
|
|
4126
|
-
to: "created",
|
|
4127
|
-
},
|
|
4128
|
-
disburse: {
|
|
4129
|
-
from: ["created"],
|
|
4130
|
-
moneyEvent: `${noun}_disburse`,
|
|
4131
|
-
moves: [
|
|
4132
|
-
{
|
|
4133
|
-
key: "transfer",
|
|
4134
|
-
operation: "create",
|
|
4135
|
-
amount: amountName,
|
|
4136
|
-
from: settlement.funder,
|
|
4137
|
-
to: settlement.advanced,
|
|
4138
|
-
},
|
|
4139
|
-
],
|
|
4140
|
-
summary: `Disburse the ${amountName} to the ${settlement.advanced.replaceAll("_", " ")}`,
|
|
4141
|
-
to: "advanced",
|
|
4142
|
-
},
|
|
4143
|
-
};
|
|
4144
|
-
// One repeatable event for the whole repayment schedule (see lowerScheduled:
|
|
4145
|
-
// the budget counts money behaviors, not anchors).
|
|
4146
|
-
const repayEventKey = `${noun}_repayments`;
|
|
4147
|
-
events.push(mintEvent({
|
|
4148
|
-
amount: `The ${repayableField}, partitioned into ${schedule.count} repayments`,
|
|
4149
|
-
fromActor: settlement.advanced,
|
|
4150
|
-
key: repayEventKey,
|
|
4151
|
-
kind: "installment",
|
|
4152
|
-
occurrence: "repeatable",
|
|
4153
|
-
toActor: settlement.funder,
|
|
4154
|
-
trigger: `Collect each of the ${schedule.count} repayments on its stored due date`,
|
|
4155
|
-
}));
|
|
4156
|
-
for (const [index, field] of repaymentFields.entries()) {
|
|
4157
|
-
const eventKey = repayEventKey;
|
|
4158
|
-
verbs[repayVerbs[index]] = {
|
|
4159
|
-
due: {
|
|
4160
|
-
field: schedule.firstDueField,
|
|
4161
|
-
rule: ruleKey,
|
|
4162
|
-
...anchorOffset(schedule, index),
|
|
4163
|
-
},
|
|
4164
|
-
from: [repayStates[index]?.from],
|
|
4165
|
-
moneyEvent: eventKey,
|
|
4166
|
-
moves: [
|
|
4167
|
-
{
|
|
4168
|
-
key: "transfer",
|
|
4169
|
-
operation: "create",
|
|
4170
|
-
amount: field,
|
|
4171
|
-
from: settlement.advanced,
|
|
4172
|
-
to: settlement.funder,
|
|
4173
|
-
},
|
|
4174
|
-
],
|
|
4175
|
-
summary: `Collect repayment ${index + 1} of ${schedule.count}`,
|
|
4176
|
-
to: repayStates[index]?.to,
|
|
4177
|
-
};
|
|
4178
|
-
}
|
|
4179
|
-
return {
|
|
4180
|
-
design: [
|
|
4181
|
-
`${noun}: ${amountName} advanced up front; ${schedule.count} repayments every ${schedule.every.raw} conserve against ${repayableField}${hasFee ? ` (advance + ${formatBps(settlement.feeBps)} fee)` : ""}`,
|
|
4182
|
-
],
|
|
4183
|
-
feeLines: hasFee
|
|
4184
|
-
? [
|
|
4185
|
-
{
|
|
4186
|
-
label: `${titleize(settlement.funder)} discount`,
|
|
4187
|
-
on: `each ${noun.replaceAll("_", " ")}`,
|
|
4188
|
-
structure: `${formatBps(settlement.feeBps)} of the ${amountName}, repaid on top of the advance`,
|
|
4189
|
-
},
|
|
4190
|
-
]
|
|
4191
|
-
: [],
|
|
4192
|
-
moneyEvents: events,
|
|
4193
|
-
noun: {
|
|
4194
|
-
actors: {
|
|
4195
|
-
[settlement.funder]: "payer",
|
|
4196
|
-
[settlement.advanced]: "beneficiary",
|
|
4197
|
-
},
|
|
4198
|
-
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" : ""}`,
|
|
4199
|
-
fields,
|
|
4200
|
-
id: noun,
|
|
4201
|
-
...partitionsSpread([
|
|
4202
|
-
...partitionClause(repayableField, repaymentFields),
|
|
4203
|
-
...(hasFee
|
|
4204
|
-
? partitionClause("repayableAmount", [amountName, "feeAmount"])
|
|
4205
|
-
: []),
|
|
4206
|
-
]),
|
|
4207
|
-
summary: `Advance to ${settlement.advanced.replaceAll("_", " ")} repaid over ${schedule.count} anchors`,
|
|
4208
|
-
title: titleize(noun),
|
|
4209
|
-
verbs,
|
|
4210
|
-
},
|
|
4211
|
-
rules: [
|
|
4212
|
-
{
|
|
4213
|
-
allowedActors: [],
|
|
4214
|
-
detail: `Each of the ${schedule.count} repayments is collected once from its stored due date`,
|
|
4215
|
-
dueDriven: true,
|
|
4216
|
-
enforcement: "platform",
|
|
4217
|
-
gatesEvent: null,
|
|
4218
|
-
key: ruleKey,
|
|
4219
|
-
kind: "deadline",
|
|
4220
|
-
label: "Repayments collected on their stored due dates",
|
|
4221
|
-
tenantTunable: false,
|
|
4222
|
-
},
|
|
4223
|
-
],
|
|
4224
|
-
settlement: { name: noun, pieces: [] },
|
|
4225
|
-
};
|
|
4226
|
-
}
|
|
4227
|
-
// ---------------------------------------------------------------------------
|
|
4228
|
-
// metered: each usage charge IS the ledger transfer
|
|
4229
|
-
function lowerMetered(settlement) {
|
|
4230
|
-
const noun = settlement.name;
|
|
4231
|
-
const ruleKey = `${noun}_period`;
|
|
4232
|
-
const currency = settlement.rates[0]?.field.currency ?? "SAR";
|
|
4233
|
-
const fields = {
|
|
4234
|
-
[settlement.closeByField]: dateFieldSpec("End of this metering period; the close makes further charges unreachable"),
|
|
4235
|
-
};
|
|
4236
|
-
for (const rate of settlement.rates) {
|
|
4237
|
-
fields[rate.field.name] = moneyFieldSpec(`Per-unit price of ${rate.meter.replaceAll("_", " ")} in ${currency} minor units, committed at period open`);
|
|
4238
|
-
}
|
|
4239
|
-
const events = [];
|
|
4240
|
-
const verbs = {
|
|
4241
|
-
close_period: {
|
|
4242
|
-
due: { field: settlement.closeByField, rule: ruleKey },
|
|
4243
|
-
from: ["open"],
|
|
4244
|
-
summary: "Close the metering period; no further usage can be charged",
|
|
4245
|
-
to: "closed",
|
|
4246
|
-
},
|
|
4247
|
-
create: {
|
|
4248
|
-
summary: `Open a ${titleize(noun).toLowerCase()} period with its committed rate card`,
|
|
4249
|
-
to: "open",
|
|
4250
|
-
},
|
|
4251
|
-
};
|
|
4252
|
-
for (const rate of settlement.rates) {
|
|
4253
|
-
const eventKey = frameKey(`${noun}_${rate.meter}`);
|
|
4254
|
-
events.push(mintEvent({
|
|
4255
|
-
amount: `The committed ${rate.field.name} per unit`,
|
|
4256
|
-
fromActor: settlement.payer,
|
|
4257
|
-
key: eventKey,
|
|
4258
|
-
kind: "charge",
|
|
4259
|
-
occurrence: "repeatable",
|
|
4260
|
-
timing: "external_schedule",
|
|
4261
|
-
toActor: settlement.payee,
|
|
4262
|
-
trigger: `Charge one ${rate.meter.replaceAll("_", " ")} at the committed rate`,
|
|
4263
|
-
}));
|
|
4264
|
-
verbs[`charge_${rate.meter}`] = {
|
|
4265
|
-
from: ["open"],
|
|
4266
|
-
moneyEvent: eventKey,
|
|
4267
|
-
moves: [
|
|
4268
|
-
{
|
|
4269
|
-
key: "transfer",
|
|
4270
|
-
operation: "create",
|
|
4271
|
-
amount: rate.field.name,
|
|
4272
|
-
from: settlement.payer,
|
|
4273
|
-
to: settlement.payee,
|
|
4274
|
-
},
|
|
4275
|
-
],
|
|
4276
|
-
summary: `Charge one metered ${rate.meter.replaceAll("_", " ")}; the emission is the transfer itself`,
|
|
4277
|
-
to: "open",
|
|
4278
|
-
};
|
|
4279
|
-
}
|
|
4280
|
-
return {
|
|
4281
|
-
design: [
|
|
4282
|
-
`${noun}: committed rate card (${settlement.rates
|
|
4283
|
-
.map((rate) => rate.meter)
|
|
4284
|
-
.join(", ")}); each usage charge IS the ledger transfer; period closes on ${settlement.closeByField}`,
|
|
4285
|
-
],
|
|
4286
|
-
feeLines: [],
|
|
4287
|
-
moneyEvents: events,
|
|
4288
|
-
noun: {
|
|
4289
|
-
actors: {
|
|
4290
|
-
[settlement.payer]: "payer",
|
|
4291
|
-
[settlement.payee]: "beneficiary",
|
|
4292
|
-
},
|
|
4293
|
-
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`,
|
|
4294
|
-
fields,
|
|
4295
|
-
id: noun,
|
|
4296
|
-
summary: `Metered charges from ${settlement.payer.replaceAll("_", " ")} on a committed rate card`,
|
|
4297
|
-
title: titleize(noun),
|
|
4298
|
-
verbs,
|
|
4299
|
-
},
|
|
4300
|
-
rules: [
|
|
4301
|
-
{
|
|
4302
|
-
allowedActors: [],
|
|
4303
|
-
detail: "The period closes once from its stored end date",
|
|
4304
|
-
dueDriven: true,
|
|
4305
|
-
enforcement: "platform",
|
|
4306
|
-
gatesEvent: null,
|
|
4307
|
-
key: ruleKey,
|
|
4308
|
-
kind: "deadline",
|
|
4309
|
-
label: "Period closed on its stored end date",
|
|
4310
|
-
tenantTunable: false,
|
|
4311
|
-
},
|
|
4312
|
-
],
|
|
4313
|
-
settlement: { name: noun, pieces: [] },
|
|
4314
|
-
};
|
|
4315
|
-
}
|
|
4316
|
-
// ---------------------------------------------------------------------------
|
|
4317
|
-
// pooled_split: pool a period total piece-wise, distribute it exactly
|
|
4318
|
-
function lowerPooledSplit(settlement) {
|
|
4319
|
-
const noun = settlement.name;
|
|
4320
|
-
const amountName = settlement.amount.name;
|
|
4321
|
-
const ruleKey = `${noun}_payout`;
|
|
4322
|
-
const remainderIndex = settlement.shares.findIndex((share) => share.to === settlement.remainderTo);
|
|
4323
|
-
const pieces = settlement.shares.map((share) => ({
|
|
4324
|
-
bps: share.bps,
|
|
4325
|
-
field: `${camelize(share.to)}ShareAmount`,
|
|
4326
|
-
origin: share.origin,
|
|
4327
|
-
releaseTo: share.to,
|
|
4328
|
-
}));
|
|
4329
|
-
const fields = {
|
|
4330
|
-
[amountName]: moneyFieldSpec(`The pooled period total in ${settlement.amount.currency} minor units; the share fields below partition it exactly`),
|
|
4331
|
-
[settlement.distributeDueField]: dateFieldSpec("The period's payout date; the pool distributes from it"),
|
|
4332
|
-
};
|
|
4333
|
-
for (const [index, piece] of pieces.entries()) {
|
|
4334
|
-
const remainder = index === Math.max(remainderIndex, 0)
|
|
4335
|
-
? " (carries the integer-division remainder)"
|
|
4336
|
-
: "";
|
|
4337
|
-
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`);
|
|
4338
|
-
}
|
|
4339
|
-
const fundVerbs = pieces.map((_, index) => `fund_share_${index + 1}`);
|
|
4340
|
-
const payoutVerbs = pieces.map((_, index) => `distribute_share_${index + 1}`);
|
|
4341
|
-
const fundStates = chain(fundVerbs, "created", "pooled", "pooling");
|
|
4342
|
-
const payoutStates = chain(payoutVerbs, "pooled", "distributed", "distributing");
|
|
4343
|
-
const events = [];
|
|
4344
|
-
const verbs = {
|
|
4345
|
-
create: {
|
|
4346
|
-
summary: `Open a ${titleize(noun).toLowerCase()} period`,
|
|
4347
|
-
to: "created",
|
|
4348
|
-
},
|
|
4349
|
-
};
|
|
4350
|
-
for (const [index, piece] of pieces.entries()) {
|
|
4351
|
-
const eventKey = `${noun}_pool_${index + 1}`;
|
|
4352
|
-
events.push(mintEvent({
|
|
4353
|
-
amount: `${formatBps(piece.bps)} of the ${amountName}`,
|
|
4354
|
-
fromActor: settlement.payer,
|
|
4355
|
-
key: eventKey,
|
|
4356
|
-
kind: "charge",
|
|
4357
|
-
toActor: "escrow",
|
|
4358
|
-
trigger: `Pool the ${piece.releaseTo.replaceAll("_", " ")}'s share for the period`,
|
|
4359
|
-
}));
|
|
4360
|
-
verbs[fundVerbs[index]] = {
|
|
4361
|
-
from: [fundStates[index]?.from],
|
|
4362
|
-
moneyEvent: eventKey,
|
|
4363
|
-
moves: [
|
|
4364
|
-
{
|
|
4365
|
-
key: "transfer",
|
|
4366
|
-
operation: "create",
|
|
4367
|
-
amount: piece.field,
|
|
4368
|
-
from: settlement.payer,
|
|
4369
|
-
to: "escrow",
|
|
4370
|
-
},
|
|
4371
|
-
],
|
|
4372
|
-
summary: `Pool share ${index + 1} of the period total`,
|
|
4373
|
-
to: fundStates[index]?.to,
|
|
4374
|
-
};
|
|
4375
|
-
}
|
|
4376
|
-
for (const [index, piece] of pieces.entries()) {
|
|
4377
|
-
const eventKey = `${noun}_payout_${index + 1}`;
|
|
4378
|
-
events.push(mintEvent({
|
|
4379
|
-
amount: `${formatBps(piece.bps)} of the ${amountName}`,
|
|
4380
|
-
fromActor: "escrow",
|
|
4381
|
-
key: eventKey,
|
|
4382
|
-
kind: "payout",
|
|
4383
|
-
toActor: piece.releaseTo,
|
|
4384
|
-
trigger: `Distribute the ${piece.releaseTo.replaceAll("_", " ")}'s share on the payout date`,
|
|
4385
|
-
}));
|
|
4386
|
-
verbs[payoutVerbs[index]] = {
|
|
4387
|
-
due: { field: settlement.distributeDueField, rule: ruleKey },
|
|
4388
|
-
from: [payoutStates[index]?.from],
|
|
4389
|
-
moneyEvent: eventKey,
|
|
4390
|
-
moves: [
|
|
4391
|
-
{
|
|
4392
|
-
key: "transfer",
|
|
4393
|
-
operation: "create",
|
|
4394
|
-
amount: piece.field,
|
|
4395
|
-
from: "escrow",
|
|
4396
|
-
to: piece.releaseTo,
|
|
4397
|
-
},
|
|
4398
|
-
],
|
|
4399
|
-
summary: `Distribute the ${piece.releaseTo.replaceAll("_", " ")}'s share of the pool`,
|
|
4400
|
-
to: payoutStates[index]?.to,
|
|
4401
|
-
};
|
|
4402
|
-
}
|
|
4403
|
-
return {
|
|
4404
|
-
design: [
|
|
4405
|
-
`${noun}: pool of ${amountName} partitioned ${pieces
|
|
4406
|
-
.map((piece) => `${formatBps(piece.bps)} ${piece.releaseTo}`)
|
|
4407
|
-
.join(" + ")}; distributes in full on ${settlement.distributeDueField}; remainder to ${settlement.remainderTo}`,
|
|
4408
|
-
],
|
|
4409
|
-
feeLines: [],
|
|
4410
|
-
moneyEvents: events,
|
|
4411
|
-
noun: {
|
|
4412
|
-
actors: {
|
|
4413
|
-
[settlement.payer]: "payer",
|
|
4414
|
-
...Object.fromEntries(settlement.shares.map((share) => [share.to, "beneficiary"])),
|
|
4415
|
-
},
|
|
4416
|
-
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`,
|
|
4417
|
-
escrow: true,
|
|
4418
|
-
fields,
|
|
4419
|
-
id: noun,
|
|
4420
|
-
...partitionsSpread(partitionClause(amountName, pieces.map((piece) => piece.field))),
|
|
4421
|
-
summary: `Period pool from ${settlement.payer.replaceAll("_", " ")} split ${settlement.shares.length} ways`,
|
|
4422
|
-
title: titleize(noun),
|
|
4423
|
-
verbs,
|
|
4424
|
-
},
|
|
4425
|
-
rules: [
|
|
4426
|
-
{
|
|
4427
|
-
allowedActors: [],
|
|
4428
|
-
detail: "Every share of the pool distributes once from the stored payout date",
|
|
4429
|
-
dueDriven: true,
|
|
4430
|
-
enforcement: "platform",
|
|
4431
|
-
gatesEvent: null,
|
|
4432
|
-
key: ruleKey,
|
|
4433
|
-
kind: "deadline",
|
|
4434
|
-
label: "Pool distributed on its stored payout date",
|
|
4435
|
-
tenantTunable: false,
|
|
4436
|
-
},
|
|
4437
|
-
],
|
|
4438
|
-
settlement: { name: noun, pieces },
|
|
4439
|
-
};
|
|
4440
|
-
}
|
|
4441
|
-
/**
|
|
4442
|
-
* The finest common partition of the amount across both exits. Cut points
|
|
4443
|
-
* come from the release allocation (payee share, then the payee-side fee to
|
|
4444
|
-
* the platform) and the cancellation split; every resulting interval becomes
|
|
4445
|
-
* one piece with a fixed destination per exit.
|
|
4446
|
-
*
|
|
4447
|
-
* A carve changes only WHO the payee's share is released to. It is not a cut
|
|
4448
|
-
* point: the funder takes the payee's whole share, so a carved hold has the
|
|
4449
|
-
* same pieces as an uncarved one and the platform's fee is untouched.
|
|
4450
|
-
*/
|
|
4451
|
-
function partitionPieces(input) {
|
|
4452
|
-
const total = Number(TOTAL_BPS);
|
|
4453
|
-
const releaseTo = input.carveTo ?? input.payee;
|
|
4454
|
-
const release = [];
|
|
4455
|
-
if (input.payeeFeeBps < total) {
|
|
4456
|
-
release.push({ end: total - input.payeeFeeBps, to: releaseTo });
|
|
4457
|
-
}
|
|
4458
|
-
if (input.payeeFeeBps > 0)
|
|
4459
|
-
release.push({ end: total, to: "platform" });
|
|
4460
|
-
const cancel = [];
|
|
4461
|
-
let cumulative = 0;
|
|
4462
|
-
for (const share of input.onCancel?.shares ?? []) {
|
|
4463
|
-
cumulative += share.bps;
|
|
4464
|
-
cancel.push({ end: cumulative, origin: share.origin, to: share.to });
|
|
4465
|
-
}
|
|
4466
|
-
const cuts = [
|
|
4467
|
-
...new Set([
|
|
4468
|
-
...release.map((segment) => segment.end),
|
|
4469
|
-
...cancel.map((segment) => segment.end),
|
|
4470
|
-
total,
|
|
4471
|
-
]),
|
|
4472
|
-
].sort((left, right) => left - right);
|
|
4473
|
-
const destinationAt = (segments, start) => segments.find((segment) => start < segment.end);
|
|
4474
|
-
const pieces = [];
|
|
4475
|
-
let start = 0;
|
|
4476
|
-
for (const cut of cuts) {
|
|
4477
|
-
if (cut <= start)
|
|
4478
|
-
continue;
|
|
4479
|
-
const releaseSegment = destinationAt(release, start);
|
|
4480
|
-
const cancelSegment = destinationAt(cancel, start);
|
|
4481
|
-
pieces.push({
|
|
4482
|
-
bps: cut - start,
|
|
4483
|
-
...(cancelSegment ? { cancelTo: cancelSegment.to } : {}),
|
|
4484
|
-
field: `piece${pieces.length + 1}Amount`,
|
|
4485
|
-
origin: cancelSegment?.origin ?? input.amount.origin,
|
|
4486
|
-
releaseTo: releaseSegment?.to ?? releaseTo,
|
|
4487
|
-
});
|
|
4488
|
-
start = cut;
|
|
4489
|
-
}
|
|
4490
|
-
return pieces;
|
|
4491
|
-
}
|
|
4492
|
-
function pieceDescription(piece, index, amountName, currency) {
|
|
4493
|
-
const cancelLeg = piece.cancelTo
|
|
4494
|
-
? `; on cancellation to the ${piece.cancelTo.replaceAll("_", " ")}`
|
|
4495
|
-
: "";
|
|
4496
|
-
const remainder = index === 0 ? " (carries the integer-division remainder)" : "";
|
|
4497
|
-
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`;
|
|
4498
|
-
}
|
|
4499
|
-
function formatBps(bps) {
|
|
4500
|
-
const percent = bps / 100;
|
|
4501
|
-
return `${Number.isInteger(percent) ? percent : percent.toFixed(2).replace(/0$/, "")}%`;
|
|
4502
|
-
}
|
|
4503
|
-
function summarize(program) {
|
|
4504
|
-
const carveFunderByHold = new Map(program.settlements.flatMap((settlement) => settlement.archetype === "advance" && settlement.source.kind === "carve"
|
|
4505
|
-
? [[settlement.source.settlement, settlement.funder]]
|
|
4506
|
-
: []));
|
|
4507
|
-
const lines = program.settlements.map((settlement) => {
|
|
4508
|
-
switch (settlement.archetype) {
|
|
4509
|
-
case "held_payment": {
|
|
4510
|
-
const cancel = settlement.onCancel
|
|
4511
|
-
? `; cancellation splits the held amount ${settlement.onCancel.shares
|
|
4512
|
-
.map((share) => `${formatBps(share.bps)} to the ${share.to.replaceAll("_", " ")}`)
|
|
4513
|
-
.join(" and ")}`
|
|
4514
|
-
: "";
|
|
4515
|
-
const carveTo = carveFunderByHold.get(settlement.name);
|
|
4516
|
-
const paid = carveTo
|
|
4517
|
-
? `the ${carveTo.replaceAll("_", " ")} is paid on confirmed release, in the ${settlement.payee.replaceAll("_", " ")}'s place`
|
|
4518
|
-
: `the ${settlement.payee.replaceAll("_", " ")} is paid on confirmed release`;
|
|
4519
|
-
return `The ${settlement.payer.replaceAll("_", " ")} funds ${settlement.amount.name} into escrow and ${paid}${cancel}`;
|
|
4520
|
-
}
|
|
4521
|
-
case "captured_payment":
|
|
4522
|
-
return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved until ${settlement.reserveUntilField}, captured by the ${settlement.payee.replaceAll("_", " ")} in strict partial slices, then settled or released`;
|
|
4523
|
-
case "instant_transfer":
|
|
4524
|
-
return `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} straight through to the ${settlement.payee.replaceAll("_", " ")}`;
|
|
4525
|
-
case "premium_forward":
|
|
4526
|
-
return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} forwards to the ${settlement.carrier.replaceAll("_", " ")} exactly once on binding`;
|
|
4527
|
-
case "deposit":
|
|
4528
|
-
return `The ${settlement.payer.replaceAll("_", " ")}'s ${settlement.amount.name} is reserved for the ${settlement.holder.replaceAll("_", " ")} until claimed or returned`;
|
|
4529
|
-
case "scheduled":
|
|
4530
|
-
return settlement.mode === "obligation"
|
|
4531
|
-
? `The ${settlement.debtor.replaceAll("_", " ")} owes ${settlement.amount.name}; ${settlement.advanceTo ? `the ${settlement.payee.replaceAll("_", " ")} advances it to the ${settlement.advanceTo.replaceAll("_", " ")}, then ` : ""}the ${settlement.payer.replaceAll("_", " ")} repays the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} anchor-bound installments`
|
|
4532
|
-
: `The ${settlement.payer.replaceAll("_", " ")} pays ${settlement.amount.name} to the ${settlement.payee.replaceAll("_", " ")} over ${settlement.schedule.count} scheduled installments`;
|
|
4533
|
-
case "advance":
|
|
4534
|
-
return settlement.source.kind === "carve"
|
|
4535
|
-
? `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid out of the ${settlement.source.settlement.replaceAll("_", " ")} release`
|
|
4536
|
-
: `The ${settlement.funder.replaceAll("_", " ")} advances ${settlement.amount.name} to the ${settlement.advanced.replaceAll("_", " ")}, repaid over ${settlement.source.schedule.count} anchors`;
|
|
4537
|
-
case "metered":
|
|
4538
|
-
return `The ${settlement.payer.replaceAll("_", " ")} is charged per metered unit at a committed rate card until the period closes`;
|
|
4539
|
-
case "pooled_split":
|
|
4540
|
-
return `The ${settlement.payer.replaceAll("_", " ")} pools ${settlement.amount.name} and it distributes ${settlement.shares.length} ways on the payout date`;
|
|
4541
|
-
case "settlement_batch":
|
|
4542
|
-
return `The ${settlement.settlementAccount.replaceAll("_", " ")} freezes capture lineage and pays one signed net amount to the ${settlement.payoutDestination.replaceAll("_", " ")}`;
|
|
4543
|
-
case "funding_round":
|
|
4544
|
-
return `The ${settlement.contributor.replaceAll("_", " ")} commits under ${settlement.target.name} until the stored close anchor activates or fails the round`;
|
|
4545
|
-
case "weighted_distribution":
|
|
4546
|
-
return `The ${settlement.source.replaceAll("_", " ")} pays a frozen claimant set by deterministic largest remainder`;
|
|
4547
|
-
case "credit_facility":
|
|
4548
|
-
return `The ${settlement.lender.replaceAll("_", " ")} admits draws under ${settlement.limit.name}; ${settlement.obligation.settlement.replaceAll("_", " ")} owns repayment`;
|
|
4549
|
-
case "recurring_collection":
|
|
4550
|
-
return `${settlement.name.replaceAll("_", " ")} adds explicit mandate evidence to ${settlement.obligation.settlement.replaceAll("_", " ")} repayment attempts`;
|
|
4551
|
-
case "conditional_disbursement":
|
|
4552
|
-
return `The ${settlement.source.replaceAll("_", " ")} pays one evidence-approved amount under ${settlement.cap.name} to the ${settlement.destination.replaceAll("_", " ")}`;
|
|
4553
|
-
case "rotating_pool":
|
|
4554
|
-
return `${settlement.members.length} fixed members contribute one exact amount per cycle in a stored payout order`;
|
|
4555
|
-
case "swap":
|
|
4556
|
-
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`;
|
|
4557
|
-
}
|
|
4558
|
-
});
|
|
4559
|
-
return `${lines.join(". ")}.`.slice(0, 400);
|
|
4560
|
-
}
|
|
4561
|
-
function camelize(snake) {
|
|
4562
|
-
const [head, ...rest] = snake.split("_");
|
|
4563
|
-
return ((head ?? "") +
|
|
4564
|
-
rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(""));
|
|
4565
|
-
}
|
|
4566
|
-
function titleize(snake) {
|
|
4567
|
-
const spaced = snake.replaceAll("_", " ");
|
|
4568
|
-
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
4569
|
-
}
|
|
4570
|
-
//# sourceMappingURL=lower.js.map
|