Skip to content
SlashClone
Made by@dealdesk

Quote builder

@dealdesk/quote-builder

Price a quote from your price book CSV and the products a customer picked. Handles flat, volume, and graduated tier pricing plus line and deal discounts, checks per-product discount limits, and writes a customer-ready quote and a deal file for the order form.

SkillRevOpsv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// quote-builder: price a quote from a price book CSV and a selections JSON.
  3. 3// Handles flat, volume (all units at the tier reached), and tiered (graduated)
  4. 4// pricing, then line and deal discounts. Node 22, built-ins only.
  5. 5//
  6. 6// Usage:
  7. 7// node scripts/quote.mjs <price-book.csv> <selections.json> [--out out] [--name quote] [--today YYYY-MM-DD]
  8. 8
  9. 9import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
  10. 10import { join } from "node:path";
  11. 12function fail(msg) {
  12. 13 console.error(`quote-builder: ${msg}`);
  13. 14 process.exit(1);
  14. 17const args = process.argv.slice(2);
  15. 18const opts = { out: "out", name: "quote", today: null };
  16. 19const pos = [];
  17. 20for (let i = 0; i < args.length; i++) {
  18. 21 if (args[i] === "--out") opts.out = args[++i];
  19. 22 else if (args[i] === "--name") opts.name = args[++i];
  20. 23 else if (args[i] === "--today") opts.today = args[++i];
  21. 24 else if (args[i] === "--help" || args[i] === "-h") {
  22. 25 console.log("Usage: node scripts/quote.mjs <price-book.csv> <selections.json> [--out out] [--name quote] [--today YYYY-MM-DD]");
  23. 26 process.exit(0);
  24. 27 } else pos.push(args[i]);
  25. 29if (pos.length !== 2) fail("give a price book CSV and a selections JSON. Try --help.");
  26. 31function read(p) {
  27. 32 try {
  28. 33 return readFileSync(p, "utf8");
  29. 34 } catch {
  30. 35 fail(`cannot read ${p}`);
  31. 36 }
  32. 39function parseCsv(text) {
  33. 40 const rows = [];
  34. 41 let row = [];
  35. 42 let field = "";
  36. 43 let q = false;
  37. 44 text = text.replace(/^\uFEFF/, "");
  38. 45 for (let i = 0; i < text.length; i++) {
  39. 46 const c = text[i];
  40. 47 if (q) {
  41. 48 if (c === '"') {
  42. 49 if (text[i + 1] === '"') {
  43. 50 field += '"';
  44. 51 i++;
  45. 52 } else q = false;
  46. 53 } else field += c;
  47. 54 } else if (c === '"') q = true;
  48. 55 else if (c === ",") {
  49. 56 row.push(field);
  50. 57 field = "";
  51. 58 } else if (c === "\n" || c === "\r") {
  52. 59 if (c === "\r" && text[i + 1] === "\n") i++;
  53. 60 row.push(field);
  54. 61 rows.push(row);
  55. 62 row = [];
  56. 63 field = "";
  57. 64 } else field += c;
  58. 65 }
  59. 66 if (field !== "" || row.length) {
  60. 67 row.push(field);
  61. 68 rows.push(row);
  62. 69 }
  63. 70 const data = rows.filter((r) => r.some((v) => v.trim() !== ""));
  64. 71 if (data.length === 0) return [];
  65. 72 const header = data.shift().map((h) => h.trim().toLowerCase().replace(/\s+/g, "_"));
  66. 73 return data.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? "").trim()])));
  67. 76const num = (s) => {
  68. 77 if (s === undefined || s === null || String(s).trim() === "") return null;
  69. 78 const n = Number(String(s).replace(/[$,]/g, ""));
  70. 79 return Number.isFinite(n) ? n : NaN;
  71. 80};
  72. 82// ---------- price book ----------
  73. 83const book = new Map();
  74. 84parseCsv(read(pos[0])).forEach((r, i) => {
  75. 85 const rowNo = i + 2;
  76. 86 if (!r.sku) fail(`price book row ${rowNo}: sku is empty`);
  77. 87 const price = num(r.unit_price ?? r.list_price ?? r.price);
  78. 88 if (price === null || Number.isNaN(price)) fail(`price book row ${rowNo} (${r.sku}): unit_price must be a number`);
  79. 89 const entry = book.get(r.sku) || {
  80. 90 sku: r.sku,
  81. 91 name: r.name || r.description || r.sku,
  82. 92 unit: r.unit || "unit",
  83. 93 billing: /one/i.test(r.billing || "") ? "one_time" : "recurring",
  84. 94 pricing: (r.pricing || "flat").toLowerCase(),
  85. 95 floor_pct: num(r.max_discount_pct),
  86. 96 tiers: [],
  87. 97 };
  88. 98 if (!["flat", "volume", "tiered"].includes(entry.pricing)) fail(`price book row ${rowNo} (${r.sku}): pricing must be flat, volume, or tiered`);
  89. 99 entry.tiers.push({ min: num(r.min_qty) ?? 1, max: num(r.max_qty), price });
  90. 100 book.set(r.sku, entry);
  91. 101});
  92. 102for (const e of book.values()) {
  93. 103 e.tiers.sort((a, b) => a.min - b.min);
  94. 104 for (let i = 1; i < e.tiers.length; i++) {
  95. 105 const prev = e.tiers[i - 1];
  96. 106 if (prev.max === null || prev.max + 1 !== e.tiers[i].min)
  97. 107 fail(`price book ${e.sku}: tiers must be contiguous (tier ending ${prev.max ?? "open"} is followed by a tier starting ${e.tiers[i].min})`);
  98. 108 }
  99. 111// ---------- selections ----------
  100. 112let sel;
  101. 113try {
  102. 114 sel = JSON.parse(read(pos[1]));
  103. 115} catch (e) {
  104. 116 fail(`${pos[1]} is not valid JSON: ${e.message}`);
  105. 118if (!Array.isArray(sel.lines) || sel.lines.length === 0) fail("selections need a non-empty lines array");
  106. 119const term = Number(sel.term_months ?? 12);
  107. 120const currency = (sel.currency || "USD").toUpperCase();
  108. 121const dealDisc = Number(sel.deal_discount_pct ?? 0);
  109. 123const cents = (x) => Math.round(x * 100);
  110. 124const fmt = (c) => new Intl.NumberFormat("en-US", { style: "currency", currency }).format(c / 100);
  111. 125const r1 = (x) => Math.round(x * 10) / 10;
  112. 127function priceFor(entry, qty) {
  113. 128 // returns total in cents for qty units (per year for recurring)
  114. 129 const t = entry.tiers;
  115. 130 if (entry.pricing === "flat" || t.length === 1) return Math.round(qty * cents(t[0].price));
  116. 131 if (entry.pricing === "volume") {
  117. 132 const tier = t.find((x) => qty >= x.min && (x.max === null || qty <= x.max)) || t[t.length - 1];
  118. 133 return Math.round(qty * cents(tier.price));
  119. 134 }
  120. 135 // tiered (graduated)
  121. 136 let total = 0;
  122. 137 for (const x of t) {
  123. 138 if (qty < x.min) break;
  124. 139 const top = x.max === null ? qty : Math.min(qty, x.max);
  125. 140 total += (top - x.min + 1) * cents(x.price);
  126. 141 }
  127. 142 return Math.round(total);
  128. 145const warnings = [];
  129. 146const termFactor = term / 12;
  130. 147const lines = sel.lines.map((l, i) => {
  131. 148 const e = book.get(l.sku);
  132. 149 if (!e) fail(`line ${i + 1}: SKU "${l.sku}" is not in the price book`);
  133. 150 const qty = Number(l.quantity);
  134. 151 if (!Number.isFinite(qty) || qty <= 0) fail(`line ${i + 1} (${l.sku}): quantity must be positive`);
  135. 152 const base = cents(e.tiers[0].price);
  136. 153 const listAnnual = Math.round(qty * base);
  137. 154 const tierAnnual = priceFor(e, qty);
  138. 155 const lineDisc = Number(l.discount_pct ?? (e.billing === "recurring" || sel.discount_one_time ? dealDisc : 0));
  139. 156 if (lineDisc < 0 || lineDisc >= 100) fail(`line ${i + 1} (${l.sku}): discount_pct must be between 0 and 100`);
  140. 157 const netAnnual = Math.round(tierAnnual * (1 - lineDisc / 100));
  141. 158 const factor = e.billing === "recurring" ? termFactor : 1;
  142. 159 const totalOff = listAnnual > 0 ? (1 - netAnnual / listAnnual) * 100 : 0;
  143. 160 if (e.floor_pct !== null && totalOff > e.floor_pct + 1e-9)
  144. 161 warnings.push(`${e.sku}: total discount ${r1(totalOff)}% is past this SKU's max of ${e.floor_pct}%`);
  145. 162 return {
  146. 163 sku: e.sku,
  147. 164 name: e.name,
  148. 165 unit: e.unit,
  149. 166 billing: e.billing,
  150. 167 pricing: e.pricing,
  151. 168 quantity: qty,
  152. 169 list_unit: base,
  153. 170 tier_unit: Math.round(tierAnnual / qty),
  154. 171 net_unit: Math.round(netAnnual / qty),
  155. 172 volume_savings_pct: listAnnual ? (1 - tierAnnual / listAnnual) * 100 : 0,
  156. 173 extra_discount_pct: lineDisc,
  157. 174 total_discount_pct: totalOff,
  158. 175 list_total: Math.round(listAnnual * factor),
  159. 176 net_total: Math.round(netAnnual * factor),
  160. 177 annual_net: e.billing === "recurring" ? netAnnual : 0,
  161. 178 };
  162. 179});
  163. 181const sum = (k) => lines.reduce((a, l) => a + l[k], 0);
  164. 182const totals = {
  165. 183 list_total: sum("list_total"),
  166. 184 net_total: sum("net_total"),
  167. 185 acv: sum("annual_net"),
  168. 186 one_time: lines.filter((l) => l.billing === "one_time").reduce((a, l) => a + l.net_total, 0),
  169. 187};
  170. 188totals.discount_amount = totals.list_total - totals.net_total;
  171. 189totals.discount_pct = totals.list_total ? (totals.discount_amount / totals.list_total) * 100 : 0;
  172. 190if (totals.discount_pct > 25) warnings.push(`Overall discount ${r1(totals.discount_pct)}% is high. Run discount-approval-check before sending.`);
  173. 192const today = opts.today ? new Date(`${opts.today}T00:00:00Z`) : new Date();
  174. 193if (Number.isNaN(today.getTime())) fail("--today must be YYYY-MM-DD");
  175. 194const validDays = Number(sel.valid_days ?? 30);
  176. 195const validUntil = new Date(today.getTime() + validDays * 86400000).toISOString().slice(0, 10);
  177. 197// ---------- outputs ----------
  178. 198const md = [];
  179. 199md.push(`# Quote${sel.quote_number ? ` ${sel.quote_number}` : ""} for ${sel.customer || "Customer"}`, "");
  180. 200md.push(`Term: ${term} months. Currency: ${currency}. Valid until ${validUntil}.`, "");
  181. 201md.push("| SKU | Product | Qty | List / unit | Your price / unit | Savings | Total for term |");
  182. 202md.push("| --- | --- | ---: | ---: | ---: | ---: | ---: |");
  183. 203for (const l of lines)
  184. 204 md.push(`| ${l.sku} | ${l.name} | ${l.quantity} | ${fmt(l.list_unit)} | ${fmt(l.net_unit)} | ${r1(l.total_discount_pct)}% | ${fmt(l.net_total)} |`);
  185. 205md.push("");
  186. 206md.push(`| | |`, `| --- | ---: |`);
  187. 207md.push(`| List value | ${fmt(totals.list_total)} |`);
  188. 208md.push(`| Savings | ${fmt(totals.discount_amount)} (${r1(totals.discount_pct)}%) |`);
  189. 209md.push(`| **Total for term** | **${fmt(totals.net_total)}** |`);
  190. 210md.push(`| Annual recurring | ${fmt(totals.acv)} |`);
  191. 211md.push(`| One-time fees | ${fmt(totals.one_time)} |`);
  192. 212md.push("", "Recurring prices are per unit per year. Prices exclude taxes.", "");
  193. 213md.push("## How the price was built (internal)", "");
  194. 214for (const l of lines) {
  195. 215 const parts = [`${l.pricing} pricing`];
  196. 216 if (l.volume_savings_pct > 0.0001) parts.push(`volume tiers save ${r1(l.volume_savings_pct)}%`);
  197. 217 if (l.extra_discount_pct > 0) parts.push(`plus ${r1(l.extra_discount_pct)}% discount`);
  198. 218 md.push(`- ${l.sku}: ${parts.join(", ")}.`);
  199. 220if (warnings.length) {
  200. 221 md.push("", "## Warnings (internal)", "");
  201. 222 for (const w of warnings) md.push(`- ${w}`);
  202. 224md.push("");
  203. 226const m = (c) => c / 100;
  204. 227const quote = {
  205. 228 customer: sel.customer || null,
  206. 229 quote_number: sel.quote_number || null,
  207. 230 currency,
  208. 231 term_months: term,
  209. 232 valid_until: validUntil,
  210. 233 lines: lines.map((l) => ({
  211. 234 sku: l.sku,
  212. 235 quantity: l.quantity,
  213. 236 billing: l.billing,
  214. 237 pricing: l.pricing,
  215. 238 list_unit: m(l.list_unit),
  216. 239 net_unit: m(l.net_unit),
  217. 240 volume_savings_pct: r1(l.volume_savings_pct),
  218. 241 extra_discount_pct: r1(l.extra_discount_pct),
  219. 242 total_discount_pct: r1(l.total_discount_pct),
  220. 243 list_total: m(l.list_total),
  221. 244 net_total: m(l.net_total),
  222. 245 })),
  223. 246 totals: {
  224. 247 list_total: m(totals.list_total),
  225. 248 net_total: m(totals.net_total),
  226. 249 discount_amount: m(totals.discount_amount),
  227. 250 discount_pct: r1(totals.discount_pct),
  228. 251 acv: m(totals.acv),
  229. 252 one_time: m(totals.one_time),
  230. 253 },
  231. 254 warnings,
  232. 255};
  233. 257// A deal file that po-creator and discount-approval-check read directly.
  234. 258const deal = {
  235. 259 customer: { legal_name: sel.customer || "" },
  236. 260 currency,
  237. 261 term_months: term,
  238. 262 start_date: sel.start_date || "",
  239. 263 lines: lines.map((l) => ({
  240. 264 sku: l.sku,
  241. 265 description: l.name,
  242. 266 quantity: l.quantity,
  243. 267 unit: l.unit,
  244. 268 list_price: m(l.list_unit),
  245. 269 net_price: m(l.net_unit),
  246. 270 billing: l.billing,
  247. 271 })),
  248. 272};
  249. 274mkdirSync(opts.out, { recursive: true });
  250. 275writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
  251. 276writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(quote, null, 2) + "\n");
  252. 277writeFileSync(join(opts.out, `${opts.name}-deal.json`), JSON.stringify(deal, null, 2) + "\n");
  253. 278console.log(
  254. 279 `Quote for ${sel.customer || "customer"}: ${fmt(totals.net_total)} for ${term} months (${r1(totals.discount_pct)}% off list), ${warnings.length} warning(s). Wrote ${join(opts.out, opts.name)}.{md,json} and ${opts.name}-deal.json`,
  255. 280);