Customer health score
@usagemetrics/customer-health-score
Score customer health from usage, support, and billing signals with weights you can read and edit. Bands every account green, yellow, or red, names the top drivers, tracks change since the last score, and flags at-risk accounts renewing soon. Works from one CSV.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Customer health score from usage, support, and billing signals with editable weights.
- 3// Usage: node scripts/health.mjs <accounts.csv> --weights <weights.json> [--as-of 2026-11-01] [--out out/health]
- 4// accounts.csv: account_id, optional name, arr, renewal_date, previous_score, plus one column per signal in weights.json.
- 5import { DAY_MS, dayKey, fail, mdTable, money, num, parseArgs, parseDate, readCsv, readJson, round, toCsv, writeOut } from "./lib.mjs";
- 7const args = parseArgs(process.argv.slice(2));
- 8const input = args._[0];
- 9if (!input || !args.weights) fail("usage: node scripts/health.mjs <accounts.csv> --weights <weights.json> [--out out/health]");
- 10const outPrefix = args.out || "out/health";
- 11const cfg = readJson(args.weights, "weights file");
- 12if (!Array.isArray(cfg.signals) || !cfg.signals.length) fail(`${args.weights}: "signals" must be a list.`);
- 13cfg.signals.forEach((s, i) => {
- 14 if (!s.column || !Number.isFinite(s.weight) || !Number.isFinite(s.good) || !Number.isFinite(s.bad) || s.good === s.bad)
- 15 fail(`${args.weights}: signals[${i}] needs "column", "weight", and different numbers for "good" and "bad".`);
- 16});
- 17const green = cfg.bands?.green ?? 70;
- 18const yellow = cfg.bands?.yellow ?? 45;
- 19const minCoverage = cfg.min_weight_coverage ?? 0.6;
- 20const asOf = args["as-of"] ? parseDate(args["as-of"]) : new Date();
- 21const renewalWindow = Number(cfg.renewal_window_days ?? 90);
- 23const rows = readCsv(input, "accounts CSV", ["account_id"]);
- 24const cols = rows.length ? Object.keys(rows[0]) : [];
- 25const missingCols = cfg.signals.filter((s) => !cols.includes(s.column.toLowerCase())).map((s) => s.column);
- 26if (missingCols.length) process.stderr.write(`Note: no column for ${missingCols.join(", ")}; those signals are skipped for every account.\n`);
- 27const totalWeight = cfg.signals.reduce((a, s) => a + s.weight, 0);
- 29const scored = rows.map((r) => {
- 30 const parts = [];
- 31 for (const s of cfg.signals) {
- 32 const v = num(r[s.column.toLowerCase()]);
- 33 if (!Number.isFinite(v)) continue;
- 34 const t = Math.max(0, Math.min(1, (v - s.bad) / (s.good - s.bad)));
- 35 parts.push({ signal: s.label || s.column, group: s.group || "other", value: v, score: t * 100, weight: s.weight });
- 36 }
- 37 const w = parts.reduce((a, p) => a + p.weight, 0);
- 38 const score = w ? parts.reduce((a, p) => a + p.score * p.weight, 0) / w : null;
- 39 const coverage = totalWeight ? w / totalWeight : 0;
- 40 const band = score === null || coverage < minCoverage ? "unknown" : score >= green ? "green" : score >= yellow ? "yellow" : "red";
- 41 const drivers = parts
- 42 .filter((p) => p.score < 60)
- 43 .sort((a, b) => b.weight * (100 - b.score) - a.weight * (100 - a.score))
- 44 .slice(0, 3)
- 45 .map((p) => `${p.signal} (${round(p.value, 2)})`);
- 46 const groups = {};
- 47 for (const g of [...new Set(parts.map((p) => p.group))]) {
- 48 const gp = parts.filter((p) => p.group === g);
- 49 const gw = gp.reduce((a, p) => a + p.weight, 0);
- 50 groups[g] = round(gp.reduce((a, p) => a + p.score * p.weight, 0) / gw, 0);
- 51 }
- 52 const renewal = parseDate(r.renewal_date);
- 53 const daysToRenewal = renewal ? Math.round((renewal - asOf) / DAY_MS) : null;
- 54 const prev = num(r.previous_score);
- 55 return {
- 56 account_id: r.account_id,
- 57 name: r.name || r.account_id,
- 58 arr: Number.isFinite(num(r.arr)) ? num(r.arr) : 0,
- 59 score: score === null ? null : round(score, 0),
- 60 band,
- 61 change: Number.isFinite(prev) && score !== null ? round(score - prev, 0) : null,
- 62 coverage: round(coverage, 2),
- 63 low_coverage: coverage < minCoverage,
- 64 groups,
- 65 drivers,
- 66 renewal_date: renewal ? dayKey(renewal) : "",
- 67 days_to_renewal: daysToRenewal,
- 68 priority: (band === "red" || (band === "yellow" && Number.isFinite(prev) && score - prev <= -10)) && daysToRenewal !== null && daysToRenewal <= renewalWindow,
- 69 };
- 70});
- 72const bands = ["green", "yellow", "red", "unknown"].map((b) => {
- 73 const list = scored.filter((s) => s.band === b);
- 74 return { band: b, accounts: list.length, arr: round(list.reduce((a, s) => a + s.arr, 0)) };
- 75});
- 76const atRisk = scored.filter((s) => s.band === "red" || s.band === "yellow").sort((a, b) => (a.priority === b.priority ? b.arr - a.arr : a.priority ? -1 : 1));
- 77const fallers = scored.filter((s) => s.change !== null && s.change <= -10).sort((a, b) => a.change - b.change);
- 79const result = {
- 80 as_of: dayKey(asOf),
- 81 weights: args.weights,
- 82 bands: Object.fromEntries(bands.map((b) => [b.band, { accounts: b.accounts, arr: b.arr }])),
- 83 priority: scored.filter((s) => s.priority).map((s) => s.account_id),
- 84 biggest_drops: fallers.map((s) => ({ account_id: s.account_id, change: s.change })),
- 85 low_coverage: scored.filter((s) => s.low_coverage).map((s) => s.account_id),
- 86 accounts: scored,
- 87};
- 88writeOut(`${outPrefix}.json`, JSON.stringify(result, null, 2) + "\n");
- 89const groupNames = [...new Set(scored.flatMap((s) => Object.keys(s.groups)))];
- 90writeOut(
- 91 `${outPrefix}.csv`,
- 92 toCsv(
- 93 scored.map((s) => ({ ...s, ...Object.fromEntries(groupNames.map((g) => [`${g}_score`, s.groups[g] ?? ""])), drivers: s.drivers.join("; ") })),
- 94 ["account_id", "name", "arr", "score", "band", "change", ...groupNames.map((g) => `${g}_score`), "drivers", "renewal_date", "days_to_renewal", "priority", "coverage"],
- 95 ),
- 96);
- 98const md = [
- 99 `# Customer health as of ${result.as_of}`,
- 100 "",
- 101 mdTable(["Band", "Accounts", "ARR"], bands.filter((b) => b.accounts).map((b) => [b.band, b.accounts, money(b.arr)])),
- 102 "",
- 103 `Bands: green ${green}+, yellow ${yellow} to ${green - 1}, red under ${yellow}. Weights from \`${args.weights}\`.`,
- 104 "",
- 105 `## Needs attention (${atRisk.length})`,
- 106 "",
- 107 atRisk.length
- 108 ? mdTable(
- 109 ["Account", "Score", "Change", "ARR", "Renews in", "Main drivers", ...groupNames],
- 110 atRisk.map((s) => [`${s.priority ? "**" : ""}${s.name}${s.priority ? "**" : ""}`, `${s.score} ${s.band}`, s.change === null ? "" : s.change > 0 ? `+${s.change}` : s.change, money(s.arr), s.days_to_renewal === null ? "" : `${s.days_to_renewal} d`, s.drivers.join(", ") || "mixed", ...groupNames.map((g) => s.groups[g] ?? "")]),
- 111 )
- 112 : "No accounts in red or yellow.",
- 113 "",
- 114 `Bold = priority: red (or yellow and down 10+ points) and renewing within ${renewalWindow} days.`,
- 115 "",
- 116 fallers.length ? `## Biggest drops since last score\n\n${fallers.map((s) => `- ${s.name}: ${s.change} to ${s.score}`).join("\n")}\n` : "",
- 117 result.low_coverage.length ? `Not banded, too little data (under ${Math.round(minCoverage * 100)}% of the weight has a value): ${result.low_coverage.join(", ")}.\n` : "",
- 118].join("\n");
- 119writeOut(`${outPrefix}.md`, md);
- 121process.stdout.write(`Wrote ${outPrefix}.md, ${outPrefix}.csv, ${outPrefix}.json\n`);
- 122process.stdout.write(bands.filter((b) => b.accounts).map((b) => `${b.band}: ${b.accounts} (${money(b.arr)})`).join(", ") + `. Priority: ${result.priority.length}.\n`);
