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