WFDASH-WEBAPP/docs/superpowers/plans/2026-08-30-non-invoiceable-order-extras.md

261 lines
12 KiB
Markdown
Raw Normal View 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`:
```ts
/**
* 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.