# Non-Invoiceable Order Extras — Design **Date:** 2026-08-30 **Status:** Approved, ready for implementation planning ## The problem We quote a line as a single sellable unit — "M20 bolt, nut and washer" — and the customer approves that one line at that one price. But the supply side doesn't work that way: the bolt comes from one supplier, the nut and washer from another. To raise the purchase orders we need three line items, not one. Today the operator has no way to add those two extra lines. `order_items` rows are the only unit the PO flow understands, so a component with no row of its own can't be ordered, can't be assigned to a driver, and never shows as collected. The operator works around it off-system. Adding the rows naively creates the opposite problem: all three would flow to the invoicing screen and QuickBooks would bill the customer for three lines against a quote they approved as one. ## What we're building A line item can be marked **excluded from invoice**. Such a line behaves like any other order item everywhere in the operational flow — it gets a supplier, a cost price, a purchase order, a driver assignment, and it shows as collected — but it never appears on a customer-facing invoice or delivery note. The customer's paperwork comes out byte-identical to what it looks like today. ## Decisions These were settled during brainstorming and are not open for reinterpretation during implementation. | Question | Decision | |---|---| | Delivery note | **Excluded**, same as the invoice. Customer paperwork must agree. | | Terminal status | Auto-flips to `invoiced` alongside its siblings. The order closes normally. | | Where lines are added | Process page (`/orders/[id]/process`), for **any** line, with no `pending` gate. | | Cost and margin | Cost counts as normal. `selling_price` is forced to `0` and locked. | | Picking slip | **Included.** The picker must physically pull the washers. | ## Data model One new field on `order_items`: ``` exclude_from_invoice : bool ``` **Polarity is load-bearing.** The flag must name the exception, not the norm. A positively-named field (`bill_customer`, `invoiceable`) defaults to `false` in PocketBase, which would silently mark every existing row in the database as non-billable the instant the field is created. `exclude_from_invoice` defaults to `false` = "bill this normally", so existing rows are unaffected. Per `AGENTS.md`, the user adds this field via the PocketBase admin UI before any code references it. `pb-exports/pb_schema-2.json` is a snapshot for reference only. No new `item_status` value is introduced. The existing select enum is untouched, which avoids a second manual PB admin change and avoids updating every status filter, pill, tab count and lifecycle comment in the app. ## Architecture The design rests on a property of the existing invoice pipeline that makes the whole feature small. ### The invoice split `buildQbInvoicePayload` (`src/app/(app)/invoicing/view-model.ts:393`) builds two arrays from one row list: ```ts order_item_ids: input.rows.map((row) => row.id), // drives the PB status write items: input.rows.map((row) => ({ ... })), // drives the QuickBooks lines ``` The n8n workflow consumes them independently. Its **"Build PocketBase Invoice Update"** node maps over `order_item_ids` and patches each row with `item_status: 'invoiced'` plus `order_item_details.invoice`. The QuickBooks invoice itself is built from `items`. So the change is: **excluded rows stay in `order_item_ids` and drop out of `items`.** n8n flips them to `invoiced` with the batch and stamps them with the same invoice number for audit; QuickBooks never learns they exist. **No n8n changes are required.** This is the crux of the design — verified against `n8n_workfows/1 - WFDASH QUICKBOOKS INVOICE1 - WFDASH QUICKBOOKS INVOICE.json`. ### The delivery note split `/api/invoicing/delivery-note/route.ts` has the same shape. Excluded rows stay in the status-flip loop (`src/app/api/invoicing/delivery-note/route.ts:230`), so they receive `item_status: "delivery_note"` and the DN number; they drop out of the `lines:` array that renders the PDF (`src/app/api/invoicing/delivery-note/route.ts:362`). ### Picking slips need no change at all Both picking-slip routes query PocketBase by document identity, not by the row list the operator submitted: - `print-bundle/route.ts:88` — `order_item_details.invoice.number = "…" && item_status = "invoiced"` - `delivery-note-print/route.ts:92` — `delivery_note_number = "…" && item_status = "delivery_note"` Because excluded rows *do* receive the invoice number / DN number and *do* reach the matching status, they are picked up by these queries automatically. The picker sees the washers. This falls out of the design for free. ### The margin gate `calculateOrderItemMarkup(cost, 0)` returns `-100`, far below the `ORDER_MARKUP_APPROVAL_THRESHOLD` of `22` (`src/lib/server/order-item-margin-approval-service.ts:4`). Without an explicit bypass, every extra the operator adds would immediately land in `approval_needed` and block the order pending manager review. **This is the highest-risk part of the implementation.** The bypass must be keyed on `exclude_from_invoice`, applied in the process page's save path before the threshold comparison. ### Adding lines mid-flight `createOrderItemForOrder` (`src/lib/server/orders-service.ts:3939`) currently throws a 409 `order_locked` if any line on the order is past `pending`. By the time the operator discovers they need a washer line, the bolt line is already `po_generated`, so this gate fires every time. It is removed. The gate's original purpose — preventing a PO's pricing/supplier reference from being orphaned — does not apply here. Adding a *new* row alongside an existing PO doesn't mutate that PO; the new row gets its own PO when the operator processes it. ## Flow ``` Operator opens /orders/[id]/process │ ├── Adds "M20 nut" with [x] Don't invoice │ → selling_price forced 0, margin gate bypassed │ → row created with exclude_from_invoice = true, item_status = pending │ ├── Normal flow: PO generated → driver assigned → collected → delivered │ (identical to every other line; order "Complete" needs no change) │ └── Invoicing screen ├── row renders muted with a "Not invoiced" chip, checkbox locked ├── Send to QuickBooks │ → order_item_ids: [bolt, nut, washer] ← all three │ → items: [bolt] ← one line billed │ → n8n flips all three to `invoiced` └── Print bundle → picking slip query finds all three by invoice number → picker pulls all three ``` ## Order completion Unchanged. `isOrderComplete` (`src/lib/server/order-completion.ts:20`) requires every item to be `delivered`; excluded items reach `delivered` on the normal path. `markOrderInvoiced` (`src/lib/server/orders-service.ts:3397`) requires every item to be `invoiced`; excluded items get there via the n8n batch flip. Neither function needs to learn about the flag. ## Error handling - **All rows in a group excluded.** Block the send with a toast rather than firing a zero-line invoice at QuickBooks. Applies to both the invoice and delivery-note submit paths. - **Operator un-ticks the only billable row** in the include-picker modal, leaving only excluded rows. Same guard, same message. - **Excluded row with a non-zero selling price** (legacy or hand-edited in PB admin). The payload builder forces the exclusion regardless of price — the flag is authoritative, price is not consulted. ## Testing Unit tests, Vitest (`npm run test:run`): - `view-model.test.ts` — `buildQbInvoicePayload` keeps excluded ids in `order_item_ids` and omits them from `items`; an all-excluded row set is detectable by the caller. - `orders-service.test.ts` — `createOrderItemForOrder` succeeds against an order with `po_generated` siblings; `exclude_from_invoice` and the zeroed selling price persist. - Delivery-note route test — excluded rows receive the status flip and DN number but are absent from the rendered `lines`. - Margin-gate test — an excluded line with cost > 0 and selling 0 does not enter `approval_needed`. ## Out of scope - Reporting changes. Extras eat margin because their cost is real and their selling price is zero; that is the correct number and no report needs adjusting to produce it. - Retrofitting existing orders that were worked around off-system. - Any change to the n8n QuickBooks workflow. - Bulk-adding extras, or templating common component sets (bolt/nut/washer as a saved group). Worth considering later if the operator finds themselves typing the same three lines repeatedly.