Discount approval check
@dealdesk/discount-approval-check
Check a deal against your own approval policy file and get the list of who must approve and why: discount ladders, payment terms, billing frequency, term length, free months, non-standard terms, and deal size. Also shows how much room is left before the next approval.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// discount-approval-check: compare a deal against an editable approval policy and
- 3// list who must approve and why. Node 22, built-ins only.
- 4//
- 5// Usage:
- 6// node scripts/approvals.mjs <deal.json> <policy.json> [--out out] [--name approvals]
- 8import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
- 9import { join } from "node:path";
- 11function fail(msg) {
- 12 console.error(`discount-approval-check: ${msg}`);
- 13 process.exit(1);
- 14}
- 16const args = process.argv.slice(2);
- 17const opts = { out: "out", name: "approvals" };
- 18const pos = [];
- 19for (let i = 0; i < args.length; i++) {
- 20 if (args[i] === "--out") opts.out = args[++i];
- 21 else if (args[i] === "--name") opts.name = args[++i];
- 22 else if (args[i] === "--help" || args[i] === "-h") {
- 23 console.log("Usage: node scripts/approvals.mjs <deal.json> <policy.json> [--out out] [--name approvals]");
- 24 process.exit(0);
- 25 } else pos.push(args[i]);
- 26}
- 27if (pos.length !== 2) fail("give a deal JSON and a policy JSON. Try --help.");
- 29function readJson(p) {
- 30 let text;
- 31 try {
- 32 text = readFileSync(p, "utf8");
- 33 } catch {
- 34 fail(`cannot read ${p}`);
- 35 }
- 36 try {
- 37 return JSON.parse(text);
- 38 } catch (e) {
- 39 fail(`${p} is not valid JSON: ${e.message}`);
- 40 }
- 41}
- 42const deal = readJson(pos[0]);
- 43const policy = readJson(pos[1]);
- 44if (!Array.isArray(policy.rules)) fail("policy needs a rules array");
- 45if (!Array.isArray(deal.lines) || deal.lines.length === 0) fail("deal needs a non-empty lines array");
- 47// ---------- metrics ----------
- 48const term = Number(deal.term_months ?? 12);
- 49let listTotal = 0;
- 50let netTotal = 0;
- 51let recList = 0;
- 52let recNet = 0;
- 53let acv = 0;
- 54let oneTime = 0;
- 55let maxLine = 0;
- 56for (const [i, l] of deal.lines.entries()) {
- 57 const q = Number(l.quantity);
- 58 const list = Number(l.list_price ?? l.net_price);
- 59 const net = Number(l.net_price ?? l.list_price);
- 60 if (![q, list, net].every(Number.isFinite)) fail(`deal line ${i + 1}: quantity, list_price, and net_price must be numbers`);
- 61 const recurring = !(l.billing === "one_time" || l.billing === "one-time");
- 62 const perYear = l.price_period === "month" ? 12 : 1;
- 63 const factor = recurring ? (perYear * term) / 12 : 1;
- 64 listTotal += q * list * factor;
- 65 netTotal += q * net * factor;
- 66 if (recurring) {
- 67 recList += q * list * factor;
- 68 recNet += q * net * factor;
- 69 acv += q * net * perYear;
- 70 } else oneTime += q * net;
- 71 if (list > 0) maxLine = Math.max(maxLine, (1 - net / list) * 100);
- 72}
- 73const r1 = (x) => Math.round(x * 10) / 10;
- 74const termsText = String(deal.payment_terms ?? "Net 30");
- 75const metrics = {
- 76 discount_pct: r1(listTotal > 0 ? (1 - netTotal / listTotal) * 100 : 0),
- 77 recurring_discount_pct: r1(recList > 0 ? (1 - recNet / recList) * 100 : 0),
- 78 max_line_discount_pct: r1(maxLine),
- 79 acv: Math.round(acv * 100) / 100,
- 80 tcv: Math.round(netTotal * 100) / 100,
- 81 one_time_total: Math.round(oneTime * 100) / 100,
- 82 term_months: term,
- 83 payment_terms_days: /due on receipt|upon receipt/i.test(termsText) ? 0 : Number((termsText.match(/\d+/) || [30])[0]),
- 84 billing_frequency: String(deal.billing_frequency ?? "annual").toLowerCase(),
- 85 free_months: Number(deal.free_months ?? 0),
- 86 nonstandard_terms: Array.isArray(deal.nonstandard_terms) ? deal.nonstandard_terms.length : 0,
- 87 ...(deal.flags && typeof deal.flags === "object" ? deal.flags : {}),
- 88};
- 90// ---------- evaluate ----------
- 91const OPS = {
- 92 ">": (a, b) => a > b,
- 93 ">=": (a, b) => a >= b,
- 94 "<": (a, b) => a < b,
- 95 "<=": (a, b) => a <= b,
- 96 "==": (a, b) => a === b,
- 97 "!=": (a, b) => a !== b,
- 98 in: (a, b) => Array.isArray(b) && b.includes(a),
- 99 not_in: (a, b) => Array.isArray(b) && !b.includes(a),
- 100 is_true: (a) => a === true,
- 101};
- 102function evalCond(c) {
- 103 if (!(c.op in OPS)) fail(`unknown op "${c.op}" (use > >= < <= == != in not_in is_true)`);
- 104 if (!(c.metric in metrics)) return { known: false };
- 105 return { known: true, ok: OPS[c.op](metrics[c.metric], c.value), actual: metrics[c.metric] };
- 106}
- 107const describe = (c) => (c.op === "is_true" ? `${c.metric} is true` : `${c.metric} ${c.op} ${JSON.stringify(c.value)}`);
- 109const fired = [];
- 110const unknown = [];
- 111for (const rule of policy.rules) {
- 112 if (!rule.id || !rule.approver) fail("every rule needs an id and an approver");
- 113 const all = rule.when || [];
- 114 const any = rule.when_any || [];
- 115 const results = [...all, ...any].map((c) => ({ c, ...evalCond(c) }));
- 116 const missing = results.filter((r) => !r.known);
- 117 if (missing.length) {
- 118 unknown.push({ id: rule.id, approver: rule.approver, missing: [...new Set(missing.map((m) => m.c.metric))] });
- 119 continue;
- 120 }
- 121 const allOk = results.slice(0, all.length).every((r) => r.ok);
- 122 const anyOk = any.length === 0 || results.slice(all.length).some((r) => r.ok);
- 123 if (allOk && anyOk && all.length + any.length > 0) {
- 124 const because = results.filter((r) => r.ok).map((r) => `${describe(r.c)} (actual ${JSON.stringify(r.actual)})`);
- 125 fired.push({ id: rule.id, group: rule.group || null, approver: rule.approver, reason: rule.reason || rule.id, because });
- 126 }
- 127}
- 129// within a group, only the last matching rule in file order counts (list a ladder low to high)
- 130const lastInGroup = new Map();
- 131fired.forEach((f, i) => {
- 132 if (f.group) lastInGroup.set(f.group, i);
- 133});
- 134const effective = fired.filter((f, i) => !f.group || lastInGroup.get(f.group) === i);
- 135const superseded = fired.filter((f) => !effective.includes(f)).map((f) => f.id);
- 137const rank = new Map((policy.approvers || []).map((a, i) => [a, i]));
- 138const byApprover = new Map();
- 139for (const f of effective) {
- 140 if (!byApprover.has(f.approver)) byApprover.set(f.approver, []);
- 141 byApprover.get(f.approver).push(f);
- 142}
- 143const approvals = [...byApprover.entries()]
- 144 .sort((a, b) => (rank.get(a[0]) ?? 999) - (rank.get(b[0]) ?? 999))
- 145 .map(([approver, rules]) => ({ approver, rules: rules.map((r) => ({ id: r.id, reason: r.reason, because: r.because })) }));
- 147// headroom: the nearest single-metric ">" or ">=" threshold not yet crossed, per metric
- 148const headroom = [];
- 149const seen = new Set();
- 150for (const rule of policy.rules) {
- 151 const conds = rule.when || [];
- 152 if (conds.length !== 1 || rule.when_any) continue;
- 153 const c = conds[0];
- 154 if (!(c.op === ">" || c.op === ">=") || typeof metrics[c.metric] !== "number") continue;
- 155 if (OPS[c.op](metrics[c.metric], c.value)) continue;
- 156 const candidates = policy.rules.filter(
- 157 (r) => (r.when || []).length === 1 && r.when[0].metric === c.metric && (r.when[0].op === ">" || r.when[0].op === ">=") && !OPS[r.when[0].op](metrics[c.metric], r.when[0].value),
- 158 );
- 159 const nearest = candidates.sort((a, b) => a.when[0].value - b.when[0].value)[0];
- 160 if (nearest && nearest.when[0].value !== 0 && !seen.has(c.metric)) {
- 161 seen.add(c.metric);
- 162 headroom.push({ metric: c.metric, actual: metrics[c.metric], next_threshold: nearest.when[0].value, op: nearest.when[0].op, approver: nearest.approver });
- 163 }
- 164}
- 166const result = {
- 167 policy: policy.name || "approval policy",
- 168 deal: deal.customer?.legal_name || deal.name || null,
- 169 status: approvals.length ? "needs_approval" : "auto_approved",
- 170 approver_count: approvals.length,
- 171 approvals,
- 172 superseded_rules: superseded,
- 173 could_not_check: unknown,
- 174 headroom,
- 175 metrics,
- 176};
- 178const md = [];
- 179md.push(`# Approval check: ${result.deal || "deal"}`, "");
- 180md.push(
- 181 result.status === "auto_approved"
- 182 ? "**Within policy.** No approvals needed."
- 183 : `**Needs ${approvals.length} approval(s):** ${approvals.map((a) => a.approver).join(", ")}`,
- 184 "",
- 185);
- 186for (const a of approvals) {
- 187 md.push(`## ${a.approver}`, "");
- 188 for (const r of a.rules) md.push(`- ${r.reason}: ${r.because.join("; ")}`);
- 189 md.push("");
- 190}
- 191if (unknown.length) {
- 192 md.push("## Could not check", "");
- 193 for (const u of unknown) md.push(`- Rule ${u.id} (${u.approver}): deal has no ${u.missing.join(", ")}`);
- 194 md.push("");
- 195}
- 196if (headroom.length) {
- 197 md.push("## Room before the next approval", "");
- 198 for (const h of headroom) md.push(`- ${h.metric}: ${h.actual} now; ${h.approver} needed ${h.op === ">=" ? "at" : "past"} ${h.next_threshold}`);
- 199 md.push("");
- 200}
- 201md.push("## Deal metrics", "", "| Metric | Value |", "| --- | --- |");
- 202for (const [k, v] of Object.entries(metrics)) md.push(`| ${k} | ${v} |`);
- 203md.push("");
- 205mkdirSync(opts.out, { recursive: true });
- 206writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(result, null, 2) + "\n");
- 207writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
- 208console.log(
- 209 result.status === "auto_approved"
- 210 ? `Within policy. Wrote ${join(opts.out, opts.name)}.{md,json}`
- 211 : `Needs approval from: ${approvals.map((a) => a.approver).join(", ")}. Wrote ${join(opts.out, opts.name)}.{md,json}`,
- 212);
