Skip to content
SlashClone
Made by@ledgerly

Cash runway model

@ledgerly/cash-runway-model

Model cash runway from cash on hand, monthly burn by category, revenue growth, and planned hires. Produces runway months, cash-zero and raise-by dates, and an 18-month table for base, lean, and upside scenarios.

SkillFinancev1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Cash runway model: cash on hand, monthly burn by category, planned hires, and scenarios.
  3. 3// Usage: node scripts/runway.mjs <runway.json> [--out out/runway]
  4. 4// See examples/runway.json for the input format.
  5. 5import { addMonths, fail, mdTable, money, monthKey, parseArgs, parseDate, readJson, round, toCsv, writeOut } from "./lib.mjs";
  6. 6
  7. 7const args = parseArgs(process.argv.slice(2));
  8. 8const input = args._[0];
  9. 9const outPrefix = args.out || "out/runway";
  10. 10if (!input) fail("usage: node scripts/runway.mjs <runway.json> [--out out/runway]");
  11. 11const cfg = readJson(input, "runway inputs");
  12. 13function need(cond, msg) {
  13. 14 if (!cond) fail(`${input}: ${msg}`);
  14. 16need(Number.isFinite(cfg.cash), '"cash" must be a number (cash in the bank today).');
  15. 17need(Array.isArray(cfg.expenses) && cfg.expenses.length, '"expenses" must list at least one { "category", "monthly" }.');
  16. 18const startDate = parseDate(cfg.as_of);
  17. 19need(startDate, '"as_of" must be a month like "2026-10" (the month the cash balance is from).');
  18. 20const start = monthKey(startDate);
  19. 21const horizon = Number.isFinite(cfg.months) ? cfg.months : 18;
  20. 22const bufferMonths = Number.isFinite(cfg.min_cash_months) ? cfg.min_cash_months : 6;
  21. 23const burden = (Number.isFinite(cfg.payroll_burden_pct) ? cfg.payroll_burden_pct : 20) / 100;
  22. 24const revenue0 = cfg.revenue?.monthly ?? 0;
  23. 25const revenueGrowth = cfg.revenue?.growth_pct ?? 0;
  24. 26const hires = (cfg.hires || []).map((h, i) => {
  25. 27 const d = parseDate(h.start);
  26. 28 need(d, `hires[${i}].start must be a month like "2027-01".`);
  27. 29 need(Number.isFinite(h.annual_salary) || Number.isFinite(h.monthly_cost), `hires[${i}] needs "annual_salary" or "monthly_cost".`);
  28. 30 const monthly = Number.isFinite(h.monthly_cost) ? h.monthly_cost : (h.annual_salary / 12) * (1 + burden);
  29. 31 return { role: h.role || `Hire ${i + 1}`, start: monthKey(d), monthly };
  30. 32});
  31. 33const oneTime = (cfg.one_time || []).map((o, i) => {
  32. 34 const d = parseDate(o.month);
  33. 35 need(d && Number.isFinite(o.amount), `one_time[${i}] needs "month" and "amount".`);
  34. 36 return { month: monthKey(d), amount: o.amount, label: o.label || "one-time" };
  35. 37});
  36. 38cfg.expenses.forEach((e, i) => need(e.category && Number.isFinite(e.monthly), `expenses[${i}] needs "category" and "monthly".`));
  37. 40const scenarios = cfg.scenarios && Object.keys(cfg.scenarios).length ? cfg.scenarios : { base: {} };
  38. 42function project(sc, months) {
  39. 43 const growth = (sc.revenue_growth_pct ?? revenueGrowth) / 100;
  40. 44 const expenseChange = (sc.expense_change_pct ?? 0) / 100;
  41. 45 const delay = sc.hire_delay_months ?? 0;
  42. 46 const skip = new Set(sc.skip_hires || []);
  43. 47 const extraRevenue = sc.revenue_change_pct ? 1 + sc.revenue_change_pct / 100 : 1;
  44. 48 let cash = cfg.cash;
  45. 49 let rev = revenue0 * extraRevenue;
  46. 50 const rows = [];
  47. 51 let zeroMonth = null;
  48. 52 let runway = null;
  49. 53 let breakeven = null;
  50. 54 for (let i = 0; i < months; i++) {
  51. 55 const m = addMonths(start, i + 1);
  52. 56 if (i > 0) rev *= 1 + growth;
  53. 57 const byCat = {};
  54. 58 for (const e of cfg.expenses) {
  55. 59 const g = (e.growth_pct ?? 0) / 100;
  56. 60 byCat[e.category] = e.monthly * (1 + expenseChange) * (1 + g) ** i;
  57. 61 }
  58. 62 let hireCost = 0;
  59. 63 let headcountAdds = 0;
  60. 64 for (const h of hires) {
  61. 65 if (skip.has(h.role)) continue;
  62. 66 if (addMonths(h.start, delay) <= m) {
  63. 67 hireCost += h.monthly;
  64. 68 headcountAdds++;
  65. 69 }
  66. 70 }
  67. 71 const once = oneTime.filter((o) => o.month === m).reduce((a, o) => a + o.amount, 0);
  68. 72 const expenses = Object.values(byCat).reduce((a, b) => a + b, 0) + hireCost + once;
  69. 73 const net = rev - expenses;
  70. 74 const before = cash;
  71. 75 cash += net;
  72. 76 if (net >= 0 && breakeven === null) breakeven = m;
  73. 77 if (cash < 0 && runway === null) {
  74. 78 runway = i + (net < 0 ? before / -net : 0);
  75. 79 zeroMonth = m;
  76. 80 }
  77. 81 rows.push({ month: m, revenue: rev, ...byCat, new_hires: hireCost, one_time: once, expenses, net_burn: -net, ending_cash: cash, hires_added: headcountAdds });
  78. 82 }
  79. 83 return { rows, runway, zeroMonth, breakeven };
  80. 86const results = {};
  81. 87for (const [name, sc] of Object.entries(scenarios)) {
  82. 88 const table = project(sc, horizon);
  83. 89 const long = project(sc, 120);
  84. 90 const burn3 = table.rows.slice(0, 3).reduce((a, r) => a + r.net_burn, 0) / 3;
  85. 91 results[name] = {
  86. 92 settings: sc,
  87. 93 runway_months: long.runway === null ? null : round(long.runway, 1),
  88. 94 cash_zero_month: long.zeroMonth,
  89. 95 raise_by_month: long.zeroMonth ? addMonths(long.zeroMonth, -bufferMonths) : null,
  90. 96 breakeven_month: long.breakeven,
  91. 97 avg_net_burn_next_3: round(burn3),
  92. 98 ending_cash_at_horizon: round(table.rows[table.rows.length - 1].ending_cash),
  93. 99 rows: table.rows,
  94. 100 };
  95. 103const cats = cfg.expenses.map((e) => e.category);
  96. 104const csvRows = [];
  97. 105for (const [name, r] of Object.entries(results))
  98. 106 for (const row of r.rows) {
  99. 107 const o = { scenario: name, month: row.month, revenue: round(row.revenue) };
  100. 108 for (const c of cats) o[c] = round(row[c]);
  101. 109 Object.assign(o, { new_hires: round(row.new_hires), one_time: round(row.one_time), total_expenses: round(row.expenses), net_burn: round(row.net_burn), ending_cash: round(row.ending_cash) });
  102. 110 csvRows.push(o);
  103. 111 }
  104. 112writeOut(`${outPrefix}.csv`, toCsv(csvRows, ["scenario", "month", "revenue", ...cats, "new_hires", "one_time", "total_expenses", "net_burn", "ending_cash"]));
  105. 114const summary = Object.fromEntries(
  106. 115 Object.entries(results).map(([k, v]) => [k, { runway_months: v.runway_months, cash_zero_month: v.cash_zero_month, raise_by_month: v.raise_by_month, breakeven_month: v.breakeven_month, avg_net_burn_next_3: v.avg_net_burn_next_3, ending_cash_at_horizon: v.ending_cash_at_horizon }]),
  107. 116);
  108. 117writeOut(`${outPrefix}.json`, JSON.stringify({ as_of: start, starting_cash: cfg.cash, horizon_months: horizon, hires: hires.map((h) => ({ ...h, monthly: round(h.monthly) })), scenarios: summary }, null, 2) + "\n");
  109. 119const runwayText = (v) => (v.runway_months === null ? "10+ years (cash never runs out)" : `${v.runway_months} months`);
  110. 120const lines = [
  111. 121 `# Cash runway as of ${start}`,
  112. 122 "",
  113. 123 `Starting cash ${money(cfg.cash)}. ${hires.length} planned hire(s), loaded at ${Math.round(burden * 100)}% on top of salary. Raise-by assumes ${bufferMonths} months to close a round before cash hits zero.`,
  114. 124 "",
  115. 125 "## Scenarios",
  116. 126 "",
  117. 127 mdTable(
  118. 128 ["Scenario", "Runway", "Cash zero", "Raise by", "Breakeven", "Avg net burn (next 3 mo)", `Cash at month ${horizon}`],
  119. 129 Object.entries(results).map(([k, v]) => [k, runwayText(v), v.cash_zero_month || "never", v.raise_by_month || "n/a", v.breakeven_month || "not in 10 years", money(v.avg_net_burn_next_3), money(v.ending_cash_at_horizon)]),
  120. 130 ),
  121. 131 "",
  122. 132];
  123. 133for (const [name, v] of Object.entries(results)) {
  124. 134 lines.push(`## ${name}: ${horizon}-month table`, "");
  125. 135 lines.push(
  126. 136 mdTable(
  127. 137 ["Month", "Revenue", "Expenses", "New hires", "Net burn", "Ending cash"],
  128. 138 v.rows.map((r) => [r.month, money(r.revenue), money(r.expenses), money(r.new_hires), money(r.net_burn), r.ending_cash < 0 ? `**${money(r.ending_cash)}**` : money(r.ending_cash)]),
  129. 139 ),
  130. 140 "",
  131. 141 );
  132. 143writeOut(`${outPrefix}.md`, lines.join("\n"));
  133. 145process.stdout.write(`Wrote ${outPrefix}.md, ${outPrefix}.csv, ${outPrefix}.json\n`);
  134. 146for (const [k, v] of Object.entries(results)) process.stdout.write(`${k}: runway ${runwayText(v)}${v.cash_zero_month ? `, cash zero ${v.cash_zero_month}, raise by ${v.raise_by_month}` : ""}\n`);