Core
Tax And Invoices
Pdf Generation

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

7. Env vars & config

VarRequiredPurposeWhat breaks
CLOUDINARY_CLOUD_NAMEyesResolves relative image paths via getFullCloudinaryUrlInvoice renders with placeholder tiles for every product
(fonts on disk)yesbackend/assets/fonts/NotoSans-{Regular,Bold}.ttf must exist at one of three resolved pathsInvoice 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 TAX column always prints fmtAmount(0, currency) (line 268). The per-line tax value is not derived from OrderItem; only the order-level taxAmount in 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, and cwd/backend/assets/fonts. On Azure the compiled dist/ layout can shift __dirname, and cwd depends on how the process is started — if none match, generation throws. Copy backend/assets/fonts/*.ttf into the deployed bundle.
  • Page breaks are manual. The items table computes rowH and checks y + 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 disables margins.bottom to 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 to res directly would be safer for large orders.
  • Shipping label truncates at 4 items. addPackageContents caps 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, 400 are 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.ts has 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> to PDFService, a template class mirroring InvoiceTemplate, and a routes/documents.ts pair (download + preview). Reuse fetchImagesAsBuffers and formatAddress.
  • Switch to HTML-to-PDF: introduce Puppeteer or @react-pdf/renderer behind PDFService — route handlers already receive a Buffer, so the swap is local. Watch Azure cold-start when bundling Chromium.
  • Persisted invoices: add an Invoice Prisma model (order_id, invoice_number, pdf_url, issued_at), upload the buffer to Cloudinary (cloudinary-storage.ts helper exists), and short-circuit regeneration when a row already exists. This is also the natural home for a real invoice_number once invoice-numbering.md lands.
  • Streaming response: pipe the PDFKit doc straight into res instead of buffering — safer for large orders.

10. Related docs