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>
12 KiB
Non-Invoiceable Order Extras — Implementation Plan
For agentic workers: Use
superpowers:subagent-driven-developmentorsuperpowers:executing-plansto 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_idsand patches each row withitem_status: 'invoiced'+order_item_details.invoice. The QuickBooks lines come from the separateitemsarray. Verified inn8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE.json. - Nothing in the Next app flips
item_statusto"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_pricehasmin: 0in the PB schema, so0is a legal value.- Tests:
npm run test:run. Dev server is on port 4001, not 3000. vitest.config.tshas noclearMocks— 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_itemscollection: - 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
OrderIteminterface insrc/lib/types.ts(interface starts at line 782), nearsurplus_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 --noEmitpasses.
Task 2 — Allow adding lines mid-flight
src/lib/server/orders-service.ts.
- Add
exclude_from_invoice?: booleantoCreateOrderItemForOrderDraft(line 3867). - Delete the pending gate in
createOrderItemForOrder— thenonPendingquery and theif (nonPending.length > 0)throw at line 3939. Replace the "Strictpendinggate" 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
nonPendinggate at line 3744 insetOrderQuote. Leave it alone. - In the secondary-field patch block, forward the flag. When
exclude_from_invoiceis true, forceselling_priceto0regardless of what the caller passed — the flag is authoritative. - Keep the
orderLockedfield in the return type for API compatibility; it is now alwaysfalse.
Tests in orders-service.test.ts:
- Adding a line to an order whose other items are
po_generatedsucceeds (previously threw 409). exclude_from_invoice: truepersists and forcesselling_priceto 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
Sheetpattern used byPriceEditSheet). - Fields: description (required), quantity, cost price, selling price, supplier, notes.
- A
Checkboxlabelled "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
itemson success. - Existing rows with
exclude_from_invoicerender 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 testscomputedMarkup < MARKUP_APPROVAL_THRESHOLD, short-circuit when the item hasexclude_from_invoice— skip the approval request entirely and save straight through. - Suppress the low-margin colouring for excluded rows (
LOW_MARGIN_THRESHOLDat 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?: booleantoInvoicingRow(line 4). - In
buildQbInvoicePayload(line 393), keeporder_item_idsbuilt from all rows, and builditemsfrom only rows whereexcludeFromInvoiceis not true. - Comment the split at the call site — the asymmetry looks like a bug
otherwise. State that
order_item_idsdrives n8n's status write anditemsdrives 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 intoInvoicingRow: addexclude_from_invoiceto both theRawInvoicingRowtype (line 42) and theINVOICING_FIELDSprojection (line 77). The projection is load-bearing — omit it and PB returns the field asundefined, silently invoicing every extra.
Tests in view-model.test.ts:
- Mixed set: excluded ids present in
order_item_ids, absent fromitems. - All-billable set produces identical output to today (regression).
- All-excluded set produces an empty
itemsarray, 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) andsubmitDeliveryNote(line ~1980). The existingnoneIncludedchecks 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_invoicein thegetFullListat 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.createacceptsexclude_from_invoice.- Update the tool description to explain what the flag does and that it
forces
selling_priceto 0. - Update the description of the removed
pendinggate — the current text at line 794 documents a 409 that no longer fires.
Definition of done
npm run test:runpasses.npx tsc --noEmitpasses.- Manually on port 4001: add an extra to an order with a
po_generatedsibling; 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 toinvoiced; print the bundle and confirm the picking slip lists the extra.