WFDASH-WEBAPP/docs/superpowers/plans/2026-07-08-invoicing-quickbooks-writeback.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

14 KiB

Invoicing QuickBooks Write-Back 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: Persist successful QuickBooks invoice creation back onto the linked order_items so the Invoicing page reload shows invoice number, invoiced status, PDF link, and print actions.

Architecture: Keep n8n as the system that owns the QuickBooks round-trip and the PocketBase write-back. The frontend only adds sender identity and linked item ids to the existing payload, then reloads PocketBase after a successful webhook response. The workflow updates each linked order_item with shared invoice metadata in order_item_details.invoice plus item_status and qb_invoice_pdf.

Tech Stack: Next.js 16, React 19, TypeScript, PocketBase browser client, n8n workflow JSON, Vitest + Testing Library.


File Structure

  • Modify: src/app/(app)/invoicing/page.tsx — extend the QuickBooks payload builder inputs and send sender identity plus order_item_ids.
  • Modify: src/app/(app)/invoicing/page.test.tsx — assert the webhook payload includes the new sender and item-id fields.
  • Modify: n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE-2.json — normalize the new request fields, validate them, write back to PocketBase on the success path, and include the saved metadata in the response.
  • Optional reference: src/lib/pocketbase.ts — confirm how pb.authStore.record exposes the logged-in user.

Task 1: Add sender identity and item ids to the frontend webhook payload

Files:

  • Modify: src/app/(app)/invoicing/page.tsx

  • Modify: src/app/(app)/invoicing/page.test.tsx

  • Step 1: Add the failing test for the new payload fields

Append this assertion block to the existing single-order webhook payload test in src/app/(app)/invoicing/page.test.tsx:

expect(JSON.parse(String(init.body))).toMatchObject({
  order_number: "SO-1",
  billing_company: "CNL",
  invoice_company: "Adal Engineering",
  customer_id: "qb-1",
  customer_name: "Adal Engineering",
  order_item_ids: ["row-1"],
  sent_by_id: "user-1",
  sent_by_name: "Test Operator",
});

Update the PocketBase mock near the top of the same file so getPocketBase() returns an authStore with a real user record:

vi.mock("@/lib/pocketbase", () => ({
  getPocketBase: () => ({
    authStore: {
      model: {
        id: "user-1",
        name: "Test Operator",
        email: "operator@example.com",
      },
    },
    collection: () => ({
      getFullList: vi.fn(async () => [
        // existing mocked rows
      ]),
    }),
  }),
}));
  • 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 payload does not yet contain order_item_ids, sent_by_id, or sent_by_name.

  • Step 3: Implement the minimal page changes

In src/app/(app)/invoicing/page.tsx, extend the payload builder call inside submitQbInvoice() so it passes the row ids plus sender identity from pb.authStore.model:

const actor = pb.authStore.model as
  | { id?: string; name?: string; email?: string }
  | null;

const payload = buildQbInvoicePayload({
  orderNumber: first.orderNumber ?? "",
  billingCompany: qbCompany,
  invoiceCompany: first.companyName ?? "",
  customer,
  rows: group.rows,
  sentById: actor?.id ?? "",
  sentByName: actor?.name || actor?.email || "",
});

Update buildQbInvoicePayload(...) so it returns:

{
  order_number: orderNumber,
  billing_company: billingCompany,
  invoice_company: invoiceCompany,
  customer_id: customer.id,
  customer_name: customer.name,
  order_item_ids: rows.map((row) => row.id),
  sent_by_id: sentById,
  sent_by_name: sentByName,
  items: rows.map((row) => ({
    id: row.id,
    name: row.description ?? "Item",
    qty: 1,
    price: 0,
  })),
}

If the function type currently does not accept the two sender fields, extend its parameter type inline in page.tsx rather than creating a new file.

  • Step 4: Re-run the page test

Run:

npx vitest run "src/app/(app)/invoicing/page.test.tsx"

Expected: PASS.

  • Step 5: 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 2: Normalize and validate the new request fields in n8n

Files:

  • Modify: n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE-2.json

  • Step 1: Update Normalize Request to extract sender and item ids

In the Normalize Request node (id: "normalize-request"), extend the code block so it reads:

const invoiceCompany = (body.invoice_company ?? body.company ?? "").toString().trim();
const orderItemIds = Array.isArray(body.order_item_ids)
  ? body.order_item_ids.map((value) => value.toString().trim()).filter(Boolean)
  : [];
const sentById = (body.sent_by_id ?? "").toString().trim();
const sentByName = (body.sent_by_name ?? "").toString().trim();

Then return these fields on the output JSON:

return [{
  json: {
    invoice_company: invoiceCompany,
    company: invoiceCompany,
    billing_company: billingCompany,
    order_number: orderNumber,
    customer_id: customerId,
    customer_name: customerName,
    order_item_ids: orderItemIds,
    sent_by_id: sentById,
    sent_by_name: sentByName,
    items,
    line_count: items.length,
    auth_ok: true
  }
}];
  • Step 2: Tighten Validate Payload for write-back requirements

In the Validate Payload node (id: "validate-payload"), add these checks before the final success return:

if (!Array.isArray($json.order_item_ids) || $json.order_item_ids.length < 1) {
  errors.push("order_item_ids must be an array with at least 1 id");
}

if (!$json.sent_by_id || typeof $json.sent_by_id !== "string" || $json.sent_by_id.trim() === "") {
  errors.push("sent_by_id is required");
}

if (!$json.sent_by_name || typeof $json.sent_by_name !== "string" || $json.sent_by_name.trim() === "") {
  errors.push("sent_by_name is required");
}
  • Step 3: Save the workflow JSON and verify it 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 3: Add PocketBase write-back on the n8n success path

Files:

  • Modify: n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE-2.json

  • Step 1: Locate the success branch immediately after invoice creation

Use the existing success path around these named nodes:

  • Build Created Response
  • Created Is Success?
  • Respond Success

Insert the write-back sequence before the final response node so the workflow only responds success after PocketBase has been updated.

  • Step 2: Add a code node that builds the PocketBase patch payload

Create a new code node named Build PocketBase Invoice Update with includeOtherFields: true behavior equivalent to:

const now = new Date().toISOString();
const invoiceNumber = ($json.invoice_number ?? "").toString().trim();
const invoiceId = ($json.invoice_id ?? "").toString().trim();
const invoiceLink = ($json.invoice_link ?? "").toString().trim();
const invoicePdf = ($json.pdf_base64 ?? $json.qb_invoice_pdf ?? "").toString().trim();
const billingCompany = ($json.billing_company ?? "").toString().trim();
const invoiceCompany = ($json.invoice_company ?? $json.company ?? "").toString().trim();
const sentById = ($json.sent_by_id ?? "").toString().trim();
const sentByName = ($json.sent_by_name ?? "").toString().trim();
const orderItemIds = Array.isArray($json.order_item_ids) ? $json.order_item_ids : [];

if (!invoiceNumber || orderItemIds.length === 0) {
  throw new Error("Missing invoice write-back fields");
}

return orderItemIds.map((orderItemId) => ({
  json: {
    order_item_id: orderItemId,
    pb_patch: {
      item_status: "invoiced",
      qb_invoice_pdf: invoicePdf,
      order_item_details: {
        invoice: {
          number: invoiceNumber,
          date: now.slice(0, 10),
          setAt: now,
          setBy: sentById,
          setByName: sentByName,
          billingCompany,
          invoiceCompany,
          qbInvoiceId: invoiceId,
          qbInvoiceLink: invoiceLink,
        },
      },
    },
  },
}));

If the workflow already has a more exact invoice date field from QuickBooks, prefer that instead of now.slice(0, 10).

  • Step 3: Add the PocketBase update node

Add an HTTP Request node named Update Order Item In PocketBase that iterates once per item from the previous node and sends:

{
  "method": "PATCH",
  "url": "={{ $env.POCKETBASE_URL }}/api/collections/order_items/records/{{ $json.order_item_id }}",
  "sendHeaders": true,
  "headerParameters": {
    "parameters": [
      { "name": "Content-Type", "value": "application/json" },
      { "name": "Authorization", "value": "=Bearer {{ $env.POCKETBASE_SERVICE_TOKEN }}" }
    ]
  },
  "sendBody": true,
  "specifyBody": "json",
  "jsonBody": "={{ $json.pb_patch }}"
}

If this workflow already uses a different PocketBase base URL or admin token variable, reuse the existing names instead of introducing new ones.

  • Step 4: Add a merge or success check before the final response

Ensure the response path does not hit Respond Success until every Update Order Item In PocketBase execution has succeeded. If any update node errors, route into the existing QB error response path instead of success.

  • Step 5: Add write-back fields to the success response

Update Build Created Response so the final JSON includes:

invoice_company: $json.invoice_company ?? $json.company,
order_item_ids: $json.order_item_ids,
sent_by_id: $json.sent_by_id,
sent_by_name: $json.sent_by_name,

This makes debugging easier and confirms the response reflects the persisted state.

  • Step 6: Re-parse the workflow JSON

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 4: Add a focused UI regression test for the new response contract

Files:

  • Modify: src/app/(app)/invoicing/page.test.tsx

  • Step 1: Add a success-path test that still reloads after the richer payload

Append:

it("still reloads the page after a successful QuickBooks response that includes write-back fields", async () => {
  const reloadSpy = vi.fn();
  Object.defineProperty(window, "location", {
    configurable: true,
    value: { reload: reloadSpy },
  });

  invoiceWebhookFetchMock.mockResolvedValueOnce(
    new Response(
      JSON.stringify({
        status: "success",
        invoice_number: "INV28819",
        invoice_id: "129869",
        invoice_link: "https://app.qbo.intuit.com/app/invoice?txnId=129869",
        billing_company: "CNL",
        invoice_company: "Adal Engineering",
        order_item_ids: ["row-1"],
        sent_by_id: "user-1",
        sent_by_name: "Test Operator",
      }),
      { status: 200 },
    ),
  );

  render(<Page />);
  await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
  fireEvent.click(getSingleOrderButton("PO-TEST-3340-FASTKTM"));
  await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
  pickQbCompany("single", "CNL");
  await waitFor(() => expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("qb-1"));
  fireEvent.click(screen.getByRole("button", { name: /^send to quickbooks$/i }));

  await waitFor(() => expect(invoiceWebhookFetchMock).toHaveBeenCalledTimes(1));
  await waitFor(() => expect(reloadSpy).toHaveBeenCalled());
});

If the page currently calls load() instead of window.location.reload(), update the expectation to assert the visible rows re-render rather than forcing a reload spy. Match the real code path.

  • Step 2: Run the page tests

Run:

npx vitest run "src/app/(app)/invoicing/page.test.tsx"

Expected: PASS.


Task 5: Verify the end-to-end behavior manually

Files:

  • Modify: none

  • Step 1: Start the app if needed

Run:

npm run dev

Expected: local Next.js server starts cleanly.

  • Step 2: Create one real invoice from the Invoicing page

Manual path:

  1. Open /invoicing
  2. Open a PO group
  3. Pick CNL or ISHERWOOD
  4. Select the matched QuickBooks customer
  5. Click Send to QuickBooks

Expected:

  • n8n runs once,

  • the invoice is created,

  • PocketBase updates the linked order_items,

  • the page refresh shows invoice number beside the rows,

  • View invoice appears,

  • Print appears for the invoiced group.

  • Step 3: Verify failure behavior

Force or simulate a PocketBase write-back failure in n8n.

Expected:

  • n8n returns an error response,
  • the UI shows a failure toast,
  • the group does not appear as cleanly invoiced on refresh.

Self-Review

  • Spec coverage: the plan covers frontend payload changes, n8n normalization/validation, PocketBase write-back, and manual verification of the refreshed UI state.
  • Placeholder scan: no TODO or TBD markers remain.
  • Type consistency: the plan uses the same field names as the approved spec: billing_company, invoice_company, order_item_ids, sent_by_id, sent_by_name, qb_invoice_pdf, and order_item_details.invoice.