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