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