Skip to content
SlashClone
Made by@ledgerly

Saas tool spend audit

@ledgerly/saas-tool-spend-audit

Audit your SaaS subscriptions from a simple list: annual spend, owners, upcoming renewals with decide-by dates, unused seats, stale tools, overlapping tools, and the savings from each fix. Writes a prioritized action list.

SkillFinancev1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// SaaS tool spend audit: owners, renewals, unused seats, stale tools, overlaps, savings.
  3. 3// Usage: node scripts/audit.mjs <tools.csv> [--as-of 2026-11-01] [--renewal-days 90] [--stale-days 60] [--notice-days 30] [--headroom-pct 5] [--out out/tool-audit]
  4. 4// Columns: tool, category, owner, cost, billing (monthly | annual), seats, active_seats,
  5. 5// renewal_date, last_used, auto_renew (yes | no). Only tool, cost, and billing are required.
  6. 6import { DAY_MS, dayKey, fail, mdTable, money, num, parseArgs, parseDate, readCsv, round, toCsv, 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/tool-audit";
  11. 11if (!input) fail("usage: node scripts/audit.mjs <tools.csv> [--as-of 2026-11-01]");
  12. 12const asOf = args["as-of"] ? parseDate(args["as-of"]) : new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()));
  13. 13if (!asOf) fail("--as-of must be a date like 2026-11-01.");
  14. 14const renewalDays = Number(args["renewal-days"] ?? 90);
  15. 15const staleDays = Number(args["stale-days"] ?? 60);
  16. 16const noticeDays = Number(args["notice-days"] ?? 30);
  17. 17const headroom = Number(args["headroom-pct"] ?? 5) / 100;
  18. 19const rows = readCsv(input, "tools CSV", ["tool", "cost", "billing"]);
  19. 20const tools = rows.map((r, i) => {
  20. 21 const cost = num(r.cost);
  21. 22 if (!Number.isFinite(cost)) fail(`row ${i + 2} (${r.tool}): cost is not a number.`);
  22. 23 const billing = (r.billing || "monthly").toLowerCase().startsWith("a") ? "annual" : "monthly";
  23. 24 const annual = billing === "annual" ? cost : cost * 12;
  24. 25 const seats = num(r.seats);
  25. 26 const active = num(r.active_seats);
  26. 27 const renewal = parseDate(r.renewal_date);
  27. 28 const lastUsed = parseDate(r.last_used);
  28. 29 const t = {
  29. 30 tool: r.tool,
  30. 31 category: r.category || "Uncategorized",
  31. 32 owner: r.owner || "",
  32. 33 billing,
  33. 34 annual_cost: round(annual),
  34. 35 seats: Number.isFinite(seats) ? seats : null,
  35. 36 active_seats: Number.isFinite(active) ? active : null,
  36. 37 renewal_date: renewal ? dayKey(renewal) : "",
  37. 38 days_to_renewal: renewal ? Math.round((renewal - asOf) / DAY_MS) : null,
  38. 39 last_used: lastUsed ? dayKey(lastUsed) : "",
  39. 40 days_since_used: lastUsed ? Math.round((asOf - lastUsed) / DAY_MS) : null,
  40. 41 auto_renew: /^(y|yes|true|1)$/i.test(r.auto_renew || ""),
  41. 42 flags: [],
  42. 43 likely_savings: 0,
  43. 44 possible_savings: 0,
  44. 45 action: "keep",
  45. 46 };
  46. 47 return t;
  47. 48});
  48. 50for (const t of tools) {
  49. 51 if (!t.owner) t.flags.push("no owner");
  50. 52 if (t.days_since_used !== null && t.days_since_used > staleDays) {
  51. 53 t.flags.push(`not used in ${t.days_since_used} days`);
  52. 54 t.likely_savings = t.annual_cost;
  53. 55 t.action = "cancel";
  54. 56 } else if (t.seats && t.active_seats !== null && t.active_seats < t.seats) {
  55. 57 const target = Math.min(t.seats, Math.ceil(t.active_seats * (1 + headroom)));
  56. 58 const unused = t.seats - target;
  57. 59 if (unused > 0) {
  58. 60 t.flags.push(`${t.seats - t.active_seats} of ${t.seats} seats unused`);
  59. 61 t.likely_savings = round((t.annual_cost / t.seats) * unused);
  60. 62 t.action = `cut to ${target} seats`;
  61. 63 }
  62. 64 }
  63. 65 if (t.days_to_renewal !== null && t.days_to_renewal >= 0 && t.days_to_renewal <= renewalDays) t.flags.push(`renews in ${t.days_to_renewal} days`);
  64. 66 if (t.days_to_renewal !== null && t.days_to_renewal < 0) t.flags.push("renewal date passed: update it");
  65. 69// Overlap: several tools in the same category. Keep the one with the most active seats (then the most expensive).
  66. 70const byCat = new Map();
  67. 71for (const t of tools) {
  68. 72 if (t.category === "Uncategorized") continue;
  69. 73 if (!byCat.has(t.category)) byCat.set(t.category, []);
  70. 74 byCat.get(t.category).push(t);
  71. 76const overlaps = [];
  72. 77for (const [cat, list] of byCat) {
  73. 78 const live = list.filter((t) => t.action !== "cancel");
  74. 79 if (live.length < 2) continue;
  75. 80 const keep = [...live].sort((a, b) => (b.active_seats ?? 0) - (a.active_seats ?? 0) || b.annual_cost - a.annual_cost)[0];
  76. 81 const free = live.filter((t) => t !== keep && t.annual_cost === 0).map((t) => t.tool);
  77. 82 overlaps.push({ category: cat, tools: live.map((t) => t.tool), keep: keep.tool, keep_annual_cost: keep.annual_cost, free_alternatives: keep.annual_cost > 0 ? free : [] });
  78. 83 for (const t of live) {
  79. 84 if (t === keep) continue;
  80. 85 t.flags.push(`overlaps with ${keep.tool} (${cat})`);
  81. 86 t.possible_savings = round(t.annual_cost - t.likely_savings);
  82. 87 if (t.action === "keep") t.action = `consider moving to ${keep.tool}`;
  83. 88 }
  84. 91const sum = (k) => round(tools.reduce((a, t) => a + t[k], 0));
  85. 92const renewals = tools
  86. 93 .filter((t) => t.days_to_renewal !== null && t.days_to_renewal >= 0 && t.days_to_renewal <= renewalDays)
  87. 94 .sort((a, b) => a.days_to_renewal - b.days_to_renewal)
  88. 95 .map((t) => ({ tool: t.tool, renewal_date: t.renewal_date, days: t.days_to_renewal, annual_cost: t.annual_cost, auto_renew: t.auto_renew, decide_by: dayKey(new Date(Date.parse(t.renewal_date) - noticeDays * DAY_MS)), notice_passed: t.days_to_renewal < noticeDays, owner: t.owner, action: t.action }));
  89. 97const result = {
  90. 98 as_of: dayKey(asOf),
  91. 99 tool_count: tools.length,
  92. 100 annual_spend: sum("annual_cost"),
  93. 101 likely_savings: sum("likely_savings"),
  94. 102 possible_savings: sum("possible_savings"),
  95. 103 no_owner: tools.filter((t) => !t.owner).map((t) => t.tool),
  96. 104 stale: tools.filter((t) => t.action === "cancel").map((t) => t.tool),
  97. 105 unused_seats: tools.filter((t) => t.action.startsWith("cut")).map((t) => ({ tool: t.tool, action: t.action, savings: t.likely_savings })),
  98. 106 overlaps,
  99. 107 renewals,
  100. 108};
  101. 109writeOut(`${outPrefix}.json`, JSON.stringify(result, null, 2) + "\n");
  102. 110writeOut(
  103. 111 `${outPrefix}.csv`,
  104. 112 toCsv(
  105. 113 tools.map((t) => ({ ...t, flags: t.flags.join("; ") })),
  106. 114 ["tool", "category", "owner", "billing", "annual_cost", "seats", "active_seats", "renewal_date", "days_to_renewal", "last_used", "days_since_used", "auto_renew", "action", "likely_savings", "possible_savings", "flags"],
  107. 115 ),
  108. 116);
  109. 118const byCategory = [...byCat.entries()].map(([c, l]) => [c, l.length, money(l.reduce((a, t) => a + t.annual_cost, 0))]).sort((a, b) => Number(b[2].replace(/[$,]/g, "")) - Number(a[2].replace(/[$,]/g, "")));
  110. 119const actions = tools.filter((t) => t.action !== "keep").sort((a, b) => b.likely_savings + b.possible_savings - (a.likely_savings + a.possible_savings));
  111. 120const md = [
  112. 121 `# SaaS tool audit as of ${result.as_of}`,
  113. 122 "",
  114. 123 `${tools.length} tools, ${money(result.annual_spend)} a year. Likely savings ${money(result.likely_savings)} a year (stale tools and unused seats). Possible savings ${money(result.possible_savings)} more if overlapping tools are consolidated.`,
  115. 124 "",
  116. 125 `## Renewals in the next ${renewalDays} days`,
  117. 126 "",
  118. 127 renewals.length ? mdTable(["Tool", "Renews", "Decide by", "Annual", "Auto-renew", "Owner", "Suggested action"], renewals.map((r) => [r.tool, r.renewal_date, r.notice_passed ? `${r.decide_by} (**passed**, act now)` : r.decide_by, money(r.annual_cost), r.auto_renew ? "yes" : "no", r.owner || "**none**", r.action])) : "None.",
  119. 128 "",
  120. 129 "## Actions, biggest savings first",
  121. 130 "",
  122. 131 actions.length ? mdTable(["Tool", "Action", "Likely savings", "Possible savings", "Why"], actions.map((t) => [t.tool, t.action, money(t.likely_savings), money(t.possible_savings), t.flags.join("; ")])) : "Nothing to change.",
  123. 132 "",
  124. 133 "## Overlapping tools",
  125. 134 "",
  126. 135 overlaps.length ? overlaps.map((o) => `- **${o.category}:** ${o.tools.join(", ")}. Most used: ${o.keep}.${o.free_alternatives.length ? ` You already have ${o.free_alternatives.join(" and ")} at no extra cost; moving off ${o.keep} would save up to ${money(o.keep_annual_cost)} a year.` : ""}`).join("\n") : "None found.",
  127. 136 "",
  128. 137 "## No owner",
  129. 138 "",
  130. 139 result.no_owner.length ? result.no_owner.map((t) => `- ${t}`).join("\n") : "Every tool has an owner.",
  131. 140 "",
  132. 141 "## Spend by category",
  133. 142 "",
  134. 143 mdTable(["Category", "Tools", "Annual"], byCategory),
  135. 144 "",
  136. 145].join("\n");
  137. 146writeOut(`${outPrefix}.md`, md);
  138. 148process.stdout.write(`Wrote ${outPrefix}.md, ${outPrefix}.csv, ${outPrefix}.json\n`);
  139. 149process.stdout.write(`${tools.length} tools, ${money(result.annual_spend)}/yr. Likely savings ${money(result.likely_savings)}/yr, possible ${money(result.possible_savings)}/yr. ${renewals.length} renewal(s) in ${renewalDays} days.\n`);