WFDASH-WEBAPP/docs/superpowers/plans/2026-08-30-non-invoiceable-order-extras.md
Dave 4d95b7af8c docs: non-invoiceable extras design + plan, quantity-handling audit
Three documents behind the two features on this branch:

- specs/2026-08-30-non-invoiceable-order-extras-design.md — the design
  the implementation followed, including why the flag must be named for
  the exception (a positively-named field defaults to false and would
  mark every existing row non-billable the moment it is created).
- plans/2026-08-30-non-invoiceable-order-extras.md — the task-by-task
  plan, now fully implemented.
- specs/2026-08-30-quantity-handling-audit.md — the order_qty /
  quantity audit that produced the preceding fix, plus the partial
  collection findings that are still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:28:44 +02:00

12 KiB
Raw Blame History

Non-Invoiceable Order Extras — Implementation Plan

For agentic workers: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: The operator can add extra line items to an order at any stage and mark them "don't invoice". Such lines get POs, drivers and collection tracking like any other line, and count toward order completion — but never appear on a customer invoice or delivery note.

Spec: docs/superpowers/specs/2026-08-30-non-invoiceable-order-extras-design.md

Tech Stack: Next.js 16 (App Router), TypeScript, PocketBase (via getAdminPocketBase()), Vitest, Tailwind + shadcn/ui, lucide-react.


Pre-flight: verified before this plan was written

Do not re-investigate these.

  • The n8n QuickBooks workflow needs no changes. Its "Build PocketBase Invoice Update" node maps over order_item_ids and patches each row with item_status: 'invoiced' + order_item_details.invoice. The QuickBooks lines come from the separate items array. Verified in n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE.json.
  • Nothing in the Next app flips item_status to "invoiced". That write is n8n's alone. Do not add an app-side flip.
  • Both picking-slip routes need no changes. They query PB by document identity (print-bundle/route.ts:88, delivery-note-print/route.ts:92), not by the submitted row list, so excluded rows are picked up automatically. Task 8 adds a regression test to lock this in.
  • selling_price has min: 0 in the PB schema, so 0 is a legal value.
  • Tests: npm run test:run. Dev server is on port 4001, not 3000.
  • vitest.config.ts has no clearMocks — mock history leaks between tests. Reset explicitly or you get green-but-vacuous tests.

File structure

File Responsibility
PocketBase admin (manual) New order_items.exclude_from_invoice bool. Task 0 — blocks everything.
src/lib/types.ts (modify) OrderItem.exclude_from_invoice?: boolean.
src/lib/server/orders-service.ts (modify) Drop the pending gate; accept the flag in the create draft.
src/app/(app)/orders/[id]/process/page.tsx (modify) Add-item form; margin-gate bypass; excluded-row styling.
src/app/(app)/invoicing/view-model.ts (modify) Split order_item_ids from items in buildQbInvoicePayload.
src/app/(app)/invoicing/page.tsx (modify) Muted row + locked checkbox + all-excluded guard, in both modals.
src/app/api/invoicing/delivery-note/route.ts (modify) Excluded rows flip status but drop out of lines:.
src/app/api/mcp/route.ts (modify) order_items.create accepts the flag.

Phase boundaries

  • Phase 1 — Tasks 0–4. Creating and pricing an excluded line. Ends with the operator able to add extras that survive the whole operational flow.
  • Phase 2 — Tasks 5–9. Keeping them off customer paperwork. Depends on Phase 1.

Task 0 — PocketBase field (BLOCKS EVERYTHING)

This is a manual step the user performs in the PB admin UI. Per AGENTS.md, schema changes to PocketBase are not self-service; the API returns an opaque 400/500 if code writes a field that doesn't exist yet.

  • Ask the user to add to the order_items collection: - Name: exclude_from_invoice - Type: Bool - Required: no
  • Confirm it exists before starting Task 1. Do not proceed on assumption.

Do not invert the name. A positively-named flag (bill_customer, invoiceable) defaults to false and would mark every existing row in the database non-billable the moment the field is created.

Optionally refresh pb-exports/pb_schema-2.json, but remember it is a reference snapshot — the running instance is the source of truth.

Task 1 — Type the field

  • Add to the OrderItem interface in src/lib/types.ts (interface starts at line 782), near surplus_qty / ready_for_collection:
/**
 * When true this line never reaches a customer-facing document. It
 * still gets a PO, a driver, a collection and a `delivered` status —
 * it is simply dropped from the QuickBooks `items` array and the
 * delivery-note PDF lines. Used for supplier-side component splits:
 * the customer approved "bolt, nut and washer" as one priced line,
 * but we must raise three PO lines to actually buy it.
 *
 * Named for the exception, not the norm: the PB default of `false`
 * must mean "bill this normally".
 */
exclude_from_invoice?: boolean;
  • npx tsc --noEmit passes.

Task 2 — Allow adding lines mid-flight

src/lib/server/orders-service.ts.

  • Add exclude_from_invoice?: boolean to CreateOrderItemForOrderDraft (line 3867).
  • Delete the pending gate in createOrderItemForOrder — the nonPending query and the if (nonPending.length > 0) throw at line 3939. Replace the "Strict pending gate" doc comment above the function with an explanation of why adding a row alongside an in-flight PO is safe: the new row doesn't mutate the existing PO, it gets its own.
  • There is a second, unrelated nonPending gate at line 3744 in setOrderQuote. Leave it alone.
  • In the secondary-field patch block, forward the flag. When exclude_from_invoice is true, force selling_price to 0 regardless of what the caller passed — the flag is authoritative.
  • Keep the orderLocked field in the return type for API compatibility; it is now always false.

Tests in orders-service.test.ts:

  • Adding a line to an order whose other items are po_generated succeeds (previously threw 409).
  • exclude_from_invoice: true persists and forces selling_price to 0 even when the caller passes a non-zero price.
  • Omitting the flag leaves it falsy and preserves existing behaviour.

Task 3 — Add-item UI on the process page

src/app/(app)/orders/[id]/process/page.tsx. The empty-state copy at line 1684 ("No line items on this order. Add items before processing.") currently points at an action that doesn't exist — this task makes it true.

  • Add an "Add item" button below the item card stack, opening a small form (reuse the existing Sheet pattern used by PriceEditSheet).
  • Fields: description (required), quantity, cost price, selling price, supplier, notes.
  • A Checkbox labelled "Don't invoice this line", with helper text: "Counts for the order and gets collected, but never appears on the customer's invoice or delivery note."
  • When ticked: force the selling-price input to 0, disable it, and show "Not invoiced" in place of the margin chip.
  • POST to the existing per-id create surface; refresh items on success.
  • Existing rows with exclude_from_invoice render with a muted "Not invoiced" badge in the card stack.

Verification: dev server on port 4001. Add an extra to an order that already has a po_generated line and confirm it saves without a 409.

Task 4 — Bypass the margin gate (HIGHEST RISK)

calculateOrderItemMarkup(cost, 0) returns -100, well under MARKUP_APPROVAL_THRESHOLD (22, declared at process/page.tsx:246). Without this task every extra lands in approval_needed and wedges the order.

  • At process/page.tsx:643, where the save path tests computedMarkup < MARKUP_APPROVAL_THRESHOLD, short-circuit when the item has exclude_from_invoice — skip the approval request entirely and save straight through.
  • Suppress the low-margin colouring for excluded rows (LOW_MARGIN_THRESHOLD at line 247) — a red "-100%" chip on every extra is noise.
  • Also guard the server side: requestOrderItemMarginApproval (src/lib/server/order-item-margin-approval-service.ts:128) should refuse with a clear error if asked to gate an excluded item, so the MCP path can't wedge an order either.

Tests:

  • An excluded item with cost 50 / selling 0 saves without entering approval_needed.
  • A non-excluded item with the same numbers still triggers approval — proves the bypass is keyed on the flag, not on the price being zero.

Task 5 — Split the QuickBooks payload

src/app/(app)/invoicing/view-model.ts.

  • Add excludeFromInvoice?: boolean to InvoicingRow (line 4).
  • In buildQbInvoicePayload (line 393), keep order_item_ids built from all rows, and build items from only rows where excludeFromInvoice is not true.
  • Comment the split at the call site — the asymmetry looks like a bug otherwise. State that order_item_ids drives n8n's status write and items drives the QuickBooks lines.
  • Export a small helper (e.g. billableRows(rows)) so the modals and the guard in Task 6 share one definition of "billable".
  • In src/app/(app)/invoicing/page.tsx, map the new PB field into InvoicingRow: add exclude_from_invoice to both the RawInvoicingRow type (line 42) and the INVOICING_FIELDS projection (line 77). The projection is load-bearing — omit it and PB returns the field as undefined, silently invoicing every extra.

Tests in view-model.test.ts:

  • Mixed set: excluded ids present in order_item_ids, absent from items.
  • All-billable set produces identical output to today (regression).
  • All-excluded set produces an empty items array, so Task 6's guard has something to detect.

Task 6 — Invoicing UI

src/app/(app)/invoicing/page.tsx. There are two modals — single-order (~line 576) and bulk (~line 965) — each with its own includedRowIds state. Apply every change to both.

  • Excluded rows render muted, with a "Not invoiced" chip.
  • Their include-checkbox is checked and disabled — the row must ride along for the status flip, and the operator must not be able to remove it or add it to the bill.
  • Exclude them from any invoice-total display in the modal.
  • Guard: if the billable subset is empty, disable submit and toast "This order has no billable lines." Apply to sendToQuickBooks (line ~1892) and submitDeliveryNote (line ~1980). The existing noneIncluded checks are about the operator's manual unticking and are not sufficient.

Task 7 — Delivery note

src/app/api/invoicing/delivery-note/route.ts.

  • Leave the status-flip loop at line 230 untouched — excluded rows must receive item_status: "delivery_note" and the DN number, or they strand.
  • At line 362, build lines: from billable items only.
  • Guard: if no billable items remain, 400 with "No billable lines for a delivery note" rather than rendering an empty PDF.
  • Fetch exclude_from_invoice in the getFullList at line 212.

Test: excluded rows get the status and DN number, and are absent from the lines passed to the PDF renderer.

Task 8 — Picking-slip regression test

No production code changes. This task exists to lock in behaviour the design depends on, so a later refactor doesn't quietly break it.

  • Test print-bundle/route.ts: an invoice whose rows include an excluded item produces a picking slip containing all rows, including the excluded one. The picker must pull the washers.
  • Same for delivery-note-print/route.ts.

Task 9 — MCP surface

src/app/api/mcp/route.ts.

  • order_items.create accepts exclude_from_invoice.
  • Update the tool description to explain what the flag does and that it forces selling_price to 0.
  • Update the description of the removed pending gate — the current text at line 794 documents a 409 that no longer fires.

Definition of done

  • npm run test:run passes.
  • npx tsc --noEmit passes.
  • Manually on port 4001: add an extra to an order with a po_generated sibling; process it onto a PO; confirm collection; send the order to QuickBooks; confirm the QuickBooks invoice shows only the billable line while all rows flip to invoiced; print the bundle and confirm the picking slip lists the extra.