diff --git a/src/lib/server/orders-service.test.ts b/src/lib/server/orders-service.test.ts index 753a09e..5c51869 100644 --- a/src/lib/server/orders-service.test.ts +++ b/src/lib/server/orders-service.test.ts @@ -3056,3 +3056,90 @@ describe("createOrder delivery-note auto-detection", () => { expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id); }); }); + +describe("createOrderItemForOrder (mid-flight add)", () => { + // Task 2 of the non-invoiceable-order-extras plan — the pending + // gate that previously 409'd when siblings were po_generated is + // removed because adding a new row is independent of any in-flight + // PO's pricing/supplier reference. Three tests below cover the + // contract: (a) the gate is gone, (b) the new flag is authoritative + // for selling_price, (c) omission leaves the row billable. + + it("adds a line to an order whose siblings are po_generated (was previously 409 order_locked)", async () => { + const { createOrderItemForOrder } = await import("./orders-service"); + await pbMock.collection("orders").create({ + id: "o-1", + order_number: "ORD-1", + // Customer + company required by createOrder's lead row — skip + // for this targeted test by using a minimal stub. + }); + await pbMock.collection("order_items").create({ + id: "oi-existing", + order: "o-1", + description: "Bolt", + quantity: 50, + item_status: "po_generated", + }); + + const result = await createOrderItemForOrder("o-1", { + description: "Washer", + quantity: 50, + cost_price: 5, + }); + + expect(result.failed).toEqual([]); + expect(result.created.length).toBe(1); + expect(result.created[0].description).toBe("Washer"); + expect(result.orderLocked).toBe(false); + }); + + it("exclude_from_invoice forces selling_price to 0 even when caller passes a non-zero price", async () => { + const { createOrderItemForOrder } = await import("./orders-service"); + await pbMock.collection("orders").create({ + id: "o-1", + order_number: "ORD-1", + }); + + const result = await createOrderItemForOrder("o-1", { + description: "Nut", + quantity: 50, + cost_price: 10, + markup: 50, // would compute selling_price = 15 without the flag + selling_price: 15, // would persist 15 without the flag + exclude_from_invoice: true, + }); + + expect(result.created[0].selling_price).toBe(0); + expect(result.created[0].markup).toBe(50); // markup stays as-is + + // The flag itself persists on the secondary update — separate + // assertion because the `created[]` return shape doesn't include + // it. + const stored = (store["order_items"] as Map>).get( + result.created[0].id, + ); + expect(stored?.exclude_from_invoice).toBe(true); + expect(stored?.selling_price).toBe(0); + }); + + it("omitting exclude_from_invoice preserves the existing markup-based selling_price calculation", async () => { + const { createOrderItemForOrder } = await import("./orders-service"); + await pbMock.collection("orders").create({ + id: "o-1", + order_number: "ORD-1", + }); + + const result = await createOrderItemForOrder("o-1", { + description: "Bolt", + quantity: 50, + cost_price: 10, + markup: 25, + }); + + expect(result.created[0].selling_price).toBe(12.5); // 10 * 1.25 + const stored = (store["order_items"] as Map>).get( + result.created[0].id, + ); + expect(stored?.exclude_from_invoice).toBeFalsy(); + }); +}); diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 6cd6b76..7724e89 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -3874,24 +3874,51 @@ export interface CreateOrderItemForOrderDraft { unit?: string; supplier?: string; notes?: string; + /** + * Mark this line as supplier-side only — gets a PO, a driver, and + * collection like any other line, but never appears on a customer + * invoice or delivery note. Forces `selling_price` to 0 + * regardless of what the caller passes (the flag is authoritative, + * price is not consulted downstream). + * + * See docs/superpowers/specs/2026-08-30-non-invoiceable-order-extras-design.md + * for the supplier-component-split use case that motivates it. + */ + exclude_from_invoice?: boolean; } /** * Add a new line item to an existing order. * - * **Strict `pending` gate**: refuses with 409 (`order_locked`) if - * any line item on the order is past `item_status === "pending"`. - * Adding a line to an order with a PO in flight would orphan the - * PO's pricing/supplier reference. Operator must split the order - * (use `orders.split`) or finish the existing PO first. + * **No `pending` gate** (removed 2026-08-30). The original gate + * refused with 409 if any sibling line was past `pending`, on the + * theory that adding a row alongside an in-flight PO would orphan + * the PO's pricing/supplier reference. That was a wrong model of + * what adding a row does — the new row is independent: it gets its + * own PO when the operator processes it, and never touches the + * existing PO's supplier / cost / quantity. The real reason to + * refuse used to be that operators had no other way to add extras, + * so they didn't notice the gate until they hit it; the non- + * invoiceable extras feature added the `Add item` affordance on the + * process page (see Task 3), so the use case is now legitimate and + * frequent. + * + * There is a separate, unrelated `nonPending` gate in `setOrderQuote` + * (line ~3744) that prevents changing a quote link after the order + * has been processed. That gate stays — different invariant. * * Mirrors what `createOrder` does for new orders: calls * `createOrderItems` for the primary row, then patches the - * secondary fields (sku/unit/supplier/notes) on success. + * secondary fields (sku/unit/supplier/notes/exclude_from_invoice) + * on success. * * The `selling_price` defaults to `cost_price * (1 + markup/100)` * via `deriveSellingPriceIfOmitted` when omitted. Markup defaults - * to 0 when both `selling_price` and `markup` are absent. + * to 0 when both `selling_price` and `markup` are absent. When + * `exclude_from_invoice` is true, `selling_price` is forced to 0 + * regardless of the caller input — the flag is the source of truth, + * and downstream invoice / delivery-note code keys on it, not on + * the price column. * * Returns `{ created, failed }` like `createOrderItems`. Per-row * failures never abort the whole call. @@ -3924,37 +3951,19 @@ export async function createOrderItemForOrder( throw err; } - // Strict pending gate: refuse if any line item is past `pending`. - // Statuses here mirror `orders.split` — only `pending` items are - // eligible for mutation (adding a line). Anything else would - // orphan an in-flight PO / driver assignment. - const nonPending = await pb - .collection(getOrderItemsCollectionName()) - .getFullList<{ id: string; item_status: string }>({ - filter: `order = "${orderId}" && item_status != "pending"`, - fields: "id,item_status", - batch: 1000, - }); - - if (nonPending.length > 0) { - const err = new Error( - `Cannot add items to order — ${nonPending.length} line item(s) are past 'pending' status. ` + - `Use orders.split to create a continuation order, or finish the existing PO first.`, - ); - (err as Error & { status?: number }).status = 409; - (err as Error & { data?: unknown }).data = { - code: "order_locked", - offendingItemIds: nonPending.map((i) => i.id), - offendingStatuses: [...new Set(nonPending.map((i) => i.item_status))], - }; - throw err; - } - - // Compute selling_price if omitted. + // Compute selling_price. When the line is excluded from invoicing + // the flag wins — a non-zero price from the caller is overridden + // to 0 so the row's invoice/collection status stays consistent + // with what the UI said (the flag itself is also persisted in the + // secondary-field patch below). const costPrice = draft.cost_price ?? 0; const markup = draft.markup ?? 0; - let sellingPrice = draft.selling_price; - if (sellingPrice == null) { + let sellingPrice: number; + if (draft.exclude_from_invoice) { + sellingPrice = 0; + } else if (draft.selling_price != null) { + sellingPrice = draft.selling_price; + } else { sellingPrice = Number((costPrice * (1 + markup / 100)).toFixed(2)); } @@ -3969,7 +3978,8 @@ export async function createOrderItemForOrder( ]); // Patch secondary fields on the newly created row (sku / unit / - // supplier / notes) — same pattern as `createOrder` uses. + // supplier / notes / exclude_from_invoice) — same pattern as + // `createOrder` uses. if (result.created.length > 0) { const newId = result.created[0].id; const secondary: Record = {}; @@ -3977,6 +3987,7 @@ export async function createOrderItemForOrder( if (draft.unit) secondary.unit = draft.unit; if (draft.supplier) secondary.supplier = draft.supplier; if (draft.notes) secondary.notes = draft.notes; + if (draft.exclude_from_invoice) secondary.exclude_from_invoice = true; if (Object.keys(secondary).length > 0) { await pb.collection(getOrderItemsCollectionName()).update(newId, secondary); } @@ -3986,6 +3997,9 @@ export async function createOrderItemForOrder( orderId, created: result.created, failed: result.failed.map((f) => ({ error: f.error })), + // Kept for API compatibility — the field is now always `false` + // because there is no longer a pending gate. Pre-existing callers + // reading this shape continue to compile and read a constant. orderLocked: false, }; }