// web/status/waterfall.js — the funding waterfall's reference implementation. // // Normative spec: docs/funding.md (§ The waterfall). A note legally pins that // prose at a git commit; this module is the runnable form of the same // computation, so anyone can re-execute a published quarter straight from // web/status/funding.json — an audit is a re-execution. It lives HERE, in the // published status dir, so the public Investing page (funding.html) imports // and runs the very file it serves — the simulator on that page IS this // algorithm, not a copy of it. Node-side consumers import the re-export shim // tools/waterfall.mjs. Pure functions only: no clock, no filesystem, no // network, no DOM. All amounts are integer cents, because floats have no // business near money. // The algorithm's parameters. Changing any of these is a new algorithm // version (docs/funding.md § Changing the algorithm), never a code tweak. export const SPLIT = { investors: 60, team: 25, commons: 15 }; // % of D export const DEFAULT_SEASONING_MONTHS = 24; export const RESERVE_MONTHS = 12; // the floor behind reserveTopUp (computed upstream) export const CAP_RANGE = [1.5, 3]; // a series fixes its cap multiple in here export const DEFAULT_CAP_MULTIPLE = 2; // Whole calendar months between two ISO dates (UTC). A part-month doesn't // count: 2026-09-30 → 2028-09-29 is 23 months, → 2028-09-30 is 24. export function monthsBetween(fromISO, toISO) { const a = new Date(fromISO + "T00:00:00Z"); const b = new Date(toISO + "T00:00:00Z"); let m = (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth()); if (b.getUTCDate() < a.getUTCDate()) m--; return m; } // A series is seasoned when BOTH legs of the spec's "later of" hold: the // calendar has passed, and the reserve floor held for the two most recent // published quarters. Fewer than two published quarters can never season — // a brand-new company distributes to no one. export function seasoned(series, asOf, priorQuarters) { const months = series.seasoningMonths ?? DEFAULT_SEASONING_MONTHS; if (!series.closedAt || monthsBetween(series.closedAt, asOf) < months) return false; const recent = priorQuarters.slice(-2); return recent.length === 2 && recent.every((q) => q.floorMet === true); } // D = R − C − F. May be ≤ 0 — the caller treats that as "distribute nothing". export function distributable({ revenue, costs, reserveTopUp }) { return revenue - costs - reserveTopUp; } // Split D into the three pools. The investor and team pools round down, so // the leftover cents always land with the commons — rounding never favours // the money. export function splitSurplus(D) { const investors = Math.floor((D * SPLIT.investors) / 100); const team = Math.floor((D * SPLIT.team) / 100); return { investors, team, commons: D - investors - team }; } // Pro-rata by remaining cap, deterministic to the cent: floor allocations // first, then the leftover cents go one at a time by largest fractional // remainder, ties broken by note id — two independent runs can never // disagree. No note is ever paid past its cap; if the pool covers every cap, // the excess is `residue`, whose destination is a published decision // (docs/funding.md), not this algorithm's. export function allocate(pool, notes) { const eligible = notes .filter((n) => n.remainingCap > 0) .slice() .sort((a, b) => (a.id < b.id ? -1 : 1)); if (pool <= 0 || eligible.length === 0) return { payments: [], residue: 0 }; const totalCap = eligible.reduce((s, n) => s + n.remainingCap, 0); if (pool >= totalCap) { return { payments: eligible.map((n) => ({ note: n.id, amount: n.remainingCap })), residue: pool - totalCap, }; } const shares = eligible.map((n) => { const exact = (pool * n.remainingCap) / totalCap; return { note: n.id, cap: n.remainingCap, amount: Math.floor(exact), frac: exact - Math.floor(exact) }; }); // After flooring, every note sits strictly below its cap and the leftover // cents number fewer than the notes, so one pass always places them all. let left = pool - shares.reduce((s, x) => s + x.amount, 0); const byRemainder = shares.slice().sort((a, b) => b.frac - a.frac || (a.note < b.note ? -1 : 1)); for (const s of byRemainder) { if (left === 0) break; if (s.amount < s.cap) { s.amount++; left--; } } return { payments: shares.filter((s) => s.amount > 0).map(({ note, amount }) => ({ note, amount })), residue: 0 }; } // One quarter, end to end — docs/funding.md § The waterfall, runnable. // `ledger` is the published funding.json; `quarter` is this quarter's inputs. export function waterfall(ledger, quarter) { const D = distributable(quarter); const base = { quarter: quarter.id, inputs: { revenue: quarter.revenue, costs: quarter.costs, reserveTopUp: quarter.reserveTopUp }, distributable: D, distributed: false, investorPool: 0, teamPool: 0, commonsPool: 0, payments: [], retained: 0, residue: 0, extinguished: [], notes: ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap })), }; if (D <= 0) return base; const { investors, team, commons } = splitSurplus(D); const seasonedIds = new Set( ledger.series.filter((s) => seasoned(s, quarter.asOf, ledger.quarters ?? [])).map((s) => s.id), ); // Seasoning gates noteholder payments only — team and commons still flow. const { payments, residue } = allocate(investors, ledger.notes.filter((n) => seasonedIds.has(n.series))); const paid = new Map(payments.map((p) => [p.note, p.amount])); const after = ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap - (paid.get(n.id) ?? 0) })); return { ...base, distributed: true, investorPool: investors, teamPool: team, commonsPool: commons, payments, residue, // With no seasoned note to pay, the investor share sits in the treasury — // it is never redirected to the other pools. retained: payments.length === 0 ? investors : 0, extinguished: after.filter((n) => n.remainingCap === 0 && (paid.get(n.id) ?? 0) > 0).map((n) => n.id), notes: after, }; } // Fold a computed quarter back into the ledger. The published funding.json // for the next quarter is exactly this output — publishing and computing are // the same data structure, which is what makes re-execution an audit. export function applyQuarter(ledger, quarter) { const result = waterfall(ledger, quarter); const caps = new Map(result.notes.map((n) => [n.id, n.remainingCap])); return { result, ledger: { ...ledger, notes: ledger.notes.map((n) => ({ ...n, remainingCap: caps.get(n.id) })), quarters: [ ...(ledger.quarters ?? []), { id: quarter.id, asOf: quarter.asOf, revenue: quarter.revenue, costs: quarter.costs, reserveTopUp: quarter.reserveTopUp, floorMet: quarter.floorMet === true, distributed: result.distributed, investorPool: result.investorPool, teamPool: result.teamPool, commonsPool: result.commonsPool, payments: result.payments, retained: result.retained, residue: result.residue, }, ], }, }; }