eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// web/status/waterfall.js — the funding waterfall's reference implementation.
2//
3// Normative spec: docs/funding.md (§ The waterfall). A note legally pins that
4// prose at a git commit; this module is the runnable form of the same
5// computation, so anyone can re-execute a published quarter straight from
6// web/status/funding.json — an audit is a re-execution. It lives HERE, in the
7// published status dir, so the public Investing page (funding.html) imports
8// and runs the very file it serves — the simulator on that page IS this
9// algorithm, not a copy of it. Node-side consumers import the re-export shim
10// tools/waterfall.mjs. Pure functions only: no clock, no filesystem, no
11// network, no DOM. All amounts are integer cents, because floats have no
12// business near money.
13
14// The algorithm's parameters. Changing any of these is a new algorithm
15// version (docs/funding.md § Changing the algorithm), never a code tweak.
16export const SPLIT = { investors: 60, team: 25, commons: 15 }; // % of D
17export const DEFAULT_SEASONING_MONTHS = 24;
18export const RESERVE_MONTHS = 12; // the floor behind reserveTopUp (computed upstream)
19export const CAP_RANGE = [1.5, 3]; // a series fixes its cap multiple in here
20export const DEFAULT_CAP_MULTIPLE = 2;
21
22// Whole calendar months between two ISO dates (UTC). A part-month doesn't
23// count: 2026-09-30 → 2028-09-29 is 23 months, → 2028-09-30 is 24.
24export function monthsBetween(fromISO, toISO) {
25 const a = new Date(fromISO + "T00:00:00Z");
26 const b = new Date(toISO + "T00:00:00Z");
27 let m = (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth());
28 if (b.getUTCDate() < a.getUTCDate()) m--;
29 return m;
30}
31
32// A series is seasoned when BOTH legs of the spec's "later of" hold: the
33// calendar has passed, and the reserve floor held for the two most recent
34// published quarters. Fewer than two published quarters can never season —
35// a brand-new company distributes to no one.
36export function seasoned(series, asOf, priorQuarters) {
37 const months = series.seasoningMonths ?? DEFAULT_SEASONING_MONTHS;
38 if (!series.closedAt || monthsBetween(series.closedAt, asOf) < months) return false;
39 const recent = priorQuarters.slice(-2);
40 return recent.length === 2 && recent.every((q) => q.floorMet === true);
41}
42
43// D = R − C − F. May be ≤ 0 — the caller treats that as "distribute nothing".
44export function distributable({ revenue, costs, reserveTopUp }) {
45 return revenue - costs - reserveTopUp;
46}
47
48// Split D into the three pools. The investor and team pools round down, so
49// the leftover cents always land with the commons — rounding never favours
50// the money.
51export function splitSurplus(D) {
52 const investors = Math.floor((D * SPLIT.investors) / 100);
53 const team = Math.floor((D * SPLIT.team) / 100);
54 return { investors, team, commons: D - investors - team };
55}
56
57// Pro-rata by remaining cap, deterministic to the cent: floor allocations
58// first, then the leftover cents go one at a time by largest fractional
59// remainder, ties broken by note id — two independent runs can never
60// disagree. No note is ever paid past its cap; if the pool covers every cap,
61// the excess is `residue`, whose destination is a published decision
62// (docs/funding.md), not this algorithm's.
63export function allocate(pool, notes) {
64 const eligible = notes
65 .filter((n) => n.remainingCap > 0)
66 .slice()
67 .sort((a, b) => (a.id < b.id ? -1 : 1));
68 if (pool <= 0 || eligible.length === 0) return { payments: [], residue: 0 };
69 const totalCap = eligible.reduce((s, n) => s + n.remainingCap, 0);
70 if (pool >= totalCap) {
71 return {
72 payments: eligible.map((n) => ({ note: n.id, amount: n.remainingCap })),
73 residue: pool - totalCap,
74 };
75 }
76 const shares = eligible.map((n) => {
77 const exact = (pool * n.remainingCap) / totalCap;
78 return { note: n.id, cap: n.remainingCap, amount: Math.floor(exact), frac: exact - Math.floor(exact) };
79 });
80 // After flooring, every note sits strictly below its cap and the leftover
81 // cents number fewer than the notes, so one pass always places them all.
82 let left = pool - shares.reduce((s, x) => s + x.amount, 0);
83 const byRemainder = shares.slice().sort((a, b) => b.frac - a.frac || (a.note < b.note ? -1 : 1));
84 for (const s of byRemainder) {
85 if (left === 0) break;
86 if (s.amount < s.cap) { s.amount++; left--; }
87 }
88 return { payments: shares.filter((s) => s.amount > 0).map(({ note, amount }) => ({ note, amount })), residue: 0 };
89}
90
91// One quarter, end to end — docs/funding.md § The waterfall, runnable.
92// `ledger` is the published funding.json; `quarter` is this quarter's inputs.
93export function waterfall(ledger, quarter) {
94 const D = distributable(quarter);
95 const base = {
96 quarter: quarter.id,
97 inputs: { revenue: quarter.revenue, costs: quarter.costs, reserveTopUp: quarter.reserveTopUp },
98 distributable: D,
99 distributed: false,
100 investorPool: 0,
101 teamPool: 0,
102 commonsPool: 0,
103 payments: [],
104 retained: 0,
105 residue: 0,
106 extinguished: [],
107 notes: ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap })),
108 };
109 if (D <= 0) return base;
110
111 const { investors, team, commons } = splitSurplus(D);
112 const seasonedIds = new Set(
113 ledger.series.filter((s) => seasoned(s, quarter.asOf, ledger.quarters ?? [])).map((s) => s.id),
114 );
115 // Seasoning gates noteholder payments only — team and commons still flow.
116 const { payments, residue } = allocate(investors, ledger.notes.filter((n) => seasonedIds.has(n.series)));
117 const paid = new Map(payments.map((p) => [p.note, p.amount]));
118 const after = ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap - (paid.get(n.id) ?? 0) }));
119 return {
120 ...base,
121 distributed: true,
122 investorPool: investors,
123 teamPool: team,
124 commonsPool: commons,
125 payments,
126 residue,
127 // With no seasoned note to pay, the investor share sits in the treasury —
128 // it is never redirected to the other pools.
129 retained: payments.length === 0 ? investors : 0,
130 extinguished: after.filter((n) => n.remainingCap === 0 && (paid.get(n.id) ?? 0) > 0).map((n) => n.id),
131 notes: after,
132 };
133}
134
135// Fold a computed quarter back into the ledger. The published funding.json
136// for the next quarter is exactly this output — publishing and computing are
137// the same data structure, which is what makes re-execution an audit.
138export function applyQuarter(ledger, quarter) {
139 const result = waterfall(ledger, quarter);
140 const caps = new Map(result.notes.map((n) => [n.id, n.remainingCap]));
141 return {
142 result,
143 ledger: {
144 ...ledger,
145 notes: ledger.notes.map((n) => ({ ...n, remainingCap: caps.get(n.id) })),
146 quarters: [
147 ...(ledger.quarters ?? []),
148 {
149 id: quarter.id,
150 asOf: quarter.asOf,
151 revenue: quarter.revenue,
152 costs: quarter.costs,
153 reserveTopUp: quarter.reserveTopUp,
154 floorMet: quarter.floorMet === true,
155 distributed: result.distributed,
156 investorPool: result.investorPool,
157 teamPool: result.teamPool,
158 commonsPool: result.commonsPool,
159 payments: result.payments,
160 retained: result.retained,
161 residue: result.residue,
162 },
163 ],
164 },
165 };
166}
167