PDF Generation
How Eziseller generates invoice and shipping-label PDFs on demand using PDFKit. Audience: new dev with Node/React experience, no Eziseller context.
1. Overview
Eziseller generates two kinds of PDFs: customer invoices (A4) and 4x6 inch shipping labels. Both are built at request time with raw PDFKit (opens in a new tab) drawing primitives — there is no HTML-to-PDF pipeline, no Puppeteer, and no templating engine. PDFs are never persisted: they are streamed into an in-memory Buffer and written straight to the HTTP response. No Invoice or Document table exists in Prisma; everything is derived from the Order row on the fly.
2. Architecture
PDFs are generated synchronously per request. The route handler pre-fetches remote images into Buffers (so PDFKit can embed them via doc.image()) and passes them in a Map<url, Buffer> to the template.
3. Data model
There is no dedicated PDF/Document model. Invoice and label content is projected directly from Order, OrderItem, and the owner's Store.
See schema.prisma. The Order.customer column is a JSON blob (name, phone, email, address) — the invoice reads it directly.
4. Key flows
4.1 Generate invoice (download or preview)
POST /invoice/:orderId returns Content-Disposition: attachment; GET /invoice/:orderId/preview returns inline. Both rebuild the PDF from scratch — there is no caching layer.
4.2 Generate shipping label
Labels are not fetched from Shiprocket — they are drawn locally from the order's customer address and the latest non-delivered Fulfillment's tracking number. No images are embedded (no logo, no barcode).
4.3 Image pre-fetching
routes/documents.ts:11-22 picks one image per line item with priority: variant.imageUrl > product.imageUrl > product.imageUrls[0]. Relative Cloudinary paths are expanded via getFullCloudinaryUrl. All URLs (items + store logo) are fetched in parallel through backend/lib/image-fetcher.ts into Buffers keyed by original URL, then passed to the template. If fetch fails, doc.image() is wrapped in try/catch and falls back to a grey placeholder rectangle (invoice-template.ts:229-240).
5. Lifecycle / state machine
6. Key files
- backend/lib/pdf.ts:84-117 —
PDFServicefacade;generateInvoicedelegates to the template,generateShippingLabelis inline PDFKit. - backend/lib/pdf.ts:119-279 — shipping-label drawing helpers (FROM/TO blocks, package contents, hand-positioned with absolute Y coords).
- backend/templates/invoice-template.ts:49-83 — A4 invoice entrypoint; wires header, customer, items, totals, footer.
- backend/templates/invoice-template.ts:30-40 —
getFontsDir()tries three candidate paths for NotoSans fonts. - backend/templates/invoice-template.ts:178-285 — items table with dynamic row height and page-break logic.
- backend/routes/documents.ts:25-147 —
POST /invoice/:orderIddownload. - backend/routes/documents.ts:150-261 —
GET /invoice/:orderId/previewinline preview. - backend/routes/documents.ts:264-487 — shipping label download + preview.
- backend/lib/image-fetcher.ts — remote-image →
Bufferhelper.
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
CLOUDINARY_CLOUD_NAME | yes | Resolves relative image paths via getFullCloudinaryUrl | Invoice renders with placeholder tiles for every product |
| (fonts on disk) | yes | backend/assets/fonts/NotoSans-{Regular,Bold}.ttf must exist at one of three resolved paths | Invoice generation throws Fonts directory not found |
No Cloudinary / S3 upload of generated PDFs — they are never persisted. No dedicated PDF env vars.
8. Gotchas & troubleshooting
- Invoice header says "Receipt / Tax Invoice" but tax is always zero. The string is hard-coded in invoice-template.ts:102 and the
TAXcolumn always printsfmtAmount(0, currency)(line 268). The per-line tax value is not derived fromOrderItem; only the order-leveltaxAmountin the totals block is real. See tax-engine.md. If tax is ever wired up, both spots must change. - Fonts not found in production.
getFontsDir()probes__dirname/../assets/fonts,cwd/assets/fonts, andcwd/backend/assets/fonts. On Azure the compileddist/layout can shift__dirname, andcwddepends on how the process is started — if none match, generation throws. Copybackend/assets/fonts/*.ttfinto the deployed bundle. - Page breaks are manual. The items table computes
rowHand checksy + rowH > PH - FOOTER_ZONE - 10(invoice-template.ts:220-223). The totals block (totals()) has no page-break check — a very long item list can push totals off the last page or overlap the footer. The footer also disablesmargins.bottomto pin itself, which means PDFKit's auto-pagination is bypassed there. - Full PDF is held in memory. Both generators buffer every chunk via
on('data')+Buffer.concat. A 200-line order with images per row allocates the whole PDF plus every image buffer in RAM twice (Map + PDFKit stream). Streaming toresdirectly would be safer for large orders. - Shipping label truncates at 4 items.
addPackageContentscaps the printed list and appends "…and N more items" (pdf.ts:239-260). Warehouse picking cannot rely on the label alone. - Label positions are absolute. Y-coords like
startY = 200,320,400are hard-coded. Long customer names or phone numbers overflow the TO box and collide with the CONTENTS block below. No measurement, no wrap. - Preview and download duplicate the entire handler.
routes/documents.tshas near-identical code for preview and download of both doc types — fix bugs in all four places. - Raw PDFKit, no templating engine. Every visual change means editing draw calls. There is no HTML, no Handlebars, no React-PDF.
9. Extension points
- New document type (e.g. packing slip, credit note): add a
generateXxx(data): Promise<Buffer>toPDFService, a template class mirroringInvoiceTemplate, and aroutes/documents.tspair (download + preview). ReusefetchImagesAsBuffersandformatAddress. - Switch to HTML-to-PDF: introduce Puppeteer or
@react-pdf/rendererbehindPDFService— route handlers already receive aBuffer, so the swap is local. Watch Azure cold-start when bundling Chromium. - Persisted invoices: add an
InvoicePrisma model (order_id, invoice_number, pdf_url, issued_at), upload the buffer to Cloudinary (cloudinary-storage.tshelper exists), and short-circuit regeneration when a row already exists. This is also the natural home for a realinvoice_numberonce invoice-numbering.md lands. - Streaming response: pipe the PDFKit doc straight into
resinstead of buffering — safer for large orders.
10. Related docs
- tax-engine.md — why the invoice's TAX column is always zero.
- invoice-numbering.md — where the
INV-…number on the invoice comes from. - ../../02-integrations/shipping/shipping-overview.md — Shiprocket flow; note that its labels are separate from the locally-drawn 4x6 label here.
- ../orders/order-lifecycle.md — the order state that seeds every PDF.