Skip to content
SlashClone
Made by@ledgerly

Arr waterfall

@ledgerly/arr-waterfall

Turn a monthly subscriptions export into an ARR waterfall: starting ARR, new, reactivation, expansion, contraction, churn, ending ARR, plus NRR and GRR. Writes markdown, CSV, and a per-customer movements file. Works offline from a CSV.

SkillFinancev1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// ARR waterfall from a monthly subscriptions export.
  3. 3// Usage: node scripts/arr-waterfall.mjs <subscriptions.csv> [--out out/arr-waterfall] [--from 2026-01] [--to 2026-09]
  4. 4// Input columns: month, customer_id, and either mrr or arr. One row per customer per month
  5. 5// (several rows for the same customer and month are added together).
  6. 6import {
  7. 7 addMonths,
  8. 8 fail,
  9. 9 mdTable,
  10. 10 money,
  11. 11 monthKey,
  12. 12 num,
  13. 13 parseArgs,
  14. 14 parseDate,
  15. 15 pct,
  16. 16 readCsv,
  17. 17 round,
  18. 18 toCsv,
  19. 19 writeOut,
  20. 20} from "./lib.mjs";
  21. 22const args = parseArgs(process.argv.slice(2));
  22. 23const input = args._[0];
  23. 24const outPrefix = args.out || "out/arr-waterfall";
  24. 25if (!input) fail("usage: node scripts/arr-waterfall.mjs <subscriptions.csv> [--out out/arr-waterfall]");
  25. 27const rows = readCsv(input, "subscriptions CSV", ["month", "customer_id"]);
  26. 28if (!rows.length) fail(`${input} has no data rows.`);
  27. 29const hasArr = "arr" in rows[0];
  28. 30const hasMrr = "mrr" in rows[0];
  29. 31if (!hasArr && !hasMrr) fail(`${input} needs an "mrr" or an "arr" column.`);
  30. 33// customer -> month -> ARR
  31. 34const arr = new Map();
  32. 35const months = new Set();
  33. 36const bad = [];
  34. 37rows.forEach((r, i) => {
  35. 38 const d = parseDate(r.month);
  36. 39 const v = num(hasArr ? r.arr : r.mrr);
  37. 40 if (!d || !r.customer_id || Number.isNaN(v)) {
  38. 41 bad.push(i + 2);
  39. 42 return;
  40. 43 }
  41. 44 const m = monthKey(d);
  42. 45 months.add(m);
  43. 46 const value = hasArr ? v : v * 12;
  44. 47 if (!arr.has(r.customer_id)) arr.set(r.customer_id, new Map());
  45. 48 const byMonth = arr.get(r.customer_id);
  46. 49 byMonth.set(m, (byMonth.get(m) || 0) + value);
  47. 50});
  48. 51if (bad.length) process.stderr.write(`Skipped ${bad.length} row(s) with a bad month, customer, or amount (lines ${bad.slice(0, 10).join(", ")}${bad.length > 10 ? ", ..." : ""}).\n`);
  49. 52if (!months.size) fail("no usable rows.");
  50. 54const sorted = [...months].sort();
  51. 55const first = args.from || sorted[0];
  52. 56const last = args.to || sorted[sorted.length - 1];
  53. 57const span = [];
  54. 58for (let m = first; m <= last; m = addMonths(m, 1)) span.push(m);
  55. 59if (span.length < 2) fail("need at least two months of data to build a waterfall.");
  56. 61const at = (c, m) => arr.get(c)?.get(m) || 0;
  57. 62const customers = [...arr.keys()];
  58. 63const seenBefore = (c, m) => [...arr.get(c).keys()].some((k) => k < m && arr.get(c).get(k) > 0);
  59. 65const table = [];
  60. 66const movers = [];
  61. 67for (let i = 1; i < span.length; i++) {
  62. 68 const prevM = span[i - 1];
  63. 69 const m = span[i];
  64. 70 const row = { month: m, starting: 0, new: 0, reactivation: 0, expansion: 0, contraction: 0, churn: 0, ending: 0, customers_start: 0, customers_end: 0, new_logos: 0, churned_logos: 0 };
  65. 71 for (const c of customers) {
  66. 72 const p = at(c, prevM);
  67. 73 const n = at(c, m);
  68. 74 row.starting += p;
  69. 75 row.ending += n;
  70. 76 if (p > 0) row.customers_start++;
  71. 77 if (n > 0) row.customers_end++;
  72. 78 let kind = null;
  73. 79 let delta = n - p;
  74. 80 if (p === 0 && n > 0) {
  75. 81 kind = seenBefore(c, prevM) || seenBefore(c, m) ? "reactivation" : "new";
  76. 82 if (kind === "new") row.new_logos++;
  77. 83 } else if (p > 0 && n === 0) {
  78. 84 kind = "churn";
  79. 85 row.churned_logos++;
  80. 86 } else if (p > 0 && n > p) kind = "expansion";
  81. 87 else if (p > 0 && n < p) kind = "contraction";
  82. 88 if (!kind) continue;
  83. 89 row[kind] += Math.abs(delta);
  84. 90 movers.push({ month: m, customer_id: c, movement: kind, from_arr: round(p), to_arr: round(n), change: round(delta) });
  85. 91 }
  86. 92 row.nrr = row.starting > 0 ? (row.starting + row.expansion - row.contraction - row.churn) / row.starting : null;
  87. 93 row.grr = row.starting > 0 ? (row.starting - row.contraction - row.churn) / row.starting : null;
  88. 94 row.check = round(row.starting + row.new + row.reactivation + row.expansion - row.contraction - row.churn - row.ending);
  89. 95 table.push(row);
  90. 98// Cohort NRR and GRR across the whole range: customers active in the first month.
  91. 99function cohortRetention(fromM, toM) {
  92. 100 let base = 0;
  93. 101 let now = 0;
  94. 102 let floor = 0;
  95. 103 for (const c of customers) {
  96. 104 const b = at(c, fromM);
  97. 105 if (b <= 0) continue;
  98. 106 const e = at(c, toM);
  99. 107 base += b;
  100. 108 now += e;
  101. 109 floor += Math.min(b, e);
  102. 110 }
  103. 111 return base > 0 ? { from: fromM, to: toM, base_arr: round(base), nrr: now / base, grr: floor / base } : null;
  104. 113const period = cohortRetention(span[0], span[span.length - 1]);
  105. 114const t12 = span.length >= 13 ? cohortRetention(span[span.length - 13], span[span.length - 1]) : null;
  106. 116const totals = {
  107. 117 starting_arr: round(table[0].starting),
  108. 118 new: round(table.reduce((a, r) => a + r.new, 0)),
  109. 119 reactivation: round(table.reduce((a, r) => a + r.reactivation, 0)),
  110. 120 expansion: round(table.reduce((a, r) => a + r.expansion, 0)),
  111. 121 contraction: round(table.reduce((a, r) => a + r.contraction, 0)),
  112. 122 churn: round(table.reduce((a, r) => a + r.churn, 0)),
  113. 123 ending_arr: round(table[table.length - 1].ending),
  114. 124};
  115. 126const outRows = table.map((r) => ({
  116. 127 month: r.month,
  117. 128 starting_arr: round(r.starting),
  118. 129 new: round(r.new),
  119. 130 reactivation: round(r.reactivation),
  120. 131 expansion: round(r.expansion),
  121. 132 contraction: round(r.contraction),
  122. 133 churn: round(r.churn),
  123. 134 ending_arr: round(r.ending),
  124. 135 net_new_arr: round(r.ending - r.starting),
  125. 136 nrr: r.nrr === null ? "" : round(r.nrr, 4),
  126. 137 grr: r.grr === null ? "" : round(r.grr, 4),
  127. 138 customers_start: r.customers_start,
  128. 139 customers_end: r.customers_end,
  129. 140 new_logos: r.new_logos,
  130. 141 churned_logos: r.churned_logos,
  131. 142}));
  132. 144const columns = Object.keys(outRows[0]);
  133. 145writeOut(`${outPrefix}.csv`, toCsv(outRows, columns));
  134. 146writeOut(`${outPrefix}-movements.csv`, toCsv(movers, ["month", "customer_id", "movement", "from_arr", "to_arr", "change"]));
  135. 148const json = {
  136. 149 source: input,
  137. 150 basis: hasArr ? "arr column" : "mrr x 12",
  138. 151 from: span[0],
  139. 152 to: span[span.length - 1],
  140. 153 totals,
  141. 154 period_cohort: period && { ...period, nrr: round(period.nrr, 4), grr: round(period.grr, 4) },
  142. 155 trailing_12m: t12 && { ...t12, nrr: round(t12.nrr, 4), grr: round(t12.grr, 4) },
  143. 156 months: outRows,
  144. 157 unreconciled_months: table.filter((r) => Math.abs(r.check) > 0.01).map((r) => r.month),
  145. 158 skipped_rows: bad.length,
  146. 159};
  147. 160writeOut(`${outPrefix}.json`, JSON.stringify(json, null, 2) + "\n");
  148. 162const neg = (v) => (v > 0 ? `-${money(v)}` : money(0));
  149. 163const lastMonthMoves = movers.filter((m) => m.month === span[span.length - 1]);
  150. 164const top = (kind) =>
  151. 165 lastMonthMoves
  152. 166 .filter((m) => m.movement === kind)
  153. 167 .sort((a, b) => Math.abs(b.change) - Math.abs(a.change))
  154. 168 .slice(0, 5)
  155. 169 .map((m) => `${m.customer_id} (${money(m.change)})`)
  156. 170 .join(", ") || "none";
  157. 172const md = [
  158. 173 `# ARR waterfall: ${span[0]} to ${span[span.length - 1]}`,
  159. 174 "",
  160. 175 `Basis: ${json.basis}. Source: \`${input}\`.`,
  161. 176 "",
  162. 177 `Starting ARR ${money(totals.starting_arr)} | New ${money(totals.new)} | Reactivation ${money(totals.reactivation)} | Expansion ${money(totals.expansion)} | Contraction ${neg(totals.contraction)} | Churn ${neg(totals.churn)} | Ending ARR ${money(totals.ending_arr)}`,
  163. 178 "",
  164. 179 period ? `Cohort retention, customers active in ${period.from}, measured in ${period.to}: NRR ${pct(period.nrr)}, GRR ${pct(period.grr)}.` : "",
  165. 180 t12 ? `Trailing 12-month NRR ${pct(t12.nrr)}, GRR ${pct(t12.grr)}.` : "Trailing 12-month NRR needs 13 months of data.",
  166. 181 "",
  167. 182 "## By month",
  168. 183 "",
  169. 184 mdTable(
  170. 185 ["Month", "Starting", "New", "Reactivation", "Expansion", "Contraction", "Churn", "Ending", "NRR (mo)", "GRR (mo)"],
  171. 186 outRows.map((r) => [r.month, money(r.starting_arr), money(r.new), money(r.reactivation), money(r.expansion), neg(r.contraction), neg(r.churn), money(r.ending_arr), r.nrr === "" ? "n/a" : pct(r.nrr), r.grr === "" ? "n/a" : pct(r.grr)]),
  172. 187 ),
  173. 188 "",
  174. 189 `## Biggest movers in ${span[span.length - 1]}`,
  175. 190 "",
  176. 191 `- Expansion: ${top("expansion")}`,
  177. 192 `- Contraction: ${top("contraction")}`,
  178. 193 `- Churn: ${top("churn")}`,
  179. 194 `- New: ${top("new")}`,
  180. 195 "",
  181. 196 json.unreconciled_months.length ? `Warning: months that do not tie out: ${json.unreconciled_months.join(", ")}.` : "Every month ties out: starting + new + reactivation + expansion - contraction - churn = ending.",
  182. 197 "",
  183. 198].join("\n");
  184. 199writeOut(`${outPrefix}.md`, md);
  185. 201process.stdout.write(`Wrote ${outPrefix}.md, ${outPrefix}.csv, ${outPrefix}.json, ${outPrefix}-movements.csv\n`);
  186. 202process.stdout.write(`Ending ARR ${money(totals.ending_arr)} (from ${money(totals.starting_arr)}).${period ? ` Cohort NRR ${pct(period.nrr)}, GRR ${pct(period.grr)}.` : ""}\n`);