DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to generate PDF purchase orders automatically

How to Generate PDF Purchase Orders Automatically

Procurement teams create POs in spreadsheets, copy them to Word templates, export to PDF, and email them to vendors. Every PO takes 10–15 minutes. Multiply by 50 POs a month and it's a half-time job.

Here's how to generate a PO the moment a purchase request is approved — triggered by your ERP, your approval workflow, or a direct API call.

Purchase order HTML template

function renderPurchaseOrderHtml({
  poNumber,
  poDate,
  deliveryDate,
  vendor,
  shipTo,
  lineItems,
  paymentTerms,
  notes,
  currency = "USD",
}) {
  const fmt = (n) =>
    new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);

  const subtotal = lineItems.reduce((s, i) => s + i.qty * i.unitPrice, 0);
  const tax = subtotal * 0.0; // adjust for your jurisdiction
  const total = subtotal + tax;

  const rows = lineItems
    .map(
      (item) => `
      <tr>
        <td>${item.sku ?? ""}</td>
        <td>${item.description}</td>
        <td style="text-align:right">${item.qty}</td>
        <td style="text-align:right">${fmt(item.unitPrice)}</td>
        <td style="text-align:right">${fmt(item.qty * item.unitPrice)}</td>
      </tr>`
    )
    .join("");

  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: -apple-system, 'Segoe UI', sans-serif; color: #111; max-width: 800px; margin: 0 auto; padding: 40px; font-size: 13px; }
    .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 3px solid #111; }
    .logo { font-size: 22px; font-weight: 800; }
    .po-meta { text-align: right; }
    .po-meta h1 { font-size: 26px; font-weight: 900; color: #111; margin: 0 0 4px; }
    .po-number { font-size: 14px; color: #666; }
    .addresses { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 24px; margin-bottom: 28px; }
    .address-block { }
    .address-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #999; margin-bottom: 6px; }
    .address-block p { line-height: 1.6; }
    .meta-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; background: #f8f8f8; padding: 14px 16px; border-radius: 6px; margin-bottom: 24px; }
    .meta-item .label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #999; }
    .meta-item .value { font-weight: 600; margin-top: 2px; }
    table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
    thead tr { background: #111; color: white; }
    thead td { padding: 10px 8px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
    tbody tr:nth-child(even) { background: #f9f9f9; }
    tbody td { padding: 10px 8px; border-bottom: 1px solid #eee; }
    .totals { margin-left: auto; width: 260px; }
    .totals table { font-size: 13px; }
    .totals td { padding: 6px 0; border: none; }
    .totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
    .total-row td { font-weight: 800; font-size: 16px; border-top: 2px solid #111; padding-top: 10px; }
    .footer-notes { margin-top: 32px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
    .auth-box { margin-top: 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 48px; }
    .sig-line { border-bottom: 1px solid #999; height: 40px; margin-bottom: 4px; }
    .sig-label { font-size: 11px; color: #999; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="logo">YourCompany</div>
      <div style="color:#666;font-size:12px;margin-top:4px">123 Main St, San Francisco CA 94105</div>
    </div>
    <div class="po-meta">
      <h1>PURCHASE ORDER</h1>
      <div class="po-number">PO# ${poNumber}</div>
    </div>
  </div>

  <div class="addresses">
    <div class="address-block">
      <div class="address-label">Vendor</div>
      <p><strong>${vendor.name}</strong><br>
      ${vendor.address ?? ""}<br>
      ${vendor.contact ? `Attn: ${vendor.contact}` : ""}
      ${vendor.email ? `<br>${vendor.email}` : ""}</p>
    </div>
    <div class="address-block">
      <div class="address-label">Ship To</div>
      <p><strong>${shipTo.name}</strong><br>
      ${shipTo.address ?? ""}<br>
      ${shipTo.contact ? `Attn: ${shipTo.contact}` : ""}</p>
    </div>
  </div>

  <div class="meta-row">
    <div class="meta-item"><div class="label">PO Date</div><div class="value">${poDate}</div></div>
    <div class="meta-item"><div class="label">Delivery Date</div><div class="value">${deliveryDate}</div></div>
    <div class="meta-item"><div class="label">Payment Terms</div><div class="value">${paymentTerms}</div></div>
    <div class="meta-item"><div class="label">Currency</div><div class="value">${currency}</div></div>
  </div>

  <table>
    <thead>
      <tr>
        <td>SKU</td><td>Description</td>
        <td style="text-align:right">Qty</td>
        <td style="text-align:right">Unit Price</td>
        <td style="text-align:right">Amount</td>
      </tr>
    </thead>
    <tbody>${rows}</tbody>
  </table>

  <div class="totals">
    <table>
      <tr><td>Subtotal</td><td>${fmt(subtotal)}</td></tr>
      ${tax > 0 ? `<tr><td>Tax</td><td>${fmt(tax)}</td></tr>` : ""}
      <tr class="total-row"><td>Total</td><td>${fmt(total)}</td></tr>
    </table>
  </div>

  ${notes ? `<div class="footer-notes"><strong>Notes:</strong> ${notes}</div>` : ""}

  <div class="auth-box">
    <div>
      <div class="sig-line"></div>
      <div class="sig-label">Authorized by — Purchasing Department</div>
    </div>
    <div>
      <div class="sig-line"></div>
      <div class="sig-label">Vendor acknowledgement</div>
    </div>
  </div>
</body>
</html>`;
}
Enter fullscreen mode Exit fullscreen mode

Generate PO on approval webhook

app.post("/webhooks/approval/purchase-request", async (req, res) => {
  res.json({ ok: true });
  const { requestId, approvedBy } = req.body;

  const request = await db.purchaseRequests.findById(requestId, {
    include: { vendor: true, lineItems: true, requester: true },
  });

  const poNumber = await generatePONumber(); // e.g. PO-2026-00142

  const html = renderPurchaseOrderHtml({
    poNumber,
    poDate: new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
    deliveryDate: request.requiredBy,
    vendor: request.vendor,
    shipTo: { name: "YourCompany — Receiving", address: "123 Main St, San Francisco CA 94105" },
    lineItems: request.lineItems,
    paymentTerms: request.vendor.paymentTerms ?? "Net 30",
    notes: request.notes,
  });

  const res2 = await fetch("https://pagebolt.dev/api/v1/pdf", {
    method: "POST",
    headers: { "x-api-key": process.env.PAGEBOLT_API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ html }),
  });

  const pdf = Buffer.from(await res2.arrayBuffer());

  // Store + email vendor
  await db.purchaseOrders.create({ data: { poNumber, requestId, approvedBy } });
  await uploadToS3(pdf, `purchase-orders/${poNumber}.pdf`);
  await emailPOToVendor({ vendor: request.vendor, pdf, poNumber });

  // Email confirmation to requester
  await emailPOConfirmation({ requester: request.requester, poNumber });
});
Enter fullscreen mode Exit fullscreen mode

Jira Service Management integration

// Triggered when a Jira "Purchase Request" issue moves to "Approved"
app.post("/webhooks/jira", async (req, res) => {
  const { issue, changelog } = req.body;

  const transition = changelog?.items?.find(
    (c) => c.field === "status" && c.toString === "Approved"
  );
  if (!transition) return res.json({ ok: true });

  const fields = issue.fields;
  const html = renderPurchaseOrderHtml({
    poNumber: `PO-${issue.key}`,
    poDate: new Date().toLocaleDateString(),
    deliveryDate: fields.duedate ?? "TBD",
    vendor: { name: fields.customfield_vendor, email: fields.customfield_vendor_email },
    shipTo: { name: "YourCompany", address: fields.customfield_ship_to },
    lineItems: JSON.parse(fields.customfield_line_items ?? "[]"),
    paymentTerms: fields.customfield_payment_terms ?? "Net 30",
    notes: fields.description,
  });

  const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
    method: "POST",
    headers: { "x-api-key": process.env.PAGEBOLT_API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ html }),
  });

  const pdf = Buffer.from(await pdfRes.arrayBuffer());
  // Attach PDF back to Jira issue
  await jira.addAttachment(issue.id, pdf, `${issue.key}-purchase-order.pdf`);

  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)