Skip to content
SlashClone
Made by@eventcrew

Event budget tracker

@eventcrew/event-budget-tracker

Track an event budget against what you have committed and spent, from a simple CSV of line items. Shows totals by category, lines over budget, bills coming due, contingency left, and cost per attendee or lead.

SkillFinancev1.0.1 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Event budget vs. actuals from a CSV of line items.
  3. 3// Usage: node scripts/budget.mjs [data/event-budget.csv] [out-dir]
  4. 4// [--total 50000] [--contingency 10] [--as-of YYYY-MM-DD] [--due-days 7]
  5. 5// [--attendees N] [--leads N] [--meetings N]
  6. 6// Writes: <out-dir>/event-budget.md and <out-dir>/event-budget.json
  7. 7//
  8. 8// Columns (flexible names): category, item, budget, committed, actual, vendor, paid, due_date
  9. 9// budget what you planned to spend on the line
  10. 10// committed what you have agreed to pay (signed quote or PO), invoiced or not
  11. 11// actual what has been invoiced or spent so far
  12. 12// paid yes/no; unpaid lines with a due date are listed as bills to pay
  13. 13// Forecast per line = the largest of committed and actual, or the budget if neither is set yet.
  14. 14import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
  15. 15import { join } from "node:path";
  16. 17const args = process.argv.slice(2);
  17. 18function flag(name, fallback) {
  18. 19 const i = args.indexOf(name);
  19. 20 if (i === -1) return fallback;
  20. 21 const value = args[i + 1] ?? "";
  21. 22 args.splice(i, 2);
  22. 23 return value;
  23. 25function num(name, fallback) {
  24. 26 const raw = flag(name, fallback);
  25. 27 if (raw === null) return null;
  26. 28 const n = Number(String(raw).replace(/[$,\s]/g, ""));
  27. 29 if (!Number.isFinite(n) || n < 0) fail(`${name} must be a positive number (got "${raw}")`);
  28. 30 return n;
  29. 32function fail(msg) {
  30. 33 console.error(`budget: ${msg}`);
  31. 34 process.exit(1);
  32. 36const asOf = flag("--as-of", new Date().toISOString().slice(0, 10));
  33. 37const total = num("--total", null);
  34. 38const contingencyPct = num("--contingency", "0");
  35. 39const dueDays = num("--due-days", "7");
  36. 40const attendees = num("--attendees", null);
  37. 41const leads = num("--leads", null);
  38. 42const meetings = num("--meetings", null);
  39. 43const [input = "data/event-budget.csv", outDir = "out"] = args;
  40. 44if (!/^\d{4}-\d{2}-\d{2}$/.test(asOf)) fail(`--as-of must be YYYY-MM-DD (got "${asOf}")`);
  41. 45if (!existsSync(input)) fail(`${input} not found. Start from examples/event-budget.csv.`);
  42. 47function parseCsv(src) {
  43. 48 const rows = [];
  44. 49 let row = [];
  45. 50 let field = "";
  46. 51 let quoted = false;
  47. 52 const push = () => {
  48. 53 row.push(field);
  49. 54 field = "";
  50. 55 if (row.some((v) => v.trim() !== "")) rows.push(row);
  51. 56 row = [];
  52. 57 };
  53. 58 for (let i = 0; i < src.length; i++) {
  54. 59 const ch = src[i];
  55. 60 if (quoted) {
  56. 61 if (ch === '"' && src[i + 1] === '"') {
  57. 62 field += '"';
  58. 63 i++;
  59. 64 } else if (ch === '"') quoted = false;
  60. 65 else field += ch;
  61. 66 } else if (ch === '"') quoted = true;
  62. 67 else if (ch === ",") {
  63. 68 row.push(field);
  64. 69 field = "";
  65. 70 } else if (ch === "\n" || ch === "\r") {
  66. 71 if (ch === "\r" && src[i + 1] === "\n") i++;
  67. 72 push();
  68. 73 } else field += ch;
  69. 74 }
  70. 75 push();
  71. 76 return rows;
  72. 79const ALIASES = {
  73. 80 category: ["category", "bucket", "type", "group"],
  74. 81 item: ["item", "line item", "description", "name"],
  75. 82 budget: ["budget", "planned", "plan", "estimate", "budgeted"],
  76. 83 committed: ["committed", "contracted", "po", "quote", "agreed"],
  77. 84 actual: ["actual", "actuals", "spent", "invoiced", "spend"],
  78. 85 vendor: ["vendor", "supplier", "payee"],
  79. 86 paid: ["paid", "paid?", "is paid"],
  80. 87 due_date: ["due_date", "due date", "due", "payment due", "invoice due"],
  81. 88};
  82. 89function key(h) {
  83. 90 const v = h.trim().toLowerCase();
  84. 91 for (const [k, names] of Object.entries(ALIASES)) if (names.includes(v)) return k;
  85. 92 return null;
  86. 94function money(s, where) {
  87. 95 const v = String(s ?? "").trim();
  88. 96 if (v === "" || v === "-") return null;
  89. 97 const neg = /^\(.*\)$/.test(v) || v.startsWith("-");
  90. 98 const n = Number(v.replace(/[()$€£,\s-]/g, ""));
  91. 99 if (!Number.isFinite(n)) {
  92. 100 warnings.push(`${where}: could not read amount "${v}", treated as blank`);
  93. 101 return null;
  94. 102 }
  95. 103 return Math.round((neg ? -n : n) * 100);
  96. 106const warnings = [];
  97. 107const [header, ...body] = parseCsv(readFileSync(input, "utf8"));
  98. 108if (!header) fail(`${input} is empty`);
  99. 109const keys = header.map(key);
  100. 110if (!keys.includes("budget")) fail(`${input} needs a "budget" column. Found: ${header.join(", ")}`);
  101. 112const lines = body.map((cells, i) => {
  102. 113 const r = {};
  103. 114 keys.forEach((k, j) => {
  104. 115 if (k) r[k] = (cells[j] ?? "").trim();
  105. 116 });
  106. 117 const where = `row ${i + 2}`;
  107. 118 const budget = money(r.budget, where) ?? 0;
  108. 119 const committed = money(r.committed, where);
  109. 120 const actual = money(r.actual, where);
  110. 121 const forecast = committed === null && actual === null ? budget : Math.max(committed ?? 0, actual ?? 0);
  111. 122 const paid = /^(y|yes|true|1|paid|x)$/i.test(r.paid ?? "");
  112. 123 const due = /^\d{4}-\d{2}-\d{2}$/.test(r.due_date ?? "") ? r.due_date : null;
  113. 124 if (r.due_date && !due) warnings.push(`${where}: due date "${r.due_date}" is not YYYY-MM-DD, ignored`);
  114. 125 return {
  115. 126 row: i + 2,
  116. 127 category: r.category || "Uncategorized",
  117. 128 item: r.item || `(row ${i + 2})`,
  118. 129 vendor: r.vendor ?? "",
  119. 130 budget,
  120. 131 committed,
  121. 132 actual,
  122. 133 forecast,
  123. 134 variance: budget - forecast,
  124. 135 status: committed === null && actual === null ? "planned" : actual !== null && actual >= (committed ?? 0) ? "invoiced" : "committed",
  125. 136 paid,
  126. 137 due_date: due,
  127. 138 };
  128. 139});
  129. 140if (lines.length === 0) fail(`${input} has a header but no line items`);
  130. 142const sum = (arr, f) => arr.reduce((a, x) => a + (f(x) ?? 0), 0);
  131. 143const categories = [...new Set(lines.map((l) => l.category))].map((name) => {
  132. 144 const ls = lines.filter((l) => l.category === name);
  133. 145 const budget = sum(ls, (l) => l.budget);
  134. 146 const forecast = sum(ls, (l) => l.forecast);
  135. 147 return {
  136. 148 name,
  137. 149 budget,
  138. 150 committed: sum(ls, (l) => l.committed),
  139. 151 actual: sum(ls, (l) => l.actual),
  140. 152 forecast,
  141. 153 variance: budget - forecast,
  142. 154 pct_of_budget: budget ? Math.round((forecast / budget) * 1000) / 10 : null,
  143. 155 over: forecast > budget,
  144. 156 };
  145. 157});
  146. 158categories.sort((a, b) => a.variance - b.variance);
  147. 160const plannedTotal = sum(lines, (l) => l.budget);
  148. 161const totalBudget = total !== null ? Math.round(total * 100) : plannedTotal;
  149. 162const contingency = Math.round(totalBudget * (contingencyPct / 100));
  150. 163const forecastTotal = sum(lines, (l) => l.forecast);
  151. 164const totals = {
  152. 165 budget: totalBudget,
  153. 166 line_budgets: plannedTotal,
  154. 167 unallocated: totalBudget - plannedTotal,
  155. 168 contingency,
  156. 169 committed: sum(lines, (l) => l.committed),
  157. 170 actual: sum(lines, (l) => l.actual),
  158. 171 forecast: forecastTotal,
  159. 172 remaining: totalBudget - forecastTotal,
  160. 173 remaining_after_contingency: totalBudget - contingency - forecastTotal,
  161. 174 unpaid: sum(lines.filter((l) => !l.paid), (l) => l.actual),
  162. 175};
  163. 176if (totals.unallocated < 0) warnings.push("line budgets add up to more than the total budget");
  164. 178const daysUntil = (d) => Math.round((Date.parse(d) - Date.parse(asOf)) / 86400000);
  165. 179const bills = lines
  166. 180 .filter((l) => !l.paid && l.due_date && (l.actual ?? l.committed ?? 0) > 0)
  167. 181 .map((l) => ({
  168. 182 item: l.item,
  169. 183 vendor: l.vendor,
  170. 184 amount: l.actual ?? l.committed,
  171. 185 invoiced: l.actual !== null,
  172. 186 due_date: l.due_date,
  173. 187 days: daysUntil(l.due_date),
  174. 188 }))
  175. 189 .filter((b) => b.days <= dueDays)
  176. 190 .sort((a, b) => a.days - b.days);
  177. 191const overLines = lines.filter((l) => l.variance < 0).sort((a, b) => a.variance - b.variance);
  178. 193const per = (n) => (n ? Math.round(forecastTotal / n) : null);
  179. 194const unitCosts = {
  180. 195 per_attendee: per(attendees),
  181. 196 per_lead: per(leads),
  182. 197 per_meeting: per(meetings),
  183. 198};
  184. 200let verdict;
  185. 201if (totals.remaining < 0) verdict = "over budget";
  186. 202else if (totals.remaining_after_contingency < 0) verdict = "into contingency";
  187. 203else if (categories.some((c) => c.over)) verdict = "on budget overall, some categories over";
  188. 204else verdict = "on budget";
  189. 206// ---- output ----------------------------------------------------------------
  190. 207const $ = (cents) => {
  191. 208 if (cents === null || cents === undefined) return "";
  192. 209 const neg = cents < 0;
  193. 210 const s = (Math.abs(cents) / 100).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 });
  194. 211 return neg ? `-$${s}` : `$${s}`;
  195. 212};
  196. 213const md = [
  197. 214 `# Event budget (as of ${asOf})`,
  198. 215 "",
  199. 216 `**${verdict[0].toUpperCase()}${verdict.slice(1)}.** Forecast ${$(totals.forecast)} of ${$(totals.budget)} budget. Remaining ${$(totals.remaining)}${contingency ? ` (${$(totals.remaining_after_contingency)} after ${contingencyPct}% contingency)` : ""}.`,
  200. 217 "",
  201. 218 `Committed ${$(totals.committed)}. Invoiced ${$(totals.actual)}. Invoiced but unpaid ${$(totals.unpaid)}.`,
  202. 219 totals.unallocated > 0 ? `Not yet allocated to a line: ${$(totals.unallocated)}.` : "",
  203. 220 "",
  204. 221 "## By category",
  205. 222 "",
  206. 223 "| Category | Budget | Committed | Invoiced | Forecast | Left | Used |",
  207. 224 "| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
  208. 225 ...categories.map(
  209. 226 (c) =>
  210. 227 `| ${c.over ? "**OVER** " : ""}${c.name} | ${$(c.budget)} | ${$(c.committed)} | ${$(c.actual)} | ${$(c.forecast)} | ${$(c.variance)} | ${c.pct_of_budget ?? "n/a"}% |`,
  211. 228 ),
  212. 229 `| **Total** | ${$(plannedTotal)} | ${$(totals.committed)} | ${$(totals.actual)} | ${$(forecastTotal)} | ${$(plannedTotal - forecastTotal)} | ${plannedTotal ? Math.round((forecastTotal / plannedTotal) * 1000) / 10 : "n/a"}% |`,
  213. 230 "",
  214. 231 "## Lines over budget",
  215. 232 "",
  216. 233 overLines.length
  217. 234 ? overLines.map((l) => `- ${l.item} (${l.category}): ${$(-l.variance)} over (${$(l.forecast)} vs ${$(l.budget)})`).join("\n")
  218. 235 : "None.",
  219. 236 "",
  220. 237 `## Bills due within ${dueDays} days`,
  221. 238 "",
  222. 239 bills.length
  223. 240 ? bills
  224. 241 .map((b) => `- ${b.days < 0 ? `**OVERDUE ${-b.days}d** ` : ""}${b.due_date}: ${b.vendor || "vendor?"}, ${b.item}, ${$(b.amount)}${b.invoiced ? "" : " (committed, invoice not logged)"}`)
  225. 242 .join("\n")
  226. 243 : "None.",
  227. 244 "",
  228. 245];
  229. 246if (attendees || leads || meetings) {
  230. 247 md.push("## Unit costs (forecast)", "");
  231. 248 if (attendees) md.push(`- Per attendee (${attendees}): ${$(unitCosts.per_attendee)}`);
  232. 249 if (leads) md.push(`- Per lead (${leads}): ${$(unitCosts.per_lead)}`);
  233. 250 if (meetings) md.push(`- Per meeting (${meetings}): ${$(unitCosts.per_meeting)}`);
  234. 251 md.push("");
  235. 253if (warnings.length) md.push("## Warnings", "", ...warnings.map((w) => `- ${w}`), "");
  236. 254const mdText = md.filter((l, i, a) => !(l === "" && a[i - 1] === "")).join("\n");
  237. 256const toDollars = (o) =>
  238. 257 Object.fromEntries(Object.entries(o).map(([k, v]) => [k, typeof v === "number" && !["pct_of_budget", "row", "days"].includes(k) ? v / 100 : v]));
  239. 258mkdirSync(outDir, { recursive: true });
  240. 259writeFileSync(join(outDir, "event-budget.md"), mdText);
  241. 260writeFileSync(
  242. 261 join(outDir, "event-budget.json"),
  243. 262 `${JSON.stringify(
  244. 263 {
  245. 264 as_of: asOf,
  246. 265 verdict,
  247. 266 totals: toDollars(totals),
  248. 267 categories: categories.map(toDollars),
  249. 268 over_budget_lines: overLines.map((l) => ({ item: l.item, category: l.category, over_by: -l.variance / 100 })),
  250. 269 bills_due: bills.map(toDollars),
  251. 270 unit_costs: toDollars(unitCosts),
  252. 271 warnings,
  253. 272 },
  254. 273 null,
  255. 274 2,
  256. 275 )}\n`,
  257. 276);
  258. 277console.log(mdText);
  259. 278console.log(`Wrote ${join(outDir, "event-budget.md")} and ${join(outDir, "event-budget.json")}`);