| 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. |
| 16 | export const SPLIT = { investors: 60, team: 25, commons: 15 }; // % of D |
| 17 | export const DEFAULT_SEASONING_MONTHS = 24; |
| 18 | export const RESERVE_MONTHS = 12; // the floor behind reserveTopUp (computed upstream) |
| 19 | export const CAP_RANGE = [1.5, 3]; // a series fixes its cap multiple in here |
| 20 | export 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. |
| 24 | export 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. |
| 36 | export 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". |
| 44 | export 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. |
| 51 | export 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. |
| 63 | export 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. |
| 93 | export 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. |
| 138 | export 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 | |