Skip to content
SlashClone
Made by@dealdesk

Po creator

@dealdesk/po-creator

Turn a deal into a clean order form or purchase order, in markdown and printable HTML. Computes line totals, discounts, contract value, and a billing schedule, and lists what finance needs before signature. Works offline from a JSON file or a short text form.

SkillRevOpsv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// po-creator: turn a deal (JSON or a short text form) into a clean order form or
  3. 3// purchase order, in markdown and printable HTML, plus a JSON summary with line
  4. 4// totals, discount math, a billing schedule, and a finance checklist.
  5. 5// Node 22, built-ins only.
  6. 6//
  7. 7// Usage:
  8. 8// node scripts/po-creator.mjs <deal.json|deal.txt> [--out out] [--name order-form] [--today YYYY-MM-DD]
  9. 9
  10. 10import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
  11. 11import { join } from "node:path";
  12. 13function fail(msg) {
  13. 14 console.error(`po-creator: ${msg}`);
  14. 15 process.exit(1);
  15. 18// ---------- arguments ----------
  16. 19const args = process.argv.slice(2);
  17. 20const opts = { out: "out", name: "order-form", today: null };
  18. 21const positional = [];
  19. 22for (let i = 0; i < args.length; i++) {
  20. 23 const a = args[i];
  21. 24 if (a === "--out") opts.out = args[++i];
  22. 25 else if (a === "--name") opts.name = args[++i];
  23. 26 else if (a === "--today") opts.today = args[++i];
  24. 27 else if (a === "--help" || a === "-h") {
  25. 28 console.log("Usage: node scripts/po-creator.mjs <deal.json|deal.txt> [--out out] [--name order-form] [--today YYYY-MM-DD]");
  26. 29 process.exit(0);
  27. 30 } else positional.push(a);
  28. 32if (positional.length !== 1) fail("give exactly one deal file (JSON or short text form). Try --help.");
  29. 33const inputPath = positional[0];
  30. 34let raw;
  31. 35try {
  32. 36 raw = readFileSync(inputPath, "utf8");
  33. 37} catch {
  34. 38 fail(`cannot read ${inputPath}`);
  35. 41// ---------- parsing ----------
  36. 42function parseShortForm(text) {
  37. 43 const deal = { customer: { billing_contact: {} }, lines: [] };
  38. 44 const lineNo = { n: 0 };
  39. 45 for (const rawLine of text.split(/\r?\n/)) {
  40. 46 lineNo.n++;
  41. 47 const line = rawLine.trim();
  42. 48 if (!line || line.startsWith("#")) continue;
  43. 49 const idx = line.indexOf(":");
  44. 50 if (idx < 0) fail(`line ${lineNo.n}: expected "key: value", got "${line}"`);
  45. 51 const key = line.slice(0, idx).trim().toLowerCase().replace(/\s+/g, "_");
  46. 52 const value = line.slice(idx + 1).trim();
  47. 53 switch (key) {
  48. 54 case "type":
  49. 55 case "document_type":
  50. 56 deal.document_type = value.toLowerCase().includes("purchase") ? "purchase_order" : "order_form";
  51. 57 break;
  52. 58 case "number":
  53. 59 case "order_number":
  54. 60 deal.order_number = value;
  55. 61 break;
  56. 62 case "seller":
  57. 63 deal.seller = { ...(deal.seller || {}), name: value };
  58. 64 break;
  59. 65 case "seller_address":
  60. 66 deal.seller = { ...(deal.seller || {}), address: value };
  61. 67 break;
  62. 68 case "customer":
  63. 69 case "customer_legal_name":
  64. 70 case "legal_name":
  65. 71 deal.customer.legal_name = value;
  66. 72 break;
  67. 73 case "address":
  68. 74 case "customer_address":
  69. 75 deal.customer.address = value;
  70. 76 break;
  71. 77 case "billing_contact": {
  72. 78 const [name, email, phone] = value.split(",").map((s) => s.trim());
  73. 79 deal.customer.billing_contact = { name, email, phone };
  74. 80 break;
  75. 81 }
  76. 82 case "signer": {
  77. 83 const [name, title, email] = value.split(",").map((s) => s.trim());
  78. 84 deal.customer.signer = { name, title, email };
  79. 85 break;
  80. 86 }
  81. 87 case "tax_id":
  82. 88 deal.customer.tax_id = value;
  83. 89 break;
  84. 90 case "currency":
  85. 91 deal.currency = value.toUpperCase();
  86. 92 break;
  87. 93 case "start":
  88. 94 case "start_date":
  89. 95 deal.start_date = value;
  90. 96 break;
  91. 97 case "term":
  92. 98 case "term_months":
  93. 99 deal.term_months = parseInt(value, 10);
  94. 100 break;
  95. 101 case "billing":
  96. 102 case "billing_frequency":
  97. 103 deal.billing_frequency = value.toLowerCase();
  98. 104 break;
  99. 105 case "payment_terms":
  100. 106 deal.payment_terms = value;
  101. 107 break;
  102. 108 case "po_required":
  103. 109 deal.po_required = /^(y|yes|true|1)$/i.test(value);
  104. 110 break;
  105. 111 case "po_number":
  106. 112 deal.po_number = value;
  107. 113 break;
  108. 114 case "notes":
  109. 115 deal.notes = value;
  110. 116 break;
  111. 117 case "line": {
  112. 118 const parts = value.split("|").map((s) => s.trim());
  113. 119 if (parts.length < 5) fail(`line ${lineNo.n}: a line needs "sku | description | quantity | list price | net price [| one-time] [| month]"`);
  114. 120 const flags = parts.slice(5).map((s) => s.toLowerCase());
  115. 121 deal.lines.push({
  116. 122 sku: parts[0],
  117. 123 description: parts[1],
  118. 124 quantity: Number(parts[2]),
  119. 125 list_price: Number(parts[3].replace(/[,$]/g, "")),
  120. 126 net_price: Number(parts[4].replace(/[,$]/g, "")),
  121. 127 billing: flags.some((f) => f.startsWith("one")) ? "one_time" : "recurring",
  122. 128 price_period: flags.includes("month") ? "month" : "year",
  123. 129 });
  124. 130 break;
  125. 131 }
  126. 132 default:
  127. 133 fail(`line ${lineNo.n}: unknown key "${key}"`);
  128. 134 }
  129. 135 }
  130. 136 return deal;
  131. 139let deal;
  132. 140if (inputPath.endsWith(".json") || raw.trim().startsWith("{")) {
  133. 141 try {
  134. 142 deal = JSON.parse(raw);
  135. 143 } catch (e) {
  136. 144 fail(`${inputPath} is not valid JSON: ${e.message}`);
  137. 145 }
  138. 146} else {
  139. 147 deal = parseShortForm(raw);
  140. 150// ---------- normalize ----------
  141. 151deal.document_type = deal.document_type === "purchase_order" ? "purchase_order" : "order_form";
  142. 152deal.currency = (deal.currency || "USD").toUpperCase();
  143. 153deal.customer = deal.customer || {};
  144. 154deal.customer.billing_contact = deal.customer.billing_contact || {};
  145. 155deal.seller = deal.seller || {};
  146. 156deal.term_months = Number(deal.term_months || 12);
  147. 157deal.billing_frequency = (deal.billing_frequency || "annual").toLowerCase();
  148. 158deal.payment_terms = deal.payment_terms || "Net 30";
  149. 159deal.po_required = Boolean(deal.po_required);
  150. 160if (!Array.isArray(deal.lines) || deal.lines.length === 0) fail("the deal has no lines. Add at least one product line.");
  151. 162const FREQ_MONTHS = { monthly: 1, quarterly: 3, "semi-annual": 6, semiannual: 6, annual: 12, annually: 12, upfront: 0 };
  152. 163if (!(deal.billing_frequency in FREQ_MONTHS)) fail(`billing_frequency "${deal.billing_frequency}" must be one of monthly, quarterly, semi-annual, annual, upfront`);
  153. 165// ---------- dates ----------
  154. 166function parseDate(s) {
  155. 167 if (!s || !/^\d{4}-\d{2}-\d{2}$/.test(String(s))) return null;
  156. 168 const d = new Date(`${s}T00:00:00Z`);
  157. 169 return Number.isNaN(d.getTime()) ? null : d;
  158. 171function iso(d) {
  159. 172 return d.toISOString().slice(0, 10);
  160. 174function addMonths(d, n) {
  161. 175 const y = d.getUTCFullYear();
  162. 176 const m = d.getUTCMonth() + n;
  163. 177 const day = d.getUTCDate();
  164. 178 const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
  165. 179 return new Date(Date.UTC(y, m, Math.min(day, last)));
  166. 181function addDays(d, n) {
  167. 182 return new Date(d.getTime() + n * 86400000);
  168. 184const today = opts.today ? parseDate(opts.today) : parseDate(new Date().toISOString().slice(0, 10));
  169. 185if (!today) fail(`--today must be YYYY-MM-DD`);
  170. 186const start = parseDate(deal.start_date);
  171. 187const end = start ? addDays(addMonths(start, deal.term_months), -1) : null;
  172. 189// ---------- money (integer cents) ----------
  173. 190const cents = (x) => Math.round(Number(x) * 100);
  174. 191const fmtMoney = (c) =>
  175. 192 new Intl.NumberFormat("en-US", { style: "currency", currency: deal.currency }).format(c / 100);
  176. 193const pct = (x) => `${(Math.round(x * 10) / 10).toFixed(1)}%`;
  177. 195const checklist = [];
  178. 196function check(level, item) {
  179. 197 checklist.push({ level, item });
  180. 200const lines = deal.lines.map((l, i) => {
  181. 201 const n = i + 1;
  182. 202 const quantity = Number(l.quantity);
  183. 203 const listUnit = cents(l.list_price ?? l.net_price);
  184. 204 const netUnit = cents(l.net_price ?? l.list_price);
  185. 205 const billing = l.billing === "one_time" || l.billing === "one-time" ? "one_time" : "recurring";
  186. 206 const period = l.price_period === "month" ? "month" : "year";
  187. 207 if (!Number.isFinite(quantity) || quantity <= 0) check("blocker", `Line ${n} (${l.sku || l.description}): quantity must be a positive number.`);
  188. 208 if (!Number.isInteger(quantity)) check("warning", `Line ${n} (${l.sku || l.description}): quantity ${quantity} is not a whole number. Confirm the unit.`);
  189. 209 if (netUnit > listUnit) check("warning", `Line ${n} (${l.sku || l.description}): net price is above list price.`);
  190. 210 if (!Number.isFinite(listUnit) || !Number.isFinite(netUnit)) fail(`line ${n}: list_price and net_price must be numbers`);
  191. 211 // multiplier from unit price to the total for the whole term
  192. 212 let termFactor = 1;
  193. 213 let annualFactor = 0;
  194. 214 if (billing === "recurring") {
  195. 215 termFactor = period === "month" ? deal.term_months : deal.term_months / 12;
  196. 216 annualFactor = period === "month" ? 12 : 1;
  197. 217 }
  198. 218 const listTotal = Math.round(quantity * listUnit * termFactor);
  199. 219 const netTotal = Math.round(quantity * netUnit * termFactor);
  200. 220 return {
  201. 221 line: n,
  202. 222 sku: l.sku || "",
  203. 223 description: l.description || "",
  204. 224 quantity,
  205. 225 unit: l.unit || (billing === "recurring" ? `per ${period}` : "one-time"),
  206. 226 billing,
  207. 227 price_period: billing === "recurring" ? period : null,
  208. 228 list_unit: listUnit,
  209. 229 net_unit: netUnit,
  210. 230 discount_pct: listUnit > 0 ? (1 - netUnit / listUnit) * 100 : 0,
  211. 231 list_total: listTotal,
  212. 232 net_total: netTotal,
  213. 233 discount_amount: listTotal - netTotal,
  214. 234 annual_net: Math.round(quantity * netUnit * annualFactor),
  215. 235 };
  216. 236});
  217. 238const sum = (arr, k) => arr.reduce((a, x) => a + x[k], 0);
  218. 239const recurring = lines.filter((l) => l.billing === "recurring");
  219. 240const oneTime = lines.filter((l) => l.billing === "one_time");
  220. 241const totals = {
  221. 242 list_total: sum(lines, "list_total"),
  222. 243 net_total: sum(lines, "net_total"),
  223. 244 discount_amount: sum(lines, "discount_amount"),
  224. 245 acv: sum(recurring, "annual_net"),
  225. 246 one_time_total: sum(oneTime, "net_total"),
  226. 247 recurring_term_total: sum(recurring, "net_total"),
  227. 248};
  228. 249totals.discount_pct = totals.list_total > 0 ? (totals.discount_amount / totals.list_total) * 100 : 0;
  229. 250totals.first_year_total =
  230. 251 Math.round(totals.acv * Math.min(1, deal.term_months / 12)) + totals.one_time_total;
  231. 252totals.tcv = totals.net_total;
  232. 254// ---------- billing schedule ----------
  233. 255const schedule = [];
  234. 257 const freq = FREQ_MONTHS[deal.billing_frequency];
  235. 258 if (freq === 0 || !start) {
  236. 259 schedule.push({ invoice: 1, date: start ? iso(start) : "on signature", period: "full term", amount: totals.tcv });
  237. 260 } else {
  238. 261 const periods = Math.ceil(deal.term_months / freq);
  239. 262 let billed = 0;
  240. 263 for (let p = 0; p < periods; p++) {
  241. 264 const monthsInPeriod = Math.min(freq, deal.term_months - p * freq);
  242. 265 let amount =
  243. 266 p === periods - 1
  244. 267 ? totals.recurring_term_total - billed
  245. 268 : Math.round((totals.recurring_term_total * monthsInPeriod) / deal.term_months);
  246. 269 billed += amount;
  247. 270 if (p === 0) amount += totals.one_time_total;
  248. 271 const pStart = addMonths(start, p * freq);
  249. 272 const pEnd = addDays(addMonths(start, p * freq + monthsInPeriod), -1);
  250. 273 schedule.push({ invoice: p + 1, date: iso(pStart), period: `${iso(pStart)} to ${iso(pEnd)}`, amount });
  251. 274 }
  252. 275 }
  253. 278// ---------- finance checklist ----------
  254. 279const c = deal.customer;
  255. 280const bc = c.billing_contact || {};
  256. 281if (!c.legal_name) check("blocker", "Customer legal name is missing.");
  257. 282else if (!/\b(inc|incorporated|llc|l\.l\.c|ltd|limited|gmbh|ag|sa|s\.a\.|sas|bv|b\.v\.|plc|corp|corporation|co|company|pty|oy|ab|as|kk|srl|spa|lp|llp|pbc)\b\.?/i.test(c.legal_name))
  258. 283 check("warning", `Customer legal name "${c.legal_name}" has no entity suffix (Inc., LLC, Ltd., GmbH). Confirm the exact registered name.`);
  259. 284if (!c.address) check("blocker", "Customer billing address is missing.");
  260. 285if (!bc.email) check("blocker", "Billing contact email is missing (where invoices go).");
  261. 286else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(bc.email)) check("blocker", `Billing contact email "${bc.email}" does not look valid.`);
  262. 287if (!bc.name) check("warning", "Billing contact name is missing. An accounts payable team name is fine.");
  263. 288if (!start) check("blocker", "Start date is missing or not YYYY-MM-DD.");
  264. 289else if (start < today) check("warning", `Start date ${iso(start)} is in the past. Confirm backdating with finance.`);
  265. 290if (!Number.isFinite(deal.term_months) || deal.term_months <= 0) check("blocker", "Term in months must be a positive number.");
  266. 291if (deal.po_required && !deal.po_number) check("blocker", "Customer requires a PO, and no PO number is recorded. Get it before invoicing, ideally before signature.");
  267. 292const termDays = Number((String(deal.payment_terms).match(/\d+/) || [30])[0]);
  268. 293if (termDays > 30) check("warning", `Payment terms ${deal.payment_terms} are longer than Net 30. Usually needs finance approval.`);
  269. 294if (totals.discount_pct > 20) check("warning", `Overall discount is ${pct(totals.discount_pct)}. Check approvals before sending.`);
  270. 295if (!c.signer || !c.signer.name) check("warning", "Customer signer name and title are missing.");
  271. 296if (!c.tax_id) check("info", "No customer tax or VAT ID recorded. Needed for most non-US customers and for tax-exempt customers (get the exemption certificate).");
  272. 297if (!deal.order_number) check("info", "No order number set. One will be shown as DRAFT.");
  273. 298check("info", "Confirm the customer's vendor onboarding (supplier forms, W-9 or bank details, insurance certificate) if they asked for it.");
  274. 300const blockers = checklist.filter((x) => x.level === "blocker").length;
  275. 301const docTitle = deal.document_type === "purchase_order" ? "Purchase Order" : "Order Form";
  276. 302const number = deal.order_number || "DRAFT";
  277. 304// ---------- markdown ----------
  278. 305const md = [];
  279. 306md.push(`# ${docTitle} ${number}`);
  280. 307md.push("");
  281. 308if (blockers > 0) md.push(`> DRAFT: ${blockers} item(s) must be fixed before signature. See the checklist at the end.`, "");
  282. 309md.push(`| | |`, `| --- | --- |`);
  283. 310if (deal.seller.name) md.push(`| Provider | ${deal.seller.name}${deal.seller.address ? `, ${deal.seller.address}` : ""} |`);
  284. 311md.push(`| Customer (legal name) | ${c.legal_name || "MISSING"} |`);
  285. 312md.push(`| Billing address | ${c.address || "MISSING"} |`);
  286. 313md.push(`| Billing contact | ${[bc.name, bc.email, bc.phone].filter(Boolean).join(", ") || "MISSING"} |`);
  287. 314if (c.tax_id) md.push(`| Tax ID | ${c.tax_id} |`);
  288. 315md.push(`| Term | ${deal.term_months} months, ${start ? iso(start) : "start date TBD"} to ${end ? iso(end) : "TBD"} |`);
  289. 316md.push(`| Billing | ${deal.billing_frequency}, ${deal.payment_terms} |`);
  290. 317md.push(`| Customer PO | ${deal.po_required ? deal.po_number || "REQUIRED, not yet received" : deal.po_number || "Not required"} |`);
  291. 318md.push(`| Currency | ${deal.currency} |`);
  292. 319md.push("");
  293. 320md.push("## Products and services", "");
  294. 321md.push("| # | SKU | Description | Qty | Unit | List price | Net price | Discount | Term total |");
  295. 322md.push("| --- | --- | --- | ---: | --- | ---: | ---: | ---: | ---: |");
  296. 323for (const l of lines) {
  297. 324 md.push(
  298. 325 `| ${l.line} | ${l.sku} | ${l.description} | ${l.quantity} | ${l.unit} | ${fmtMoney(l.list_unit)} | ${fmtMoney(l.net_unit)} | ${pct(l.discount_pct)} | ${fmtMoney(l.net_total)} |`,
  299. 326 );
  300. 328md.push("");
  301. 329md.push("## Totals", "");
  302. 330md.push(`| | |`, `| --- | ---: |`);
  303. 331md.push(`| List value | ${fmtMoney(totals.list_total)} |`);
  304. 332md.push(`| Discount | ${fmtMoney(totals.discount_amount)} (${pct(totals.discount_pct)}) |`);
  305. 333md.push(`| **Total contract value** | **${fmtMoney(totals.tcv)}** |`);
  306. 334md.push(`| Annual recurring (ACV) | ${fmtMoney(totals.acv)} |`);
  307. 335md.push(`| One-time fees | ${fmtMoney(totals.one_time_total)} |`);
  308. 336md.push(`| First-year total | ${fmtMoney(totals.first_year_total)} |`);
  309. 337md.push("");
  310. 338md.push("## Billing schedule", "");
  311. 339md.push("| Invoice | Date | Service period | Amount |", "| ---: | --- | --- | ---: |");
  312. 340for (const s of schedule) md.push(`| ${s.invoice} | ${s.date} | ${s.period} | ${fmtMoney(s.amount)} |`);
  313. 341md.push("");
  314. 342md.push("Prices exclude taxes. Recurring prices are per unit per year unless the unit says otherwise.");
  315. 343if (deal.notes) md.push("", `Notes: ${deal.notes}`);
  316. 344md.push("", "## Signatures", "");
  317. 345md.push("| | Customer | Provider |", "| --- | --- | --- |");
  318. 346md.push(`| Name | ${c.signer?.name || ""} | |`, `| Title | ${c.signer?.title || ""} | |`, "| Signature | | |", "| Date | | |");
  319. 347md.push("", "## Finance checklist before signature", "");
  320. 348for (const x of checklist) md.push(`- [ ] **${x.level.toUpperCase()}**: ${x.item}`);
  321. 349md.push("");
  322. 351// ---------- html ----------
  323. 352const esc = (s) =>
  324. 353 String(s ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
  325. 354const row = (cells, tag = "td") => `<tr>${cells.map((x) => `<${tag}>${x}</${tag}>`).join("")}</tr>`;
  326. 355const html = `<!doctype html>
  327. 356<html lang="en">
  328. 357<head>
  329. 358<meta charset="utf-8">
  330. 359<meta name="viewport" content="width=device-width, initial-scale=1">
  331. 360<title>${esc(docTitle)} ${esc(number)}</title>
  332. 361<style>
  333. 362 :root { --ink: #1f2328; --muted: #59636e; --line: #d0d7de; --accent: #0b5cad; --warn: #9a6700; --bad: #b42318; }
  334. 363 * { box-sizing: border-box; }
  335. 364 body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--ink); background: #fff; margin: 0; padding: 32px 16px; }
  336. 365 main { max-width: 860px; margin: 0 auto; }
  337. 366 h1 { font-size: 24px; margin: 0 0 4px; }
  338. 367 h2 { font-size: 15px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 28px 0 8px; }
  339. 368 table { width: 100%; border-collapse: collapse; }
  340. 369 th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
  341. 370 th { font-weight: 600; color: var(--muted); font-size: 12px; }
  342. 371 td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
  343. 372 .meta td:first-child { color: var(--muted); width: 32%; }
  344. 373 .total td { font-weight: 700; border-top: 2px solid var(--ink); }
  345. 374 .banner { border: 1px solid var(--bad); color: var(--bad); padding: 8px 12px; border-radius: 6px; margin: 12px 0; }
  346. 375 .sig td { height: 36px; }
  347. 376 .blocker { color: var(--bad); } .warning { color: var(--warn); } .info { color: var(--muted); }
  348. 377 .wrap { overflow-x: auto; }
  349. 378 @media print { body { padding: 0; } .checklist { page-break-before: always; } a { color: inherit; } }
  350. 379</style>
  351. 380</head>
  352. 381<body>
  353. 382<main>
  354. 383<h1>${esc(docTitle)} ${esc(number)}</h1>
  355. 384${deal.seller.name ? `<div>${esc(deal.seller.name)}${deal.seller.address ? `, ${esc(deal.seller.address)}` : ""}</div>` : ""}
  356. 385${blockers > 0 ? `<div class="banner">DRAFT: ${blockers} item(s) must be fixed before signature. See the checklist.</div>` : ""}
  357. 386<h2>Customer and terms</h2>
  358. 387<table class="meta">
  359. 388${row(["Customer (legal name)", esc(c.legal_name || "MISSING")])}
  360. 389${row(["Billing address", esc(c.address || "MISSING")])}
  361. 390${row(["Billing contact", esc([bc.name, bc.email, bc.phone].filter(Boolean).join(", ") || "MISSING")])}
  362. 391${c.tax_id ? row(["Tax ID", esc(c.tax_id)]) : ""}
  363. 392${row(["Term", esc(`${deal.term_months} months, ${start ? iso(start) : "start date TBD"} to ${end ? iso(end) : "TBD"}`)])}
  364. 393${row(["Billing", esc(`${deal.billing_frequency}, ${deal.payment_terms}`)])}
  365. 394${row(["Customer PO", esc(deal.po_required ? deal.po_number || "REQUIRED, not yet received" : deal.po_number || "Not required")])}
  366. 395${row(["Currency", esc(deal.currency)])}
  367. 396</table>
  368. 397<h2>Products and services</h2>
  369. 398<div class="wrap"><table>
  370. 399<tr><th>#</th><th>SKU</th><th>Description</th><th class="num">Qty</th><th>Unit</th><th class="num">List</th><th class="num">Net</th><th class="num">Disc.</th><th class="num">Term total</th></tr>
  371. 400${lines
  372. 401 .map(
  373. 402 (l) =>
  374. 403 `<tr><td>${l.line}</td><td>${esc(l.sku)}</td><td>${esc(l.description)}</td><td class="num">${l.quantity}</td><td>${esc(l.unit)}</td><td class="num">${fmtMoney(l.list_unit)}</td><td class="num">${fmtMoney(l.net_unit)}</td><td class="num">${pct(l.discount_pct)}</td><td class="num">${fmtMoney(l.net_total)}</td></tr>`,
  375. 404 )
  376. 405 .join("\n")}
  377. 406</table></div>
  378. 407<h2>Totals</h2>
  379. 408<table>
  380. 409<tr><td>List value</td><td class="num">${fmtMoney(totals.list_total)}</td></tr>
  381. 410<tr><td>Discount</td><td class="num">${fmtMoney(totals.discount_amount)} (${pct(totals.discount_pct)})</td></tr>
  382. 411<tr class="total"><td>Total contract value</td><td class="num">${fmtMoney(totals.tcv)}</td></tr>
  383. 412<tr><td>Annual recurring (ACV)</td><td class="num">${fmtMoney(totals.acv)}</td></tr>
  384. 413<tr><td>One-time fees</td><td class="num">${fmtMoney(totals.one_time_total)}</td></tr>
  385. 414<tr><td>First-year total</td><td class="num">${fmtMoney(totals.first_year_total)}</td></tr>
  386. 415</table>
  387. 416<h2>Billing schedule</h2>
  388. 417<table>
  389. 418<tr><th class="num">Invoice</th><th>Date</th><th>Service period</th><th class="num">Amount</th></tr>
  390. 419${schedule.map((s) => `<tr><td class="num">${s.invoice}</td><td>${esc(s.date)}</td><td>${esc(s.period)}</td><td class="num">${fmtMoney(s.amount)}</td></tr>`).join("\n")}
  391. 420</table>
  392. 421<p>Prices exclude taxes. Recurring prices are per unit per year unless the unit says otherwise.</p>
  393. 422${deal.notes ? `<p>Notes: ${esc(deal.notes)}</p>` : ""}
  394. 423<h2>Signatures</h2>
  395. 424<table class="sig">
  396. 425<tr><th></th><th>Customer</th><th>Provider</th></tr>
  397. 426<tr><td>Name</td><td>${esc(c.signer?.name || "")}</td><td></td></tr>
  398. 427<tr><td>Title</td><td>${esc(c.signer?.title || "")}</td><td></td></tr>
  399. 428<tr><td>Signature</td><td></td><td></td></tr>
  400. 429<tr><td>Date</td><td></td><td></td></tr>
  401. 430</table>
  402. 431<section class="checklist">
  403. 432<h2>Finance checklist before signature (internal, remove before sending)</h2>
  404. 433<ul>
  405. 434${checklist.map((x) => `<li class="${x.level}"><strong>${x.level.toUpperCase()}</strong>: ${esc(x.item)}</li>`).join("\n")}
  406. 435</ul>
  407. 436</section>
  408. 437</main>
  409. 438</body>
  410. 439</html>
  411. 440`;
  412. 442// ---------- write ----------
  413. 443const money = (c) => c / 100;
  414. 444const summary = {
  415. 445 document_type: deal.document_type,
  416. 446 order_number: number,
  417. 447 customer: c.legal_name || null,
  418. 448 currency: deal.currency,
  419. 449 start_date: start ? iso(start) : null,
  420. 450 end_date: end ? iso(end) : null,
  421. 451 term_months: deal.term_months,
  422. 452 lines: lines.map((l) => ({
  423. 453 line: l.line,
  424. 454 sku: l.sku,
  425. 455 quantity: l.quantity,
  426. 456 billing: l.billing,
  427. 457 list_unit: money(l.list_unit),
  428. 458 net_unit: money(l.net_unit),
  429. 459 discount_pct: Math.round(l.discount_pct * 10) / 10,
  430. 460 list_total: money(l.list_total),
  431. 461 net_total: money(l.net_total),
  432. 462 })),
  433. 463 totals: {
  434. 464 list_total: money(totals.list_total),
  435. 465 discount_amount: money(totals.discount_amount),
  436. 466 discount_pct: Math.round(totals.discount_pct * 10) / 10,
  437. 467 tcv: money(totals.tcv),
  438. 468 acv: money(totals.acv),
  439. 469 one_time_total: money(totals.one_time_total),
  440. 470 first_year_total: money(totals.first_year_total),
  441. 471 },
  442. 472 billing_schedule: schedule.map((s) => ({ ...s, amount: money(s.amount) })),
  443. 473 checklist,
  444. 474 blockers,
  445. 475 ready_for_signature: blockers === 0,
  446. 476};
  447. 478mkdirSync(opts.out, { recursive: true });
  448. 479writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
  449. 480writeFileSync(join(opts.out, `${opts.name}.html`), html);
  450. 481writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(summary, null, 2) + "\n");
  451. 482console.log(
  452. 483 `${docTitle} ${number}: TCV ${fmtMoney(totals.tcv)}, discount ${pct(totals.discount_pct)}, ${blockers} blocker(s). Wrote ${join(opts.out, opts.name)}.{md,html,json}`,
  453. 484);