# Invoice Print Bundle (2x Invoice + 1x Picking Slip) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a server-side print bundle endpoint that returns a single PDF containing 2 copies of the invoice (all pages) and 1 picking slip, generated by invoice number. **Architecture:** Generate bundles on demand in a Next.js API route. The route looks up all invoiced `order_items` for a given invoice number, downloads the saved PocketBase invoice PDF once, duplicates it twice with `pdf-lib`, generates a modern picking slip PDF with `@react-pdf/renderer`, appends it, then streams one PDF back to the browser. The UI `Print` button calls this endpoint and opens the returned PDF blob. **Tech Stack:** Next.js 16 route handlers (nodejs runtime), PocketBase admin client (`getAdminPocketBase`), `pdf-lib` for merging/duplicating, `@react-pdf/renderer` for picking slip PDF generation, Vitest. --- ## File Structure - Add: `src/app/api/invoicing/print-bundle/route.ts` — new route handler that returns the combined PDF. - Add: `src/app/api/invoicing/print-bundle/route.test.ts` — route-level tests with mocked PB fetch and mocked PDF generation. - Add: `src/app/(app)/invoicing/picking-slip.tsx` — React PDF component that renders the picking slip layout. - Add: `src/app/(app)/invoicing/print-bundle.ts` — pure helpers for building the bundle (download invoice PDF, duplicate pages, append slip). - Add: `src/app/(app)/invoicing/print-bundle.test.ts` — unit tests for helper behavior (page counts, error cases). - Modify: `src/app/(app)/invoicing/page.tsx` — change the Print button to call the bundle endpoint with `invoiceNumber`. - Modify: `package.json` — add `pdf-lib` dependency. --- ### Task 1: Add `pdf-lib` dependency **Files:** - Modify: `package.json` - [ ] **Step 1: Add dependency** Add to `dependencies`: ```json "pdf-lib": "^1.17.1" ``` - [ ] **Step 2: Install** Run: ```bash npm install ``` Expected: installs without peer dependency errors. - [ ] **Step 3: Verify** Run: ```bash node -e "require('pdf-lib'); console.log('ok')" ``` Expected: prints `ok`. --- ### Task 2: Implement picking slip React PDF component **Files:** - Add: `src/app/(app)/invoicing/picking-slip.tsx` - Test: `src/app/(app)/invoicing/picking-slip.test.ts` (optional if you prefer snapshot tests; otherwise covered via bundle test) - [ ] **Step 1: Create the data types** In `src/app/(app)/invoicing/picking-slip.tsx` define: ```tsx import React from "react"; import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer"; export type PickingSlipHeader = { orderNumber: string; companyName: string; customerName: string; invoiceNumber: string; date: string; }; export type PickingSlipLine = { id: string; description: string; ordered: number; collected: number; pickQty: number; outstanding: number; toStock: number; }; export function PickingSlipPdf(props: { header: PickingSlipHeader; lines: PickingSlipLine[] }) { const styles = StyleSheet.create({ page: { padding: 28, fontSize: 10 }, titleRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 10 }, title: { fontSize: 14, fontWeight: 700 }, meta: { color: "#475569" }, box: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 10, marginBottom: 12 }, gridHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 6, marginBottom: 6 }, row: { flexDirection: "row", paddingVertical: 4, borderBottomWidth: 1, borderBottomColor: "#f1f5f9" }, colDesc: { width: "48%" }, colNum: { width: "10%", textAlign: "right" }, colNumWide: { width: "12%", textAlign: "right" }, headerCell: { color: "#64748b", fontWeight: 600 }, badge: { fontSize: 9, color: "#0f172a" }, }); return ( Picking Slip {`Order: ${props.header.orderNumber}`} {`Company: ${props.header.companyName}`} {`Customer: ${props.header.customerName}`} {`Invoice: ${props.header.invoiceNumber}`} {`Date: ${props.header.date}`} Item Ordered Collected Pick Outstanding To Stock {props.lines.map((line) => ( {line.description} {line.ordered} {line.collected} {line.pickQty} {line.outstanding} {line.toStock} ))} ); } ``` - [ ] **Step 2: Add minimal validation helpers** Still in the same file, add: ```tsx export function computePickingSlipLine(input: { id: string; description: string; ordered: number; collected: number; pickQty: number; }): PickingSlipLine { const ordered = Number.isFinite(input.ordered) ? input.ordered : 0; const collected = Number.isFinite(input.collected) ? input.collected : 0; const pickQty = Number.isFinite(input.pickQty) ? input.pickQty : 0; return { id: input.id, description: input.description || "(no description)", ordered, collected, pickQty, outstanding: Math.max(ordered - pickQty, 0), toStock: Math.max(collected - pickQty, 0), }; } ``` --- ### Task 3: Create pure bundling helpers (download invoice, duplicate twice, append slip) **Files:** - Add: `src/app/(app)/invoicing/print-bundle.ts` - Test: `src/app/(app)/invoicing/print-bundle.test.ts` - [ ] **Step 1: Write failing tests for PDF page count math** Create `src/app/(app)/invoicing/print-bundle.test.ts`: ```ts import { PDFDocument } from "pdf-lib"; import { duplicateAllPagesTwice } from "./print-bundle"; async function makePdfWithPages(count: number) { const pdf = await PDFDocument.create(); for (let i = 0; i < count; i += 1) pdf.addPage(); return pdf.save(); } it("duplicates all invoice pages twice", async () => { const input = await makePdfWithPages(2); const output = await duplicateAllPagesTwice(input); const outDoc = await PDFDocument.load(output); expect(outDoc.getPageCount()).toBe(4); }); ``` - [ ] **Step 2: Run test to verify fail** Run: ```bash npx vitest run "src/app/(app)/invoicing/print-bundle.test.ts" ``` Expected: FAIL because `duplicateAllPagesTwice` doesn’t exist. - [ ] **Step 3: Implement helpers** Create `src/app/(app)/invoicing/print-bundle.ts`: ```ts import { PDFDocument } from "pdf-lib"; export async function duplicateAllPagesTwice(invoicePdfBytes: Uint8Array) { const invoiceDoc = await PDFDocument.load(invoicePdfBytes); const out = await PDFDocument.create(); const invoicePages = await out.copyPages(invoiceDoc, invoiceDoc.getPageIndices()); for (const p of invoicePages) out.addPage(p); const invoicePages2 = await out.copyPages(invoiceDoc, invoiceDoc.getPageIndices()); for (const p of invoicePages2) out.addPage(p); return out.save(); } export async function appendPdf(outPdfBytes: Uint8Array, appendPdfBytes: Uint8Array) { const outDoc = await PDFDocument.load(outPdfBytes); const appendDoc = await PDFDocument.load(appendPdfBytes); const pages = await outDoc.copyPages(appendDoc, appendDoc.getPageIndices()); for (const p of pages) outDoc.addPage(p); return outDoc.save(); } ``` - [ ] **Step 4: Run tests** Run: ```bash npx vitest run "src/app/(app)/invoicing/print-bundle.test.ts" ``` Expected: PASS. --- ### Task 4: Add `/api/invoicing/print-bundle` route handler **Files:** - Add: `src/app/api/invoicing/print-bundle/route.ts` - Test: `src/app/api/invoicing/print-bundle/route.test.ts` - [ ] **Step 1: Write failing route test for 400/404/409** Create `src/app/api/invoicing/print-bundle/route.test.ts`: ```ts import { GET } from "./route"; it("returns 400 when invoiceNumber is missing", async () => { const req = new Request("http://localhost/api/invoicing/print-bundle"); const res = await GET(req as any); expect(res.status).toBe(400); }); ``` - [ ] **Step 2: Implement route skeleton** Create `src/app/api/invoicing/print-bundle/route.ts`: ```ts import { NextRequest, NextResponse } from "next/server"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { duplicateAllPagesTwice, appendPdf } from "@/app/(app)/invoicing/print-bundle"; import { renderToBuffer } from "@react-pdf/renderer"; import { PickingSlipPdf, computePickingSlipLine } from "@/app/(app)/invoicing/picking-slip"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const url = new URL(req.url); const invoiceNumber = url.searchParams.get("invoiceNumber")?.trim() ?? ""; if (!invoiceNumber) { return NextResponse.json({ error: "Missing invoiceNumber" }, { status: 400 }); } const adminPb = await getAdminPocketBase(); const items = await adminPb.collection("order_items").getFullList({ filter: `order_item_details.invoice.number = "${invoiceNumber}" && item_status = "invoiced"`, }); if (!items.length) { return NextResponse.json({ error: "Invoice not found" }, { status: 404 }); } const withPdf = items.find((it: any) => Boolean(it.qb_invoice_pdf)); if (!withPdf) { return NextResponse.json({ error: "Invoice PDF not stored yet" }, { status: 409 }); } const pbBase = (process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "").replace(/\/$/, ""); const collectionId = withPdf.collectionId ?? "order_items"; const recordId = withPdf.id; const fileName = withPdf.qb_invoice_pdf; const fileUrl = `${pbBase}/api/v1/files/${collectionId}/${recordId}/${fileName}`; const invoiceRes = await fetch(fileUrl); if (!invoiceRes.ok) { return NextResponse.json({ error: `Failed to download invoice PDF (${invoiceRes.status})` }, { status: 502 }); } const invoiceBytes = new Uint8Array(await invoiceRes.arrayBuffer()); const doubled = await duplicateAllPagesTwice(invoiceBytes); const first = items[0] as any; const header = { orderNumber: first.order_number ?? first.orderNumber ?? "", companyName: first.order_item_details?.invoice?.invoiceCompany ?? "", customerName: first.customer_name ?? first.customerName ?? "", invoiceNumber, date: first.order_item_details?.invoice?.date ?? "", }; const lines = items.map((it: any) => { const ordered = Number(it.order_qty ?? 0); const collected = Number(it.qty_collected ?? 0); const pickQty = Math.min(collected, ordered); return computePickingSlipLine({ id: it.id, description: it.description ?? "", ordered, collected, pickQty, }); }); const slipBytes = await renderToBuffer(); const merged = await appendPdf(doubled, new Uint8Array(slipBytes)); return new NextResponse(Buffer.from(merged), { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${invoiceNumber}-bundle.pdf"`, "Cache-Control": "private, no-cache", }, }); } ``` - [ ] **Step 3: Run route test** Run: ```bash npx vitest run "src/app/api/invoicing/print-bundle/route.test.ts" ``` Expected: PASS for the 400 test. --- ### Task 5: Wire the Print button to call the bundle endpoint by invoice number **Files:** - Modify: `src/app/(app)/invoicing/page.tsx` - Test: `src/app/(app)/invoicing/page.test.tsx` - [ ] **Step 1: Add failing test** In `src/app/(app)/invoicing/page.test.tsx`, add a test asserting `Print` calls the route with invoice number and opens the returned blob. - [ ] **Step 2: Implement client handler** In `page.tsx`, update the print handler to: 1. read invoice number from `order_item_details.invoice.number` 2. `fetch(/api/invoicing/print-bundle?invoiceNumber=...)` 3. `const blob = await res.blob()` then `URL.createObjectURL(blob)` then `window.open(...)` --- ### Task 6: Self-review + focused test run - [ ] **Step 1: Run targeted suite** Run: ```bash npx vitest run "src/app/(app)/invoicing/print-bundle.test.ts" "src/app/api/invoicing/print-bundle/route.test.ts" ``` - [ ] **Step 2: Run invoicing page tests** Run: ```bash npx vitest run "src/app/(app)/invoicing/page.test.tsx" ``` Expected: PASS. --- ## Execution Handoff Plan complete and saved to `docs/superpowers/plans/2026-07-09-invoice-print-bundle.md`. Two execution options: 1. Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks 2. Inline Execution — execute tasks in this session with checkpoints Which approach?