Add complete end-to-end QuickBooks invoicing workflow for the invoicing page: - Add `jspdf` dependency for client-side PDF generation - Implement updated item status handling with fallback for new invoice statuses - Add server-side proxy API for secure QuickBooks customer lookup - Build utilities to combine QuickBooks invoices and picking slips into a single PDF - Add invoicing view models for row grouping, payload construction, and status checks - Add full test coverage for all new components, APIs, and utilities - Include feature design documentation and sample n8n workflow config
32 KiB
Invoicing Redesign And PDF Upload 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: Redesign the Invoicing page into To Invoice and Invoiced operational tabs, support bulk per-order QuickBooks matching with local invoice overrides, and store the real QuickBooks invoice PDF in PocketBase as a file.
Architecture: Keep PocketBase as the source of truth for what the page shows, but reshape the frontend around order-based cards instead of the current mixed list. Keep n8n as the system that creates the QuickBooks invoice and writes back the invoice metadata, but change the PDF step to upload a real file to PocketBase and return a usable file URL to the app. The implementation is split so each slice is testable on its own: view-model/data, tabbed page shell, bulk matching and overrides, then workflow/PDF upload integration.
Tech Stack: Next.js 16, React 19, TypeScript, PocketBase browser client, n8n workflow JSON, Vitest, Testing Library, jsPDF.
File Structure
- Modify:
src/app/(app)/invoicing/page.tsx— replace the current single mixed screen with tab-aware loading, order-card rendering, recent invoice history, bulk matching, and local invoice overrides. - Modify:
src/app/(app)/invoicing/view-model.ts— introduce order-card data structures, tab filters, recent-history helpers, outstanding-item helpers, override payload shaping, and PocketBase file URL handling. - Modify:
src/app/(app)/invoicing/view-model.test.ts— cover the new order grouping, outstanding detection, recent-history defaults, and file URL helpers. - Modify:
src/app/(app)/invoicing/page.test.tsx— cover the new tabs, order-card rendering, bulk row matching, local overrides, and changed view/print behavior. - Modify:
src/app/(app)/invoicing/print.ts— keep the picking-slip builder, but make invoice download work from a PocketBase file URL and preserve existing combined-print behavior. - Modify:
src/app/(app)/invoicing/print.test.tsif present, otherwise create it — add focused tests for the PocketBase file URL download path if needed. - Modify:
n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE-2.json— change the success path so the invoice PDF is uploaded to PocketBase as a file and the saved file name or URL is written back toorder_items. - Optional reference:
src/lib/pocketbase.ts— confirm existing client usage and any helper patterns for file URLs.
Task 1: Reshape invoicing data around order cards and recent invoice history
Files:
-
Modify:
src/app/(app)/invoicing/view-model.ts -
Modify:
src/app/(app)/invoicing/view-model.test.ts -
Step 1: Write the failing tests for order-card grouping and outstanding items
Append these tests to src/app/(app)/invoicing/view-model.test.ts:
it("groups rows by order number and separates ready and outstanding rows", () => {
const rows = [
makeRow({ id: "row-1", orderNumber: "SO-1", item_status: "ready_invoice", description: "Item A" }),
makeRow({ id: "row-2", orderNumber: "SO-1", item_status: "processed", description: "Item B" }),
makeRow({ id: "row-3", orderNumber: "SO-2", item_status: "ready_invoice", description: "Item C" }),
];
const groups = buildOrderInvoiceCards(rows);
expect(groups).toHaveLength(2);
expect(groups[0]).toMatchObject({
orderNumber: "SO-1",
readyCount: 1,
outstandingCount: 1,
hasOutstanding: true,
});
expect(groups[0].readyRows.map((row) => row.id)).toEqual(["row-1"]);
expect(groups[0].outstandingRows.map((row) => row.id)).toEqual(["row-2"]);
});
it("defaults recent invoice filters to the last 3 days", () => {
const today = new Date("2026-07-08T12:00:00.000Z");
const range = buildRecentInvoiceRange(today);
expect(range.from).toBe("2026-07-05");
expect(range.to).toBe("2026-07-08");
});
- Step 2: Run the view-model tests to verify they fail
Run:
npx vitest run "src/app/(app)/invoicing/view-model.test.ts"
Expected: FAIL because buildOrderInvoiceCards() and buildRecentInvoiceRange() do not exist yet.
- Step 3: Implement the minimal view-model types and helpers
In src/app/(app)/invoicing/view-model.ts, add the new grouped card shape and helpers:
export type OrderInvoiceCard<T extends InvoicingRow = InvoicingRow> = {
orderNumber: string;
rows: T[];
readyRows: T[];
outstandingRows: T[];
readyCount: number;
outstandingCount: number;
hasOutstanding: boolean;
companyName: string;
customerName: string;
};
export function buildOrderInvoiceCards<T extends InvoicingRow>(rows: T[]): OrderInvoiceCard<T>[] {
return groupRowsByOrderNumber(rows).map((group) => {
const readyRows = group.rows.filter((row) => row.item_status === "ready_invoice");
const outstandingRows = group.rows.filter((row) => row.item_status !== "ready_invoice" && row.item_status !== "invoiced");
const first = group.rows[0];
return {
orderNumber: group.orderNumber,
rows: group.rows,
readyRows,
outstandingRows,
readyCount: readyRows.length,
outstandingCount: outstandingRows.length,
hasOutstanding: outstandingRows.length > 0,
companyName: first?.companyName ?? "",
customerName: first?.customerName ?? "",
};
});
}
export function buildRecentInvoiceRange(today = new Date()): { from: string; to: string } {
const end = new Date(today);
const start = new Date(today);
start.setDate(start.getDate() - 3);
return {
from: start.toISOString().slice(0, 10),
to: end.toISOString().slice(0, 10),
};
}
Keep the existing groupRowsByOrderNumber() helper if it is still useful; do not remove it unless the new helper makes it dead code and the tests cover the replacement.
- Step 4: Add a helper for PocketBase invoice file URLs
Still in src/app/(app)/invoicing/view-model.ts, add a small helper that preserves current string URLs and later supports PocketBase file paths:
export function resolveInvoicePdfUrl(input: {
rawValue?: string | null;
recordId?: string;
collectionId?: string;
fileName?: string | null;
pocketbaseBaseUrl?: string;
}): string | null {
if (input.rawValue && /^(https?:|data:)/.test(input.rawValue)) {
return input.rawValue;
}
if (!input.recordId || !input.collectionId || !input.fileName || !input.pocketbaseBaseUrl) {
return null;
}
const base = input.pocketbaseBaseUrl.replace(/\/$/, "");
return `${base}/api/files/${input.collectionId}/${input.recordId}/${input.fileName}`;
}
- Step 5: Add the failing test for the PocketBase file URL helper
Append this test to src/app/(app)/invoicing/view-model.test.ts:
it("builds a PocketBase file URL when qb_invoice_pdf stores a file name", () => {
expect(resolveInvoicePdfUrl({
collectionId: "pbc_order_items",
recordId: "row-1",
fileName: "invoice_a.pdf",
pocketbaseBaseUrl: "https://pb.example.com",
})).toBe("https://pb.example.com/api/files/pbc_order_items/row-1/invoice_a.pdf");
});
- Step 6: Run the view-model tests again
Run:
npx vitest run "src/app/(app)/invoicing/view-model.test.ts"
Expected: PASS.
- Step 7: Run diagnostics
Run GetDiagnostics on:
src/app/(app)/invoicing/view-model.tssrc/app/(app)/invoicing/view-model.test.ts
Expected: no new TypeScript or lint errors.
Task 2: Replace the mixed invoicing screen with To Invoice and Invoiced tabs
Files:
-
Modify:
src/app/(app)/invoicing/page.tsx -
Modify:
src/app/(app)/invoicing/page.test.tsx -
Step 1: Write the failing page tests for the new tab shell
Append these tests to src/app/(app)/invoicing/page.test.tsx:
it("renders To Invoice and Invoiced tabs", async () => {
render(<InvoicingPage />);
expect(await screen.findByRole("tab", { name: "To Invoice" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Invoiced" })).toBeInTheDocument();
});
it("shows ready items by default and reveals outstanding items when toggled", async () => {
mockedRows.splice(0, mockedRows.length,
makeRawRow({ id: "row-1", orderNumber: "SO-1", description: "Ready item", item_status: "ready_invoice" }),
makeRawRow({ id: "row-2", orderNumber: "SO-1", description: "Outstanding item", item_status: "processed" }),
);
render(<InvoicingPage />);
expect(await screen.findByText("Ready item")).toBeInTheDocument();
expect(screen.queryByText("Outstanding item")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /show outstanding/i }));
expect(screen.getByText("Outstanding item")).toBeInTheDocument();
});
- Step 2: Run the page tests to verify they fail
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: FAIL because the current page still renders the old filter/toggle layout.
- Step 3: Add the tab state and split the two data views
In src/app/(app)/invoicing/page.tsx, replace the current single toggle state with explicit tab state:
type InvoicingTab = "to_invoice" | "invoiced";
const [activeTab, setActiveTab] = useState<InvoicingTab>("to_invoice");
const [showOutstanding, setShowOutstanding] = useState<Record<string, boolean>>({});
const orderCards = useMemo(() => buildOrderInvoiceCards(rows), [rows]);
const recentInvoiceRange = useMemo(() => buildRecentInvoiceRange(), []);
Render a tab list near the top of the page:
<div className="inline-flex rounded-lg border bg-white p-1">
<Button
type="button"
variant={activeTab === "to_invoice" ? "default" : "ghost"}
role="tab"
onClick={() => setActiveTab("to_invoice")}
>
To Invoice
</Button>
<Button
type="button"
variant={activeTab === "invoiced" ? "default" : "ghost"}
role="tab"
onClick={() => setActiveTab("invoiced")}
>
Invoiced
</Button>
</div>
- Step 4: Render
To Invoiceas one card per order
Inside page.tsx, replace the current top-level PO-group map for the active-work area with:
{activeTab === "to_invoice" && orderCards.map((card) => {
const expanded = showOutstanding[card.orderNumber] ?? false;
return (
<section key={card.orderNumber} className="rounded-xl border bg-white p-4 shadow-sm">
<header className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="text-sm font-semibold">{card.orderNumber || "No order number"}</div>
<div className="text-sm text-slate-600">{card.companyName || "No company"}</div>
<div className="text-sm text-slate-500">{card.customerName || "No customer"}</div>
</div>
<div className="flex items-center gap-2">
<Badge>{`${card.readyCount} ready / ${card.rows.length} total`}</Badge>
{card.hasOutstanding ? <Badge variant="outline">Has outstanding</Badge> : <Badge variant="outline">Ready</Badge>}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setShowOutstanding((current) => ({
...current,
[card.orderNumber]: !expanded,
}))}
>
{expanded ? "Hide outstanding" : "Show outstanding"}
</Button>
</div>
</header>
<div className="mt-4 space-y-2">
{card.readyRows.map((row) => (
<div key={row.id} className="grid grid-cols-[1fr_auto_auto] gap-4 rounded-lg border p-3">
<div>{row.description ?? "(no description)"}</div>
<div>{row.supplierName ?? ""}</div>
<div>{`${row.qtyCollected ?? 0} / ${row.qtyOrdered ?? 0}`}</div>
</div>
))}
</div>
{expanded && card.outstandingRows.length > 0 ? (
<div className="mt-4 space-y-2 border-t pt-4">
{card.outstandingRows.map((row) => (
<div key={row.id} className="grid grid-cols-[1fr_auto_auto] gap-4 rounded-lg bg-slate-50 p-3">
<div>{row.description ?? "(no description)"}</div>
<div>{row.supplierName ?? ""}</div>
<div>{`${row.qtyCollected ?? 0} / ${row.qtyOrdered ?? 0}`}</div>
</div>
))}
</div>
) : null}
</section>
);
})}
If InvoicingRow does not yet include collected and ordered quantities, add qtyCollected?: number | null and qtyOrdered?: number | null in view-model.ts, then map them from the raw rows in page.tsx.
- Step 5: Render
Invoicedas recent history
In the same file, add the Invoiced branch:
{activeTab === "invoiced" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-slate-600">
{`Showing invoices from ${recentInvoiceRange.from} to ${recentInvoiceRange.to}`}
</div>
</div>
{rows
.filter((row) => row.item_status === "invoiced")
.map((row) => (
<div key={row.id} className="rounded-xl border bg-white p-4 shadow-sm">
<div className="font-medium">{row.invoiceNumber ?? "No invoice number"}</div>
<div className="text-sm text-slate-600">{row.orderNumber ?? "No order number"}</div>
<div className="text-sm text-slate-500">{row.companyName ?? "No company"}</div>
</div>
))}
</section>
)}
Keep the current search input, but scope it to the active tab instead of applying one mixed filter to both modes.
- Step 6: Re-run the page tests
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: PASS for the new tab and outstanding-toggle behaviors.
- Step 7: Run diagnostics
Run GetDiagnostics on:
src/app/(app)/invoicing/page.tsxsrc/app/(app)/invoicing/page.test.tsx
Expected: no new TypeScript or lint errors.
Task 3: Replace the current bulk modal with row-based per-order QuickBooks matching
Files:
-
Modify:
src/app/(app)/invoicing/page.tsx -
Modify:
src/app/(app)/invoicing/page.test.tsx -
Modify:
src/app/(app)/invoicing/view-model.ts -
Step 1: Write the failing bulk-flow test
Append this test to src/app/(app)/invoicing/page.test.tsx:
it("lets the operator match a QuickBooks company and customer per order in bulk mode", async () => {
fetchCustomersMock.mockResolvedValue([{ id: "qb-1", name: "Adal Engineering" }]);
render(<InvoicingPage />);
await userEvent.click(await screen.findByRole("button", { name: /bulk invoice/i }));
await userEvent.selectOptions(screen.getByLabelText("QB company for SO-1"), "CNL");
expect(fetchCustomersMock).toHaveBeenCalledWith({
customerQuery: "Adal Engineering",
billingCompany: "CNL",
});
expect(await screen.findByRole("combobox", { name: "QuickBooks customer for SO-1" })).toHaveValue("qb-1");
});
- Step 2: Run the page test to verify it fails
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: FAIL because the current bulk modal still groups around the older PO flow and does not expose per-order labels like QB company for SO-1.
- Step 3: Add a local bulk row model
In src/app/(app)/invoicing/view-model.ts, add:
export type BulkInvoiceRow = {
orderNumber: string;
companyName: string;
customerName: string;
rows: InvoicingRow[];
};
export function buildBulkInvoiceRows(cards: OrderInvoiceCard[]): BulkInvoiceRow[] {
return cards
.filter((card) => card.readyRows.length > 0)
.map((card) => ({
orderNumber: card.orderNumber,
companyName: card.companyName,
customerName: card.customerName,
rows: card.readyRows,
}));
}
- Step 4: Rebuild the bulk modal around one row per order
In src/app/(app)/invoicing/page.tsx, replace the older BulkQbModal content with row-based state:
const [bulkSelections, setBulkSelections] = useState<Record<string, {
billingCompany: string;
customers: QbCustomer[];
selectedCustomerId: string;
loading: boolean;
}>>({});
Render the bulk list like:
{bulkRows.map((row) => {
const selection = bulkSelections[row.orderNumber] ?? {
billingCompany: "",
customers: [],
selectedCustomerId: "",
loading: false,
};
return (
<div key={row.orderNumber} className="grid grid-cols-[minmax(0,1fr)_160px_minmax(0,1fr)] gap-3 rounded-lg border p-3">
<div>
<div className="font-medium">{row.orderNumber}</div>
<div className="text-sm text-slate-600">{row.companyName}</div>
<div className="text-sm text-slate-500">{row.customerName}</div>
</div>
<label className="space-y-1 text-sm">
<span>{`QB company for ${row.orderNumber}`}</span>
<select
value={selection.billingCompany}
onChange={async (event) => {
const billingCompany = event.currentTarget.value;
setBulkSelections((current) => ({
...current,
[row.orderNumber]: { ...selection, billingCompany, loading: true },
}));
const customers = await fetchCustomers({
customerQuery: row.companyName,
billingCompany,
});
const picked = pickClosestCustomer(customers, row.companyName);
setBulkSelections((current) => ({
...current,
[row.orderNumber]: {
billingCompany,
customers,
selectedCustomerId: picked?.id ?? "",
loading: false,
},
}));
}}
>
<option value="">Select</option>
<option value="CNL">CNL</option>
<option value="ISHERWOOD">ISHERWOOD</option>
</select>
</label>
<label className="space-y-1 text-sm">
<span>{`QuickBooks customer for ${row.orderNumber}`}</span>
<select
value={selection.selectedCustomerId}
onChange={(event) => {
const nextId = event.currentTarget.value;
setBulkSelections((current) => ({
...current,
[row.orderNumber]: { ...selection, selectedCustomerId: nextId },
}));
}}
>
<option value="">Select customer</option>
{selection.customers.map((customer) => (
<option key={customer.id} value={customer.id}>{customer.name}</option>
))}
</select>
</label>
</div>
);
})}
- Step 5: Re-run the page tests
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: PASS for the bulk matching flow.
- Step 6: Run diagnostics
Run GetDiagnostics on:
src/app/(app)/invoicing/page.tsxsrc/app/(app)/invoicing/view-model.tssrc/app/(app)/invoicing/page.test.tsx
Expected: no new TypeScript or lint errors.
Task 4: Add local invoice overrides that affect only the QuickBooks payload
Files:
-
Modify:
src/app/(app)/invoicing/view-model.ts -
Modify:
src/app/(app)/invoicing/page.tsx -
Modify:
src/app/(app)/invoicing/page.test.tsx -
Step 1: Write the failing override test
Append this test to src/app/(app)/invoicing/page.test.tsx:
it("uses local order number, qty, and selling price overrides only in the QuickBooks payload", async () => {
render(<InvoicingPage />);
await userEvent.click(await screen.findByRole("button", { name: /invoice in quickbooks/i }));
await userEvent.clear(screen.getByLabelText("Invoice order number"));
await userEvent.type(screen.getByLabelText("Invoice order number"), "SO-OVERRIDE");
await userEvent.clear(screen.getByLabelText("Qty for row-1"));
await userEvent.type(screen.getByLabelText("Qty for row-1"), "4");
await userEvent.clear(screen.getByLabelText("Selling price for row-1"));
await userEvent.type(screen.getByLabelText("Selling price for row-1"), "78.5");
await userEvent.click(screen.getByRole("button", { name: /send to quickbooks/i }));
const [, init] = fetchMock.mock.calls.at(-1)!;
expect(JSON.parse(String(init.body))).toMatchObject({
order_number: "SO-OVERRIDE",
items: [{ name: expect.any(String), qty: 4, price: 78.5 }],
});
});
- Step 2: Run the page tests to verify they fail
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: FAIL because the invoice modal does not yet expose editable override inputs.
- Step 3: Extend the payload helper to accept local overrides
In src/app/(app)/invoicing/view-model.ts, extend the payload builder:
export type InvoiceLineOverride = {
rowId: string;
qty: number;
price: number;
};
export function buildQbInvoicePayload(input: {
orderNumber: string;
billingCompany: string;
invoiceCompany: string;
customer: { id: string; name: string };
rows: InvoicingRow[];
orderNumberOverride?: string;
lineOverrides?: InvoiceLineOverride[];
sentById?: string;
sentByName?: string;
}): QbInvoicePayload {
const overrideMap = new Map(input.lineOverrides?.map((line) => [line.rowId, line]) ?? []);
return {
order_number: input.orderNumberOverride?.trim() || input.orderNumber,
billing_company: input.billingCompany,
invoice_company: input.invoiceCompany,
customer_id: input.customer.id,
customer_name: input.customer.name,
order_item_ids: input.rows.map((row) => row.id),
sent_by_id: input.sentById ?? "",
sent_by_name: input.sentByName ?? "",
items: input.rows.map((row) => {
const line = overrideMap.get(row.id);
return {
name: row.description ?? "(no description)",
qty: line?.qty ?? 1,
price: line?.price ?? 0,
};
}),
};
}
Update the QbInvoicePayload type in the same file so it includes:
order_item_ids: string[];
sent_by_id: string;
sent_by_name: string;
- Step 4: Add local override UI in the single-order modal
In src/app/(app)/invoicing/page.tsx, add modal-local state:
const [orderNumberOverride, setOrderNumberOverride] = useState(group.orderNumber);
const [lineOverrides, setLineOverrides] = useState<Record<string, { qty: string; price: string }>>({});
Render compact inputs before the final send button:
<label className="space-y-1 text-sm">
<span>Invoice order number</span>
<input
value={orderNumberOverride}
onChange={(event) => setOrderNumberOverride(event.currentTarget.value)}
className="w-full rounded-md border px-3 py-2"
/>
</label>
{group.rows.map((row) => {
const current = lineOverrides[row.id] ?? { qty: "1", price: "0" };
return (
<div key={row.id} className="grid grid-cols-3 gap-3">
<div className="text-sm">{row.description ?? "(no description)"}</div>
<label className="space-y-1 text-sm">
<span>{`Qty for ${row.id}`}</span>
<input
value={current.qty}
onChange={(event) => setLineOverrides((state) => ({
...state,
[row.id]: { ...current, qty: event.currentTarget.value },
}))}
className="w-full rounded-md border px-3 py-2"
/>
</label>
<label className="space-y-1 text-sm">
<span>{`Selling price for ${row.id}`}</span>
<input
value={current.price}
onChange={(event) => setLineOverrides((state) => ({
...state,
[row.id]: { ...current, price: event.currentTarget.value },
}))}
className="w-full rounded-md border px-3 py-2"
/>
</label>
</div>
);
})}
When building the payload, convert these values to numbers and pass them as lineOverrides.
- Step 5: Re-run the page tests
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: PASS for the override behavior.
- Step 6: Run diagnostics
Run GetDiagnostics on:
src/app/(app)/invoicing/view-model.tssrc/app/(app)/invoicing/page.tsxsrc/app/(app)/invoicing/page.test.tsx
Expected: no new TypeScript or lint errors.
Task 5: Upload the real QuickBooks invoice PDF into PocketBase
Files:
-
Modify:
n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE-2.json -
Step 1: Add the schema precondition to the plan notes before editing
Before changing the workflow, confirm the order_items collection has:
- a PocketBase
Filefield namedqb_invoice_pdf - optional text fields
qb_invoice_idandqb_invoice_linkif you want them top-level
Do not proceed with the upload node unless qb_invoice_pdf is a real file field.
- Step 2: Change the write-back builder so it no longer stores a base64 string
In the Build PocketBase Invoice Update code node, remove the text assignment:
if (invoicePdf) {
pbPatch.qb_invoice_pdf = invoicePdf;
}
Replace it with a payload that keeps invoice metadata only:
return orderItemIds.map((orderItemId) => ({
json: {
...ctx,
order_item_id: orderItemId,
pb_patch: {
item_status: "invoiced",
order_item_details: {
invoice: {
number: invoiceNumber,
date: invoiceDate,
setAt: now,
setBy: sentById,
setByName: sentByName,
billingCompany,
invoiceCompany,
qbInvoiceId: invoiceId,
qbInvoiceLink: invoiceLink,
},
},
},
invoice_pdf_base64: rawInvoicePdf,
},
}));
- Step 3: Add a multipart upload step for the PDF file
Insert a new HTTP Request node before the metadata patch node. Name it Upload Invoice PDF To PocketBase.
Configure it to PATCH the record URL:
{{$env.NEXT_PUBLIC_POCKETBASE_URL}}/api/collections/order_items/records/{{ $('Build PocketBase Invoice Update').item.json.order_item_id }}
Set multipart form-data with:
- field name:
qb_invoice_pdf - file name:
invoice_{{$json.invoice_number || "invoice"}}.pdf - binary or decoded file source derived from
invoice_pdf_base64
If the node requires binary input first, add a code node immediately before it:
const base64 = ($json.invoice_pdf_base64 ?? "").toString().trim();
if (!base64) {
throw new Error("Missing invoice_pdf_base64 for PocketBase upload");
}
return [{
json: $json,
binary: {
qb_invoice_pdf: {
data: base64,
mimeType: "application/pdf",
fileName: `invoice_${($json.invoice_number ?? "invoice").toString().trim() || "invoice"}.pdf`,
},
},
}];
- Step 4: Patch metadata after the upload succeeds
Keep the existing Update Order Item In PocketBase node, but make it patch only:
{
item_status: "invoiced",
order_item_details: {
invoice: {
number: invoiceNumber,
date: invoiceDate,
setAt: now,
setBy: sentById,
setByName: sentByName,
billingCompany,
invoiceCompany,
qbInvoiceId: invoiceId,
qbInvoiceLink: invoiceLink,
},
},
}
Do not send qb_invoice_pdf in the JSON patch once the file upload node owns that field.
- Step 5: Rewire success routing
The success path should become:
Build Created Response
-> Created Is Success?
-> Build PocketBase Invoice Update
-> Upload Invoice PDF To PocketBase
-> Update Order Item In PocketBase
-> Finalize PocketBase Invoice Update
-> PocketBase Write-Back Is Success?
-> Respond Success
Any file-upload or metadata-patch failure should route into the existing error response path instead of Respond Success.
- Step 6: Validate the workflow JSON still parses
Run:
node -e 'JSON.parse(require("fs").readFileSync("n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE-2.json","utf8")); console.log("ok")'
Expected: prints ok.
Task 6: Switch view/print behavior to the PocketBase file URL and verify end-to-end UI behavior
Files:
-
Modify:
src/app/(app)/invoicing/page.tsx -
Modify:
src/app/(app)/invoicing/print.ts -
Modify:
src/app/(app)/invoicing/page.test.tsx -
Modify:
src/app/(app)/invoicing/view-model.ts -
Step 1: Write the failing file-URL behavior test
Append this test to src/app/(app)/invoicing/page.test.tsx:
it("opens the resolved PocketBase invoice file URL for View invoice", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockedRows.splice(0, mockedRows.length, makeRawRow({
id: "row-1",
item_status: "invoiced",
qb_invoice_pdf: "invoice_a.pdf",
collectionId: "pbc_order_items",
order_item_details: { invoice: { number: "INV-1", date: "2026-07-08" } },
}));
render(<InvoicingPage />);
await userEvent.click(await screen.findByRole("tab", { name: "Invoiced" }));
await userEvent.click(await screen.findByRole("button", { name: /view invoice/i }));
expect(openSpy).toHaveBeenCalledWith(
"http://127.0.0.1:8090/api/files/pbc_order_items/row-1/invoice_a.pdf",
"_blank",
"noopener,noreferrer",
);
});
Adjust the expected PocketBase host if the test helper uses a different mocked base URL.
- Step 2: Run the page test to verify it fails
Run:
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: FAIL because the current page treats qb_invoice_pdf as a final openable string and does not resolve PocketBase file names.
- Step 3: Thread collection metadata through the row mapper
In src/app/(app)/invoicing/page.tsx, extend RawInvoicingRow and toInvoicingRow():
type RawInvoicingRow = {
id: string;
collectionId?: string;
qb_invoice_pdf?: string | null;
// existing fields
};
function toInvoicingRow(row: RawInvoicingRow): InvoicingRow {
return {
id: row.id,
collectionId: row.collectionId ?? "",
qb_invoice_pdf: resolveInvoicePdfUrl({
rawValue: row.qb_invoice_pdf ?? null,
recordId: row.id,
collectionId: row.collectionId ?? "",
fileName: row.qb_invoice_pdf ?? null,
pocketbaseBaseUrl: process.env.NEXT_PUBLIC_POCKETBASE_URL,
}),
// existing mapped fields
};
}
Also extend InvoicingRow in view-model.ts:
collectionId?: string;
qtyCollected?: number | null;
qtyOrdered?: number | null;
sellingPrice?: number | null;
- Step 4: Keep print download behavior unchanged except for the new URL source
In src/app/(app)/invoicing/print.ts, keep:
export async function downloadQbInvoicePdf(url: string): Promise<Uint8Array> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download invoice PDF (${response.status})`);
}
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}
Do not add special PocketBase logic here. The page should pass a fully resolved URL into this helper.
- Step 5: Run the focused tests and the full invoicing suite
Run:
npx vitest run "src/app/(app)/invoicing/view-model.test.ts"
npx vitest run "src/app/(app)/invoicing/page.test.tsx"
Expected: PASS.
- Step 6: Run diagnostics
Run GetDiagnostics on:
src/app/(app)/invoicing/page.tsxsrc/app/(app)/invoicing/view-model.tssrc/app/(app)/invoicing/print.tssrc/app/(app)/invoicing/page.test.tsx
Expected: no new TypeScript or lint errors.
- Step 7: Manually verify the live flow
After importing the updated workflow into n8n and adding the PocketBase file field, verify manually:
1. Open /invoicing.
2. Confirm the page shows To Invoice and Invoiced tabs.
3. Confirm To Invoice groups by order and hides outstanding rows until toggled.
4. Run one single-order invoice and one bulk run.
5. Check PocketBase order_items contains the uploaded qb_invoice_pdf file.
6. Return to the page and confirm View invoice opens the stored file.
7. Confirm Print downloads the stored invoice PDF and appends the picking slip.
Expected: both single and bulk invoice flows work, and the uploaded file is the source for view/print.
Self-Review
- Spec coverage:
To InvoiceandInvoicedtabs: Task 2- order-number grouping and outstanding toggle: Tasks 1 and 2
Collected / Orderedvisibility: Tasks 1, 2, and 6- bulk per-order matching: Task 3
- local invoice-only overrides: Task 4
- PocketBase real PDF upload: Task 5
- view/print from the stored file URL: Task 6
- Placeholder scan:
- Removed
TODO-style wording from implementation steps - Every task includes concrete file paths, commands, and code snippets
- Removed
- Type consistency:
- Added
order_item_ids,sent_by_id,sent_by_name,qtyCollected,qtyOrdered,sellingPrice, andcollectionIdin the same plan where they are later consumed
- Added