Skip to content
SlashClone
Made by@ledgerly

Finance update pack

@ledgerly/finance-update-pack

Write the monthly finance update for leadership or investors from a few inputs: revenue, costs, cash, and plan. A script computes plan vs actual, flags the variances that matter, and works out burn and runway; Claude drafts the narrative and asks you for the why behind each big variance.

SkillFinancev1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Plan vs actual for a monthly finance update.
  3. 3// Usage: node scripts/variance.mjs <month.csv> [--month 2026-10] [--out out/finance-update] [--threshold-pct 10] [--threshold-abs 5000]
  4. 4// Input columns: line, type, plan, actual, prior (optional), better (optional: higher | lower)
  5. 5// type: revenue | cogs | opex | metric | cash. One "cash" row holds the ending cash balance.
  6. 6import { fail, mdTable, money, num, parseArgs, pct, readCsv, round, writeOut } from "./lib.mjs";
  7. 7
  8. 8const args = parseArgs(process.argv.slice(2));
  9. 9const input = args._[0];
  10. 10const outPrefix = args.out || "out/finance-update";
  11. 11const thrPct = Number(args["threshold-pct"] ?? 10) / 100;
  12. 12const thrAbs = Number(args["threshold-abs"] ?? 5000);
  13. 13const month = args.month || "this month";
  14. 14if (!input) fail("usage: node scripts/variance.mjs <month.csv> [--month 2026-10] [--out out/finance-update]");
  15. 16const TYPES = ["revenue", "cogs", "opex", "metric", "cash"];
  16. 17const rows = readCsv(input, "finance CSV", ["line", "type", "actual"]).map((r, i) => {
  17. 18 const type = (r.type || "").toLowerCase();
  18. 19 if (!TYPES.includes(type)) fail(`row ${i + 2} ("${r.line}"): type must be one of ${TYPES.join(", ")}.`);
  19. 20 const actual = num(r.actual);
  20. 21 if (!Number.isFinite(actual)) fail(`row ${i + 2} ("${r.line}"): actual is not a number.`);
  21. 22 const better = (r.better || (type === "cogs" || type === "opex" ? "lower" : "higher")).toLowerCase();
  22. 23 return { line: r.line, type, plan: num(r.plan), actual, prior: num(r.prior), better };
  23. 24});
  24. 26const total = (type, key) => rows.filter((r) => r.type === type).reduce((a, r) => a + (Number.isFinite(r[key]) ? r[key] : 0), 0);
  25. 27const has = (type, key) => rows.some((r) => r.type === type && Number.isFinite(r[key]));
  26. 28function block(key) {
  27. 29 if (!has("revenue", key) && !has("opex", key)) return null;
  28. 30 const revenue = total("revenue", key);
  29. 31 const cogs = total("cogs", key);
  30. 32 const opex = total("opex", key);
  31. 33 const grossProfit = revenue - cogs;
  32. 34 const netBurn = cogs + opex - revenue;
  33. 35 return { revenue, cogs, gross_profit: grossProfit, gross_margin: revenue ? grossProfit / revenue : null, opex, net_burn: netBurn };
  34. 37const actual = block("actual");
  35. 38const plan = block("plan");
  36. 39const prior = block("prior");
  37. 40const cashRow = rows.find((r) => r.type === "cash");
  38. 41const cash = cashRow ? cashRow.actual : null;
  39. 42const runway = (b) => (cash !== null && b && b.net_burn > 0 ? cash / b.net_burn : null);
  40. 43// Average the latest two months of burn when we have them, so one odd month does not swing runway.
  41. 44const avgBurn = prior ? (actual.net_burn + prior.net_burn) / 2 : actual.net_burn;
  42. 45const runwayAvg = cash !== null && avgBurn > 0 ? cash / avgBurn : null;
  43. 47const lines = rows
  44. 48 .filter((r) => r.type !== "cash")
  45. 49 .map((r) => {
  46. 50 const variance = Number.isFinite(r.plan) ? r.actual - r.plan : null;
  47. 51 const variancePct = variance !== null && r.plan !== 0 ? variance / Math.abs(r.plan) : null;
  48. 52 const mom = Number.isFinite(r.prior) && r.prior !== 0 ? (r.actual - r.prior) / Math.abs(r.prior) : null;
  49. 53 const favorable = variance === null || variance === 0 ? null : r.better === "lower" ? variance < 0 : variance > 0;
  50. 54 const big = variancePct !== null && Math.abs(variancePct) >= thrPct && (r.type === "metric" || Math.abs(variance) >= thrAbs);
  51. 55 return { line: r.line, type: r.type, plan: Number.isFinite(r.plan) ? r.plan : null, actual: r.actual, prior: Number.isFinite(r.prior) ? r.prior : null, variance: variance === null ? null : round(variance), variance_pct: variancePct === null ? null : round(variancePct, 4), mom_pct: mom === null ? null : round(mom, 4), favorable, flag: big };
  52. 56 });
  53. 58const flagged = lines.filter((l) => l.flag).sort((a, b) => Math.abs(b.variance_pct) - Math.abs(a.variance_pct));
  54. 59const r2 = (b) => b && Object.fromEntries(Object.entries(b).map(([k, v]) => [k, v === null ? null : round(v, k === "gross_margin" ? 4 : 2)]));
  55. 60const result = {
  56. 61 month,
  57. 62 thresholds: { pct: thrPct, abs: thrAbs },
  58. 63 actual: r2(actual),
  59. 64 plan: r2(plan),
  60. 65 prior: r2(prior),
  61. 66 ending_cash: cash,
  62. 67 runway_months: runway(actual) === null ? null : round(runway(actual), 1),
  63. 68 runway_months_2mo_avg: runwayAvg === null ? null : round(runwayAvg, 1),
  64. 69 flagged: flagged.map((l) => l.line),
  65. 70 lines,
  66. 71};
  67. 72writeOut(`${outPrefix}-variance.json`, JSON.stringify(result, null, 2) + "\n");
  68. 74const isMoney = (l) => l.type !== "metric";
  69. 75const fmt = (l, v) => (v === null ? "n/a" : isMoney(l) ? money(v) : Number(v).toLocaleString("en-US"));
  70. 76const fv = (l) => (l.favorable === null ? "" : l.favorable ? "fav" : "unfav");
  71. 77const head = (label, key) => [label, plan ? money(plan[key]) : "n/a", money(actual[key]), plan ? money(actual[key] - plan[key]) : "n/a", prior ? money(prior[key]) : "n/a"];
  72. 78const md = [
  73. 79 `# Plan vs actual: ${month}`,
  74. 80 "",
  75. 81 mdTable(
  76. 82 ["", "Plan", "Actual", "Var", "Prior month"],
  77. 83 [
  78. 84 head("Revenue", "revenue"),
  79. 85 head("Gross profit", "gross_profit"),
  80. 86 ["Gross margin", plan?.gross_margin != null ? pct(plan.gross_margin) : "n/a", actual.gross_margin != null ? pct(actual.gross_margin) : "n/a", "", prior?.gross_margin != null ? pct(prior.gross_margin) : "n/a"],
  81. 87 head("Operating expenses", "opex"),
  82. 88 head("Net burn", "net_burn"),
  83. 89 ],
  84. 90 ),
  85. 91 "",
  86. 92 cash !== null
  87. 93 ? `Ending cash ${money(cash)}. Runway ${result.runway_months === null ? "not burning" : `${result.runway_months} months`} at this month's burn${result.runway_months_2mo_avg !== null && prior ? `, ${result.runway_months_2mo_avg} months at the two-month average` : ""}.`
  88. 94 : "No cash row given, so no runway.",
  89. 95 "",
  90. 96 `## Variances over ${Math.round(thrPct * 100)}% and ${money(thrAbs)} (metrics: ${Math.round(thrPct * 100)}% only)`,
  91. 97 "",
  92. 98 flagged.length
  93. 99 ? mdTable(["Line", "Plan", "Actual", "Var", "Var %", "", "Why (fill in)"], flagged.map((l) => [l.line, fmt(l, l.plan), fmt(l, l.actual), fmt(l, l.variance), pct(l.variance_pct), fv(l), ""]))
  94. 100 : "Nothing over threshold.",
  95. 101 "",
  96. 102 "## All lines",
  97. 103 "",
  98. 104 mdTable(["Line", "Type", "Plan", "Actual", "Var", "Var %", "MoM", ""], lines.map((l) => [l.line, l.type, fmt(l, l.plan), fmt(l, l.actual), fmt(l, l.variance), l.variance_pct === null ? "n/a" : pct(l.variance_pct), l.mom_pct === null ? "n/a" : pct(l.mom_pct), fv(l)])),
  99. 105 "",
  100. 106].join("\n");
  101. 107writeOut(`${outPrefix}-variance.md`, md);
  102. 109process.stdout.write(`Wrote ${outPrefix}-variance.md and ${outPrefix}-variance.json\n`);
  103. 110process.stdout.write(`Revenue ${money(actual.revenue)} vs plan ${plan ? money(plan.revenue) : "n/a"}; net burn ${money(actual.net_burn)}; ${flagged.length} line(s) over threshold.\n`);