WFDASH-WEBAPP/docs/superpowers/plans/2026-07-08-invoicing-redesign-and-pdf-upload.md
SJCoetz 18334721cb feat(invoicing): add QuickBooks invoicing flow and print functionality
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
2026-07-09 09:44:55 +02:00

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.ts if 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 to order_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.ts
  • src/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 Invoice as 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 Invoiced as 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.tsx
  • src/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.tsx
  • src/app/(app)/invoicing/view-model.ts
  • src/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.ts
  • src/app/(app)/invoicing/page.tsx
  • src/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 File field named qb_invoice_pdf
  • optional text fields qb_invoice_id and qb_invoice_link if 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.tsx
  • src/app/(app)/invoicing/view-model.ts
  • src/app/(app)/invoicing/print.ts
  • src/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 Invoice and Invoiced tabs: Task 2
    • order-number grouping and outstanding toggle: Tasks 1 and 2
    • Collected / Ordered visibility: 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
  • Type consistency:
    • Added order_item_ids, sent_by_id, sent_by_name, qtyCollected, qtyOrdered, sellingPrice, and collectionId in the same plan where they are later consumed