Skip to content
SlashClone
Made by@dealdesk

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.

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