feat(quotes): finish createQuote refactor for operator-supplied quote_number

The /api/v1/quotes/create route was wired to accept operator-supplied
quote_number + rfq_number + quote_notes + notes + status, but the
service-layer side never matched: QuoteNumberExistsError didn't exist
and CreateQuoteInput ignored the new fields. Production deploy was
failing on `Module has no exported member 'QuoteNumberExistsError'`.

Service changes:
- New QuoteNumberExistsError class (mirrors OrderNumberCollisionError):
  carries existingId + attemptedNumber, mapped to 409 by the route
- CreateQuoteInput extended with quote_number, rfq_number, quote_notes,
  notes, status; imported QuoteStatus from @/lib/types
- createQuote now uses input.quote_number verbatim when supplied, else
  falls back to the existing atomic-counter generator
- createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE
  constraint violation on quote_number is re-thrown as the typed error
  with the colliding row's id resolved via a follow-up getList

New route test file (9 cases) covers happy path with supplied
quote_number, the 409 collision branch, notes/quote_notes fallback
merge, auth-gate failures, missing customer/items validation, and
generic 500 fallback. Uses importOriginal so the real
QuoteNumberExistsError class survives the service-layer mock.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-27 04:19:51 +02:00
parent 1717d62980
commit d11a1848e8
2 changed files with 278 additions and 19 deletions

View file

@ -0,0 +1,183 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
// Mock the service layer so we can drive createQuote's success /
// 409 paths without touching PB. The route is a thin wrapper; the
// service-side branches are exercised here by what the mock returns.
//
// We use `importOriginal` so the real `QuoteNumberExistsError` class
// stays exported — the route catches `instanceof QuoteNumberExistsError`
// to map the typed error → 409. Without it, `instanceof` always fails
// and we'd 500 instead of 409.
const createQuoteMock = vi.fn();
vi.mock("@/lib/server/quotes-service", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/server/quotes-service")>();
return {
...actual,
createQuote: (...args: unknown[]) => createQuoteMock(...args),
};
});
// assertAgentAuth reads AGENT_API_KEY at call time, so we set it
// in beforeEach. The matching Bearer header is added on each request.
const AGENT_KEY = "test-agent-key";
import { POST } from "./route";
function makeReq(body: unknown) {
return new NextRequest("http://localhost/api/v1/quotes/create", {
method: "POST",
body: JSON.stringify(body),
headers: {
"content-type": "application/json",
authorization: `Bearer ${AGENT_KEY}`,
},
});
}
const baseBody = {
customer: "cust-1",
company: "co-1",
description: "Walk-in for John",
validity_days: 30,
rfq_number: "RFQ-2026-014",
items: [{ description: "Bearing 6205", quantity: 2 }],
};
beforeEach(() => {
createQuoteMock.mockReset();
process.env.AGENT_API_KEY = AGENT_KEY;
});
describe("POST /api/v1/quotes/create", () => {
it("forwards the operator-supplied quote_number to the service and returns 201", async () => {
createQuoteMock.mockResolvedValueOnce({
quote: { id: "q-new", quote_number: "Q-2026-08-007" },
customer: { id: "cust-1", customer: "John" },
company: { id: "co-1", company: "Acme" },
items: [{ id: "qi-1" }],
quote_number: "Q-2026-08-007",
});
const res = await POST(
makeReq({
...baseBody,
quote_number: "Q-2026-08-007",
quote_notes: "Why we cannot quote: out of stock",
}),
);
expect(res.status).toBe(201);
const passed = createQuoteMock.mock.calls[0][0];
expect(passed.quote_number).toBe("Q-2026-08-007");
expect(passed.rfq_number).toBe("RFQ-2026-014");
expect(passed.quote_notes).toBe("Why we cannot quote: out of stock");
expect(passed.items).toHaveLength(1);
const body = await res.json();
expect(body.quote_number).toBe("Q-2026-08-007");
});
it("returns 409 with existing_id when the service throws QuoteNumberExistsError", async () => {
const { QuoteNumberExistsError } = await import(
"@/lib/server/quotes-service"
);
createQuoteMock.mockRejectedValueOnce(
new QuoteNumberExistsError("q-existing-15", "Q-2026-08-007"),
);
const res = await POST(
makeReq({ ...baseBody, quote_number: "Q-2026-08-007" }),
);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.existing_id).toBe("q-existing-15");
expect(body.code).toBe("quote_number_exists");
expect(body.error).toMatch(/already in use/i);
});
it("falls back from notes → quote_notes (per the route's merge rule)", async () => {
createQuoteMock.mockResolvedValueOnce({
quote: { id: "q-new", quote_number: "Q-2026-08-008" },
customer: { id: "cust-1" },
company: null,
items: [],
quote_number: "Q-2026-08-008",
});
const res = await POST(makeReq({ ...baseBody, notes: "free-text note" }));
expect(res.status).toBe(201);
const passed = createQuoteMock.mock.calls[0][0];
// `notes` should map to `quote_notes` when only `notes` is supplied.
expect(passed.quote_notes).toBe("free-text note");
});
it("prefers quote_notes over notes when both are supplied", async () => {
createQuoteMock.mockResolvedValueOnce({
quote: { id: "q-new", quote_number: "Q-2026-08-009" },
customer: { id: "cust-1" },
company: null,
items: [],
quote_number: "Q-2026-08-009",
});
const res = await POST(
makeReq({
...baseBody,
notes: "fallback value",
quote_notes: "primary value",
}),
);
expect(res.status).toBe(201);
const passed = createQuoteMock.mock.calls[0][0];
expect(passed.quote_notes).toBe("primary value");
});
it("returns 401 when the Authorization header is missing", async () => {
const req = new NextRequest("http://localhost/api/v1/quotes/create", {
method: "POST",
body: JSON.stringify(baseBody),
headers: { "content-type": "application/json" },
});
const res = await POST(req);
expect(res.status).toBe(401);
expect(createQuoteMock).not.toHaveBeenCalled();
});
it("returns 400 when the body is not JSON", async () => {
const req = new NextRequest("http://localhost/api/v1/quotes/create", {
method: "POST",
body: "not-json",
headers: {
"content-type": "application/json",
authorization: `Bearer ${AGENT_KEY}`,
},
});
const res = await POST(req);
expect(res.status).toBe(400);
expect(createQuoteMock).not.toHaveBeenCalled();
});
it("returns 400 when customer is missing", async () => {
const res = await POST(makeReq({ ...baseBody, customer: "" }));
expect(res.status).toBe(400);
expect(createQuoteMock).not.toHaveBeenCalled();
});
it("returns 400 when items is empty", async () => {
const res = await POST(makeReq({ ...baseBody, items: [] }));
expect(res.status).toBe(400);
expect(createQuoteMock).not.toHaveBeenCalled();
});
it("returns 500 on a generic service error", async () => {
createQuoteMock.mockRejectedValueOnce(new Error("boom"));
const res = await POST(makeReq(baseBody));
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/boom/);
});
});

View file

@ -5,7 +5,7 @@ import {
PLACEHOLDER_CUSTOMER_IDS,
PLACEHOLDER_CUSTOMER_NAMES,
} from "@/lib/placeholder-customers";
import type { Quote, QuoteItem, Customer, Company } from "@/lib/types";
import type { Quote, QuoteItem, Customer, Company, QuoteStatus } from "@/lib/types";
/**
* Server-side quote pipeline used by the MCP layer (n8n) and internal API routes.
@ -2583,9 +2583,51 @@ export interface CreateQuoteInput {
items: CreateQuoteInputItem[];
/** Optional override — defaults to validity_days=30. */
validity_days?: number;
/** Optional operator-supplied quote number (e.g. for re-quoting the
* same customer under a stable reference). If omitted, the server
* generates a fresh number from the atomic counter. Collisions with
* an existing `quotes.quote_number` throw `QuoteNumberExistsError`
* (caught at the REST layer → 409). */
quote_number?: string;
/** Customer-side reference (their RFQ number, PO reference, etc.).
* Persists to `quotes.rfq_number`. */
rfq_number?: string;
/** Operator-facing free-text note rendered on the customer PDF
* (distinct from the `notes` audit-log field). Persists to
* `quotes.quote_notes`. */
quote_notes?: string;
/** Notes string (the audit-log field). Most callers should leave this
* empty on create — the rejection pipeline writes here. Walk-in
* creation accepts it for completeness. */
notes?: string;
/** Optional status override — defaults to "new". The full QuoteStatus
* union is accepted; callers that want a stricter surface (e.g. the
* REST route) validate the subset they expose. */
status?: QuoteStatus;
is_no_quote?: boolean;
}
/**
* Thrown by `createQuote` when the operator-supplied `quote_number`
* collides with an existing quote. Maps to HTTP 409 at the REST layer
* (`/api/v1/quotes/create`). The `existingId` field carries the
* colliding row's PB id so the client can deep-link to it.
*
* Mirrors `OrderNumberCollisionError` (orders-service.ts).
*/
export class QuoteNumberExistsError extends Error {
readonly existingId: string;
readonly attemptedNumber: string;
constructor(existingId: string, attemptedNumber: string) {
super(
`Quote number "${attemptedNumber}" is already in use by another quote.`,
);
this.name = "QuoteNumberExistsError";
this.existingId = existingId;
this.attemptedNumber = attemptedNumber;
}
}
export interface CreateQuoteResult {
quote: Quote;
customer: Customer;
@ -2646,24 +2688,58 @@ export async function createQuote(
);
}
// ── 3. Generate a quote number.
const quoteNumber = await generateQuoteNumberServer();
// ── 3. Resolve quote number. The operator may supply one (e.g. for a
// re-quote with a stable customer-side reference); otherwise we
// generate a fresh one from the atomic counter.
const operatorSuppliedNumber =
typeof input.quote_number === "string" && input.quote_number.trim()
? input.quote_number.trim()
: null;
const quoteNumber = operatorSuppliedNumber ?? (await generateQuoteNumberServer());
// ── 4. Create the quote row.
const quote = await pb.collection(QUOTES_COLLECTION).create({
quote_number: quoteNumber,
customer: customer.id,
company: company?.id || null,
description: input.description || "",
status: "new",
notes: "",
validity_days: input.validity_days ?? QUOTE_VALID_DAYS,
company_letterhead: input.letterhead || "CNL",
has_attachments: false,
email_subject: input.description || "",
email_body: "",
is_no_quote: input.is_no_quote,
});
// ── 4. Create the quote row. PB enforces a unique index on
// `quotes.quote_number`; the try/catch below maps the DB-level
// UNIQUE violation back to the typed `QuoteNumberExistsError` so the
// REST layer can respond 409 with the colliding row's id.
// The let + try/catch shape means the type must be explicit; we
// mirror the inference the const-form would have produced (the
// generic RecordModel cast as `Quote` at the return point below).
let quote: Record<string, unknown> & { id: string };
try {
quote = await pb.collection(QUOTES_COLLECTION).create({
quote_number: quoteNumber,
customer: customer.id,
company: company?.id || null,
description: input.description || "",
status: input.status ?? "new",
notes: input.notes ?? "",
rfq_number: input.rfq_number ?? "",
quote_notes: input.quote_notes ?? "",
validity_days: input.validity_days ?? QUOTE_VALID_DAYS,
company_letterhead: input.letterhead || "CNL",
has_attachments: false,
email_subject: input.description || "",
email_body: "",
is_no_quote: input.is_no_quote,
});
} catch (err) {
// Race-safe: even when the operator didn't supply a number, the
// generated one could theoretically collide if the counter
// implementation changes. Catch the unique-constraint violation,
// re-query for the existing row's id, and re-throw typed.
if (err && /UNIQUE constraint failed/i.test(String(err))) {
const escaped = quoteNumber.replace(/"/g, '\\"');
const conflict = await pb
.collection(QUOTES_COLLECTION)
.getList<{ id: string }>(1, 1, {
filter: `quote_number = "${escaped}"`,
});
if (conflict.items[0]) {
throw new QuoteNumberExistsError(conflict.items[0].id, quoteNumber);
}
}
throw err;
}
// ── 5. Create the line items via the shared service layer so the
// F.1 auto-derive + F.2 whitelist apply unchanged.
@ -2687,7 +2763,7 @@ export async function createQuote(
}
return {
quote: quote as Quote,
quote: quote as unknown as Quote,
customer,
company: company as Customer | null,
items: createdItems,