261 lines
12 KiB
Markdown
261 lines
12 KiB
Markdown
|
|
# 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.
|