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