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