# 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?