From f3ff3af7e09db11657b348dd3d58d79df1eea37f Mon Sep 17 00:00:00 2001 From: Kors van Loon Date: Wed, 26 Aug 2026 16:31:31 +0200 Subject: [PATCH 1/4] feat: create an order from a quote POST /{projectKey}/orders/quotes and POST /{projectKey}/me/orders/quotes had no route, so the last step of the B2B quote flow could not be exercised at all. Consumers could unit-test their own orderability guards, but not the conversion itself, the resulting order, or the quote's transition. The guards are the interesting part and are modelled: the quote must be Pending and not past its validTo, both InvalidOperation otherwise, and the draft version is checked against the stored quote so a stale version raises ConcurrentModification. quoteStateToAccepted moves the quote to Accepted as part of creating the order. The order is built from the quote's own line items and prices rather than recalculated, since not re-pricing is the point of converting instead of rebuilding a cart. It references the quote and has origin "Quote". The draft is validated in the repository rather than through a generated zod schema; generating one needs the commercetools-api-reference checkout that scripts/generate-schemas.ts expects. Fixes #412 --- .changeset/order-from-quote.md | 15 ++ src/repositories/order/index.ts | 89 ++++++++++ src/services/my-order.ts | 24 ++- src/services/order-from-quote.test.ts | 227 ++++++++++++++++++++++++++ src/services/order.ts | 28 ++++ 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 .changeset/order-from-quote.md create mode 100644 src/services/order-from-quote.test.ts diff --git a/.changeset/order-from-quote.md b/.changeset/order-from-quote.md new file mode 100644 index 00000000..2c53fc7f --- /dev/null +++ b/.changeset/order-from-quote.md @@ -0,0 +1,15 @@ +--- +"@labdigital/commercetools-mock": minor +--- + +Support creating an order from a quote, on `POST /{projectKey}/orders/quotes` +and `POST /{projectKey}/me/orders/quotes`. + +Turning a quote into an order is the endpoint of the B2B quote flow and was the +one step that could not be exercised. The guards around it are modelled too, +since those are what a consumer wants to assert: the quote must be `Pending` +and not past its `validTo`, a stale `version` raises `ConcurrentModification`, +and `quoteStateToAccepted: true` moves the quote to `Accepted` as part of +creating the order. The order carries the quote's line items and prices — +nothing is re-priced — and references the quote it came from with +`origin: "Quote"`. diff --git a/src/repositories/order/index.ts b/src/repositories/order/index.ts index 9327d1ec..38a5eee9 100644 --- a/src/repositories/order/index.ts +++ b/src/repositories/order/index.ts @@ -7,15 +7,18 @@ import type { Delivery, DuplicateFieldError, GeneralError, + InvalidOperationError, LineItem, LineItemImportDraft, Order, OrderFromCartDraft, + OrderFromQuoteDraft, OrderImportDraft, OrderPagedSearchResponse, OrderSearchRequest, Product, ProductVariant, + Quote, ReferencedResourceNotFoundError, ResourceNotFoundError, ShippingInfo, @@ -42,6 +45,7 @@ import { import type { Writable } from "#src/types.ts"; import type { RepositoryContext } from "../abstract.ts"; import { AbstractResourceRepository, type QueryParams } from "../abstract.ts"; +import { checkConcurrentModification } from "../errors.ts"; import { calculateMoneyTotalCentAmount, createAddress, @@ -145,6 +149,91 @@ export class OrderRepository extends AbstractResourceRepository<"order"> { return await this.saveNew(context, resource); } + async createFromQuote( + context: RepositoryContext, + draft: OrderFromQuoteDraft, + ): Promise { + // Resolving the reference raises ReferencedResourceNotFound when the quote + // does not exist + const quote = await this._storage.getByResourceIdentifier<"quote">( + context.projectKey, + draft.quote, + ); + + checkConcurrentModification(quote.version, draft.version, quote.id); + + if (quote.quoteState !== "Pending") { + throw new CommercetoolsError( + { + code: "InvalidOperation", + message: `The quote with ID '${quote.id}' cannot be ordered because it is in state '${quote.quoteState}'.`, + }, + 400, + ); + } + + if (quote.validTo && new Date(quote.validTo) < new Date()) { + throw new CommercetoolsError( + { + code: "InvalidOperation", + message: `The quote with ID '${quote.id}' cannot be ordered because it expired on ${quote.validTo}.`, + }, + 400, + ); + } + + // Converting a quote reuses its prices; nothing is recalculated + const resource: Writable = { + ...getBaseResourceProperties(context.clientId), + billingAddress: quote.billingAddress, + businessUnit: quote.businessUnit, + country: quote.country, + custom: quote.custom, + customerGroup: quote.customerGroup, + customerId: quote.customer?.id, + customLineItems: quote.customLineItems, + directDiscounts: quote.directDiscounts, + itemShippingAddresses: quote.itemShippingAddresses, + lastMessageSequenceNumber: 0, + lineItems: quote.lineItems, + orderNumber: draft.orderNumber ?? generateRandomString(10), + orderState: draft.orderState ?? "Open", + origin: "Quote", + paymentState: draft.paymentState, + purchaseOrderNumber: quote.purchaseOrderNumber, + quote: { + typeId: "quote", + id: quote.id, + }, + refusedGifts: [], + shipmentState: draft.shipmentState, + shipping: [], + shippingAddress: quote.shippingAddress, + shippingInfo: quote.shippingInfo, + shippingMode: "Single", + store: quote.store, + syncInfo: [], + taxCalculationMode: quote.taxCalculationMode, + taxedPrice: quote.taxedPrice, + taxMode: quote.taxMode, + taxRoundingMode: quote.taxRoundingMode, + totalPrice: createCentPrecisionMoney(quote.totalPrice), + }; + + const order = await this.saveNew(context, resource); + + if (draft.quoteStateToAccepted) { + const accepted = { + ...quote, + quoteState: "Accepted", + version: quote.version + 1, + } as Writable; + await this._storage.add(context.projectKey, "quote", accepted); + } + + return order; + } + async import( context: RepositoryContext, draft: OrderImportDraft, diff --git a/src/services/my-order.ts b/src/services/my-order.ts index b1e30c9c..5aa400b8 100644 --- a/src/services/my-order.ts +++ b/src/services/my-order.ts @@ -1,4 +1,6 @@ -import type { FastifyInstance } from "fastify"; +import type { MyOrderFromQuoteDraft } from "@commercetools/platform-sdk"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { getRepositoryContext } from "../repositories/helpers.ts"; import type { MyOrderRepository } from "../repositories/my-order.ts"; import AbstractService from "./abstract.ts"; @@ -26,6 +28,7 @@ export class MyOrderService extends AbstractService { instance.delete("/orders/:id", this.deleteWithId.bind(this)); + instance.post("/orders/quotes", this.createFromQuote.bind(this)); instance.post("/orders", this.post.bind(this)); instance.post("/orders/:id", this.postWithId.bind(this)); @@ -34,4 +37,23 @@ export class MyOrderService extends AbstractService { { prefix: `/${basePath}` }, ); } + + async createFromQuote( + request: FastifyRequest<{ + Params: Record; + Body: MyOrderFromQuoteDraft; + }>, + reply: FastifyReply, + ) { + const { id, version, quoteStateToAccepted } = request.body; + const resource = await this.repository.createFromQuote( + getRepositoryContext(request), + { + quote: { typeId: "quote", id }, + version, + quoteStateToAccepted, + }, + ); + return reply.status(this.createStatusCode).send(resource); + } } diff --git a/src/services/order-from-quote.test.ts b/src/services/order-from-quote.test.ts new file mode 100644 index 00000000..7c1f6142 --- /dev/null +++ b/src/services/order-from-quote.test.ts @@ -0,0 +1,227 @@ +import type { LineItem, Quote } from "@commercetools/platform-sdk"; +import { afterEach, describe, expect, test } from "vitest"; +import { CommercetoolsMock, getBaseResourceProperties } from "../index.ts"; + +const ctMock = new CommercetoolsMock({ defaultProjectKey: "dummy" }); +const customerId = "8d4d2b1a-6d90-4f0f-9e34-3c1a01e6b3f1"; + +const lineItem = { + id: "0a2a1c4e-1e33-4c19-9c6c-27a9c3f0a9d1", + productId: "3d1f0f7a-6f1c-4f10-9a3c-9a9d0f1b2c33", + name: { en: "Test product" }, + productType: { + typeId: "product-type", + id: "2b3c4d5e-6f70-4819-9a2b-3c4d5e6f7081", + }, + variant: { id: 1, sku: "1337" }, + price: { + id: "5f6e7d8c-9b0a-4c1d-8e2f-3a4b5c6d7e8f", + value: { + type: "centPrecision", + currencyCode: "EUR", + centAmount: 14900, + fractionDigits: 2, + }, + }, + quantity: 2, + totalPrice: { + type: "centPrecision", + currencyCode: "EUR", + centAmount: 29800, + fractionDigits: 2, + }, + discountedPricePerQuantity: [], + taxedPricePortions: [], + perMethodTaxRate: [], + state: [], + priceMode: "Platform", + lineItemMode: "Standard", +} as unknown as LineItem; + +const createQuote = async (overrides: Partial = {}) => { + const quote: Quote = { + ...getBaseResourceProperties(), + key: "quote-1", + quoteState: "Pending", + customer: { typeId: "customer", id: customerId }, + quoteRequest: { + typeId: "quote-request", + id: "0f0f0a3b-6a2f-4a4c-9a05-6a6b0a5f7d3e", + }, + stagedQuote: { + typeId: "staged-quote", + id: "1ac6f1a0-6f4b-4a8e-9f9e-2ac4f6a1b7c2", + }, + lineItems: [lineItem], + customLineItems: [], + taxMode: "Platform", + priceRoundingMode: "HalfEven", + taxRoundingMode: "HalfEven", + taxCalculationMode: "LineItemLevel", + totalPrice: { + type: "centPrecision", + currencyCode: "EUR", + centAmount: 29800, + fractionDigits: 2, + }, + ...overrides, + }; + + await ctMock.project().unsafeAdd("quote", quote); + return quote; +}; + +describe("Order from Quote", () => { + afterEach(() => { + ctMock.clear(); + }); + + test("create order from quote", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + expect(response.statusCode).toBe(201); + + const order = response.json(); + expect(order.quote).toEqual({ typeId: "quote", id: quote.id }); + expect(order.origin).toBe("Quote"); + expect(order.orderState).toBe("Open"); + expect(order.customerId).toBe(customerId); + expect(order.lineItems).toHaveLength(1); + expect(order.totalPrice.centAmount).toBe(29800); + }); + + test("quoteStateToAccepted transitions the quote", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + quoteStateToAccepted: true, + }, + }); + expect(response.statusCode).toBe(201); + + const updated = await ctMock.app.inject({ + method: "GET", + url: `/dummy/quotes/${quote.id}`, + }); + expect(updated.json().quoteState).toBe("Accepted"); + expect(updated.json().version).toBe(quote.version + 1); + }); + + test("the quote is left alone without quoteStateToAccepted", async () => { + const quote = await createQuote(); + + await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + const updated = await ctMock.app.inject({ + method: "GET", + url: `/dummy/quotes/${quote.id}`, + }); + expect(updated.json().quoteState).toBe("Pending"); + }); + + test("a quote that is not pending cannot be ordered", async () => { + const quote = await createQuote({ quoteState: "Declined" }); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidOperation"); + }); + + test("an expired quote cannot be ordered", async () => { + const quote = await createQuote({ validTo: "2020-01-01T00:00:00.000Z" }); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidOperation"); + }); + + test("a stale version is rejected", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: quote.id }, + version: quote.version + 1, + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().errors[0].code).toBe("ConcurrentModification"); + }); + + test("an unknown quote is rejected", async () => { + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { + quote: { typeId: "quote", id: "2c4bb2c1-0f4f-4c1e-9d2f-9a1d3e4b5c6a" }, + version: 1, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("ReferencedResourceNotFound"); + }); + + test("create order from quote via /me", async () => { + const quote = await createQuote(); + + const response = await ctMock.app.inject({ + method: "POST", + url: "/dummy/me/orders/quotes", + payload: { + id: quote.id, + version: quote.version, + quoteStateToAccepted: true, + }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json().quote).toEqual({ typeId: "quote", id: quote.id }); + + const updated = await ctMock.app.inject({ + method: "GET", + url: `/dummy/quotes/${quote.id}`, + }); + expect(updated.json().quoteState).toBe("Accepted"); + }); +}); diff --git a/src/services/order.ts b/src/services/order.ts index 4f8405d0..0505f470 100644 --- a/src/services/order.ts +++ b/src/services/order.ts @@ -1,4 +1,6 @@ import type { + InvalidInputError, + OrderFromQuoteDraft, OrderImportDraft, OrderSearchRequest, ResourceNotFoundError, @@ -22,6 +24,7 @@ export class OrderService extends AbstractService { } extraRoutes(instance: FastifyInstance) { + instance.post("/quotes", this.createFromQuote.bind(this)); instance.post("/import", this.import.bind(this)); instance.post("/search", this.search.bind(this)); instance.get( @@ -30,6 +33,31 @@ export class OrderService extends AbstractService { ); } + async createFromQuote( + request: FastifyRequest<{ + Params: Record; + Body: OrderFromQuoteDraft; + }>, + reply: FastifyReply, + ) { + const draft = request.body; + if (!draft?.quote?.id && !draft?.quote?.key) { + throw new CommercetoolsError( + { + code: "InvalidInput", + message: "Request body does not contain a quote reference.", + }, + 400, + ); + } + + const resource = await this.repository.createFromQuote( + getRepositoryContext(request), + draft, + ); + return reply.status(this.createStatusCode).send(resource); + } + async import( request: FastifyRequest<{ Params: Record; From 0bce2756faa02f852ebc6539f8f31233b971c02d Mon Sep 17 00:00:00 2001 From: Kors van Loon Date: Thu, 27 Aug 2026 13:48:26 +0200 Subject: [PATCH 2/4] feat: validate the order-from-quote drafts with generated schemas The route validated its body by hand, which meant the /orders/quotes draft was the only create endpoint not checked against a zod schema in strict mode. OrderFromQuoteDraft and MyOrderFromQuoteDraft are now generated like every other draft, and both routes validate through them the way AbstractService.post does. Running the generator also picks up the optional fields the spec gained since it last ran; that drift is additive and is included here rather than left to surprise the next person who regenerates. AGENTS.md now describes the workflow, since hand-rolling the validation was the avoidable mistake. --- .changeset/order-from-quote-schemas.md | 11 +++++ AGENTS.md | 4 +- scripts/generate-schemas.ts | 4 ++ src/schemas/generated/business-unit.ts | 4 +- src/schemas/generated/cart.ts | 2 +- src/schemas/generated/common.ts | 46 ++++++++++++++++++++ src/schemas/generated/customer.ts | 2 +- src/schemas/generated/extension.ts | 5 +++ src/schemas/generated/index.ts | 6 ++- src/schemas/generated/inventory-entry.ts | 3 ++ src/schemas/generated/my-order-from-quote.ts | 10 +++++ src/schemas/generated/order-from-quote.ts | 22 ++++++++++ src/schemas/generated/payment.ts | 2 +- src/schemas/generated/review.ts | 2 +- src/schemas/generated/shipping-method.ts | 3 ++ src/schemas/generated/shopping-list.ts | 2 +- src/schemas/generated/standalone-price.ts | 2 +- src/schemas/generated/store.ts | 2 + src/services/my-order.ts | 6 +++ src/services/order-from-quote.test.ts | 29 ++++++++++++ src/services/order.ts | 16 +++---- 21 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 .changeset/order-from-quote-schemas.md create mode 100644 src/schemas/generated/my-order-from-quote.ts create mode 100644 src/schemas/generated/order-from-quote.ts diff --git a/.changeset/order-from-quote-schemas.md b/.changeset/order-from-quote-schemas.md new file mode 100644 index 00000000..609fdb86 --- /dev/null +++ b/.changeset/order-from-quote-schemas.md @@ -0,0 +1,11 @@ +--- +"@labdigital/commercetools-mock": patch +--- + +Regenerate the zod draft schemas from the current commercetools OpenAPI spec. + +Picks up optional fields added upstream since the last run — extension +dependencies, expansion paths and additional context, inventory entry stock +levels and reservation expiry, shipping method stores and carrier, store +storefront URLs — plus the `reservation` and `variant` reference type ids and +the `ReserveOnCart` inventory mode. diff --git a/AGENTS.md b/AGENTS.md index 910efd5c..b9494935 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,4 +6,6 @@ - Always run `pnpm test` after making changes and verify all tests pass before considering your work done. Fix any test failures you introduce. - Always run `pnpm biome check` after making changes and verify it reports no errors before considering your work done. Use `pnpm biome check --write --unsafe` to auto-fix formatting, import ordering, and unused import issues. - Update the changesets after making impactful changes. If your change is a bug fix, add a changeset with type "patch". If your change is a new feature, add a changeset with type "minor". If your change is a breaking change, add a changeset with type "major". Create the files directly, as `pnpm changeset` doesn't work well with agents. -- If you are making API changes, make sure to update the documentation and examples listed in `README.md` \ No newline at end of file +- If you are making API changes, make sure to update the documentation and examples listed in `README.md` +- When you add an endpoint that accepts a new draft type, add that draft to `DRAFT_SCHEMAS` and `DRAFT_FILE_MAP` in `scripts/generate-schemas.ts` and run `pnpm generate:schemas`, then validate the request body against the generated schema (see `AbstractService.post`, which does this for the standard create route). Don't hand-write a schema in `src/schemas/generated/`, and don't hand-roll the validation in the handler. The generator needs the commercetools OpenAPI spec checked out at `../commercetools-api-reference`; run `pnpm biome format --write src/schemas/generated` afterwards, because the generator emits unformatted output. +- Hand-written schemas belong in `src/schemas/`, next to the generated directory. They are for the cases the generator cannot express — for example a union the spec flattens to its base type. \ No newline at end of file diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts index 8525e08d..3cfcc5e1 100644 --- a/scripts/generate-schemas.ts +++ b/scripts/generate-schemas.ts @@ -43,9 +43,11 @@ const DRAFT_SCHEMAS = [ "DiscountGroupDraft", "ExtensionDraft", "InventoryEntryDraft", + "MyOrderFromQuoteDraft", "MyQuoteRequestDraft", "OrderEditDraft", "OrderFromCartDraft", + "OrderFromQuoteDraft", "PaymentDraft", "ProductDraft", "ProductDiscountDraft", @@ -85,9 +87,11 @@ const DRAFT_FILE_MAP: Record = { DiscountGroupDraft: "discount-group", ExtensionDraft: "extension", InventoryEntryDraft: "inventory-entry", + MyOrderFromQuoteDraft: "my-order-from-quote", MyQuoteRequestDraft: "my-quote-request", OrderEditDraft: "order-edit", OrderFromCartDraft: "order-from-cart", + OrderFromQuoteDraft: "order-from-quote", PaymentDraft: "payment", ProductDraft: "product", ProductDiscountDraft: "product-discount", diff --git a/src/schemas/generated/business-unit.ts b/src/schemas/generated/business-unit.ts index 5dcdc942..06c73b72 100644 --- a/src/schemas/generated/business-unit.ts +++ b/src/schemas/generated/business-unit.ts @@ -10,8 +10,10 @@ import { BusinessUnitStatusSchema, BusinessUnitStoreModeSchema, BusinessUnitTypeSchema, - CustomerGroupAssignmentDraftSchema, + CompanyDraftSchema, CustomFieldsDraftSchema, + CustomerGroupAssignmentDraftSchema, + DivisionDraftSchema, StoreResourceIdentifierSchema, } from "./common.ts"; diff --git a/src/schemas/generated/cart.ts b/src/schemas/generated/cart.ts index 8b271c97..9498e2be 100644 --- a/src/schemas/generated/cart.ts +++ b/src/schemas/generated/cart.ts @@ -8,10 +8,10 @@ import { CartOriginSchema, CountryCodeSchema, CurrencyCodeSchema, - CustomerGroupResourceIdentifierSchema, CustomFieldsDraftSchema, CustomLineItemDraftSchema, CustomShippingDraftSchema, + CustomerGroupResourceIdentifierSchema, ExternalTaxRateDraftSchema, InventoryModeSchema, LineItemDraftSchema, diff --git a/src/schemas/generated/common.ts b/src/schemas/generated/common.ts index 4ed2994d..8b6e7609 100644 --- a/src/schemas/generated/common.ts +++ b/src/schemas/generated/common.ts @@ -87,6 +87,7 @@ export const ReferenceTypeIdSchema = z.enum([ "quote-request", "recurrence-policy", "recurring-order", + "reservation", "review", "shipping-method", "shopping-list", @@ -97,6 +98,7 @@ export const ReferenceTypeIdSchema = z.enum([ "subscription", "tax-category", "type", + "variant", "zone", ]); @@ -127,6 +129,7 @@ export const InventoryModeSchema = z.enum([ "None", "TrackOnly", "ReserveOnOrder", + "ReserveOnCart", ]); export const PriceSelectionModeSchema = z.enum(["Fixed", "Dynamic"]); @@ -402,6 +405,7 @@ export const ResourceTypeIdSchema = z.enum([ "product-selection", "product-tailoring", "quote", + "reservation", "review", "recurring-order", "shipping", @@ -1031,6 +1035,25 @@ export const ExtensionTriggerSchema = z.object({ condition: z.string().nullish(), }); +export const ExtensionResourceIdentifierSchema = z + .object({ + typeId: ReferenceTypeIdSchema, + id: z.string().nullish(), + key: z.string().nullish(), + }) + .refine((data) => data.id !== undefined || data.key !== undefined, { + message: "Either 'id' or 'key' must be provided", + }); + +export const ExtensionAdditionalContextDraftSchema = z.object({ + includeOldResource: z.boolean().nullish(), +}); + +export const InventoryEntryStockLevelsSchema = z.object({ + reorderPoint: z.number().int().nullish(), + safetyStock: z.number().int().nullish(), +}); + export const OrderReferenceSchema = z.object({ typeId: ReferenceTypeIdSchema, id: z.string(), @@ -1050,6 +1073,16 @@ export const StateResourceIdentifierSchema = z message: "Either 'id' or 'key' must be provided", }); +export const QuoteResourceIdentifierSchema = z + .object({ + typeId: ReferenceTypeIdSchema, + id: z.string().nullish(), + key: z.string().nullish(), + }) + .refine((data) => data.id !== undefined || data.key !== undefined, { + message: "Either 'id' or 'key' must be provided", + }); + export const PaymentMethodTokenSchema = z.object({ value: z.string(), }); @@ -1311,6 +1344,19 @@ export const ProductSelectionSettingDraftSchema = z.object({ active: z.boolean().nullish(), }); +export const StorefrontSchema = z.object({ + checkoutUrlTemplate: z.string().nullish(), + orderUrlTemplate: z.string().nullish(), + termsOfServiceUrl: z.string().nullish(), + privacyPolicyUrl: z.string().nullish(), + refundPolicyUrl: z.string().nullish(), + shippingPolicyUrl: z.string().nullish(), + cookiePolicyUrl: z.string().nullish(), + imprintUrl: z.string().nullish(), + faqUrl: z.string().nullish(), + contactUrl: z.string().nullish(), +}); + export const DestinationSchema = z.object({ type: z.string(), }); diff --git a/src/schemas/generated/customer.ts b/src/schemas/generated/customer.ts index 3578c2c8..a662f44a 100644 --- a/src/schemas/generated/customer.ts +++ b/src/schemas/generated/customer.ts @@ -6,9 +6,9 @@ import { AuthenticationModeSchema, BaseAddressSchema, CartResourceIdentifierSchema, + CustomFieldsDraftSchema, CustomerGroupAssignmentDraftSchema, CustomerGroupResourceIdentifierSchema, - CustomFieldsDraftSchema, LocaleSchema, StoreResourceIdentifierSchema, } from "./common.ts"; diff --git a/src/schemas/generated/extension.ts b/src/schemas/generated/extension.ts index 1ca0851c..80f0e322 100644 --- a/src/schemas/generated/extension.ts +++ b/src/schemas/generated/extension.ts @@ -3,7 +3,9 @@ import { z } from "zod"; import { + ExtensionAdditionalContextDraftSchema, ExtensionDestinationSchema, + ExtensionResourceIdentifierSchema, ExtensionTriggerSchema, } from "./common.ts"; @@ -12,4 +14,7 @@ export const ExtensionDraftSchema = z.object({ destination: ExtensionDestinationSchema, triggers: z.array(ExtensionTriggerSchema), timeoutInMs: z.number().int().nullish(), + dependencies: z.array(ExtensionResourceIdentifierSchema).nullish(), + expansionPaths: z.array(z.string()).nullish(), + additionalContext: ExtensionAdditionalContextDraftSchema.nullish(), }); diff --git a/src/schemas/generated/index.ts b/src/schemas/generated/index.ts index 8a75d1c0..052a89da 100644 --- a/src/schemas/generated/index.ts +++ b/src/schemas/generated/index.ts @@ -1,6 +1,7 @@ // This file is auto-generated by scripts/generate-schemas.ts // Do not edit manually. +export * from "./common.ts"; export * from "./associate-role.ts"; export * from "./attribute-group.ts"; export * from "./business-unit.ts"; @@ -8,7 +9,6 @@ export * from "./cart.ts"; export * from "./cart-discount.ts"; export * from "./category.ts"; export * from "./channel.ts"; -export * from "./common.ts"; export * from "./custom-object.ts"; export * from "./customer.ts"; export * from "./customer-group.ts"; @@ -16,9 +16,11 @@ export * from "./discount-code.ts"; export * from "./discount-group.ts"; export * from "./extension.ts"; export * from "./inventory-entry.ts"; +export * from "./my-order-from-quote.ts"; export * from "./my-quote-request.ts"; export * from "./order-edit.ts"; export * from "./order-from-cart.ts"; +export * from "./order-from-quote.ts"; export * from "./payment.ts"; export * from "./product.ts"; export * from "./product-discount.ts"; @@ -32,8 +34,8 @@ export * from "./recurring-order.ts"; export * from "./review.ts"; export * from "./shipping-method.ts"; export * from "./shopping-list.ts"; -export * from "./staged-quote.ts"; export * from "./standalone-price.ts"; +export * from "./staged-quote.ts"; export * from "./state.ts"; export * from "./store.ts"; export * from "./subscription.ts"; diff --git a/src/schemas/generated/inventory-entry.ts b/src/schemas/generated/inventory-entry.ts index ab85d69e..29e0436f 100644 --- a/src/schemas/generated/inventory-entry.ts +++ b/src/schemas/generated/inventory-entry.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { ChannelResourceIdentifierSchema, CustomFieldsDraftSchema, + InventoryEntryStockLevelsSchema, } from "./common.ts"; export const InventoryEntryDraftSchema = z.object({ @@ -16,5 +17,7 @@ export const InventoryEntryDraftSchema = z.object({ maxCartQuantity: z.number().int().nullish(), restockableInDays: z.number().int().nullish(), expectedDelivery: z.string().nullish(), + reservationExpirationInMinutes: z.number().int().nullish(), + stockLevels: InventoryEntryStockLevelsSchema.nullish(), custom: CustomFieldsDraftSchema.nullish(), }); diff --git a/src/schemas/generated/my-order-from-quote.ts b/src/schemas/generated/my-order-from-quote.ts new file mode 100644 index 00000000..1a577267 --- /dev/null +++ b/src/schemas/generated/my-order-from-quote.ts @@ -0,0 +1,10 @@ +// This file is auto-generated by scripts/generate-schemas.ts +// Do not edit manually. + +import { z } from "zod"; + +export const MyOrderFromQuoteDraftSchema = z.object({ + id: z.string(), + version: z.number().int(), + quoteStateToAccepted: z.boolean().nullish(), +}); diff --git a/src/schemas/generated/order-from-quote.ts b/src/schemas/generated/order-from-quote.ts new file mode 100644 index 00000000..7a6289c7 --- /dev/null +++ b/src/schemas/generated/order-from-quote.ts @@ -0,0 +1,22 @@ +// This file is auto-generated by scripts/generate-schemas.ts +// Do not edit manually. + +import { z } from "zod"; +import { + OrderStateSchema, + PaymentStateSchema, + QuoteResourceIdentifierSchema, + ShipmentStateSchema, + StateResourceIdentifierSchema, +} from "./common.ts"; + +export const OrderFromQuoteDraftSchema = z.object({ + quote: QuoteResourceIdentifierSchema, + version: z.number().int(), + quoteStateToAccepted: z.boolean().nullish(), + orderNumber: z.string().nullish(), + paymentState: PaymentStateSchema.nullish(), + shipmentState: ShipmentStateSchema.nullish(), + orderState: OrderStateSchema.nullish(), + state: StateResourceIdentifierSchema.nullish(), +}); diff --git a/src/schemas/generated/payment.ts b/src/schemas/generated/payment.ts index 440801a4..94b544db 100644 --- a/src/schemas/generated/payment.ts +++ b/src/schemas/generated/payment.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import { - CustomerResourceIdentifierSchema, CustomFieldsDraftSchema, + CustomerResourceIdentifierSchema, MoneySchema, PaymentMethodInfoDraftSchema, PaymentStatusDraftSchema, diff --git a/src/schemas/generated/review.ts b/src/schemas/generated/review.ts index 6500a8a5..45e7bb1a 100644 --- a/src/schemas/generated/review.ts +++ b/src/schemas/generated/review.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import { - CustomerResourceIdentifierSchema, CustomFieldsDraftSchema, + CustomerResourceIdentifierSchema, LocaleSchema, StateResourceIdentifierSchema, } from "./common.ts"; diff --git a/src/schemas/generated/shipping-method.ts b/src/schemas/generated/shipping-method.ts index 6060d0fb..9875074f 100644 --- a/src/schemas/generated/shipping-method.ts +++ b/src/schemas/generated/shipping-method.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { CustomFieldsDraftSchema, LocalizedStringSchema, + StoreResourceIdentifierSchema, TaxCategoryResourceIdentifierSchema, ZoneRateDraftSchema, } from "./common.ts"; @@ -21,4 +22,6 @@ export const ShippingMethodDraftSchema = z.object({ isDefault: z.boolean(), predicate: z.string().nullish(), custom: CustomFieldsDraftSchema.nullish(), + stores: z.array(StoreResourceIdentifierSchema).nullish(), + carrier: z.string().nullish(), }); diff --git a/src/schemas/generated/shopping-list.ts b/src/schemas/generated/shopping-list.ts index 90acdf8a..0e28bd31 100644 --- a/src/schemas/generated/shopping-list.ts +++ b/src/schemas/generated/shopping-list.ts @@ -4,8 +4,8 @@ import { z } from "zod"; import { BusinessUnitResourceIdentifierSchema, - CustomerResourceIdentifierSchema, CustomFieldsDraftSchema, + CustomerResourceIdentifierSchema, LocalizedStringSchema, ShoppingListLineItemDraftSchema, StoreResourceIdentifierSchema, diff --git a/src/schemas/generated/standalone-price.ts b/src/schemas/generated/standalone-price.ts index 611adafd..c42ebb83 100644 --- a/src/schemas/generated/standalone-price.ts +++ b/src/schemas/generated/standalone-price.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { ChannelResourceIdentifierSchema, CountryCodeSchema, - CustomerGroupResourceIdentifierSchema, CustomFieldsDraftSchema, + CustomerGroupResourceIdentifierSchema, DiscountedPriceDraftSchema, MoneySchema, PriceTierDraftSchema, diff --git a/src/schemas/generated/store.ts b/src/schemas/generated/store.ts index 061c8280..d93fb349 100644 --- a/src/schemas/generated/store.ts +++ b/src/schemas/generated/store.ts @@ -9,6 +9,7 @@ import { LocalizedStringSchema, ProductSelectionSettingDraftSchema, StoreCountrySchema, + StorefrontSchema, } from "./common.ts"; export const StoreDraftSchema = z.object({ @@ -20,4 +21,5 @@ export const StoreDraftSchema = z.object({ supplyChannels: z.array(ChannelResourceIdentifierSchema).nullish(), productSelections: z.array(ProductSelectionSettingDraftSchema).nullish(), custom: CustomFieldsDraftSchema.nullish(), + storefront: StorefrontSchema.nullish(), }); diff --git a/src/services/my-order.ts b/src/services/my-order.ts index 5aa400b8..755b5aac 100644 --- a/src/services/my-order.ts +++ b/src/services/my-order.ts @@ -1,5 +1,7 @@ import type { MyOrderFromQuoteDraft } from "@commercetools/platform-sdk"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { MyOrderFromQuoteDraftSchema } from "#src/schemas/generated/my-order-from-quote.ts"; +import { validateDraft } from "#src/validate.ts"; import { getRepositoryContext } from "../repositories/helpers.ts"; import type { MyOrderRepository } from "../repositories/my-order.ts"; import AbstractService from "./abstract.ts"; @@ -45,6 +47,10 @@ export class MyOrderService extends AbstractService { }>, reply: FastifyReply, ) { + if (this.repository.strict) { + validateDraft(request.body, MyOrderFromQuoteDraftSchema); + } + const { id, version, quoteStateToAccepted } = request.body; const resource = await this.repository.createFromQuote( getRepositoryContext(request), diff --git a/src/services/order-from-quote.test.ts b/src/services/order-from-quote.test.ts index 7c1f6142..d4d87918 100644 --- a/src/services/order-from-quote.test.ts +++ b/src/services/order-from-quote.test.ts @@ -225,3 +225,32 @@ describe("Order from Quote", () => { expect(updated.json().quoteState).toBe("Accepted"); }); }); + +describe("Order from Quote draft validation", () => { + const strictMock = new CommercetoolsMock({ + defaultProjectKey: "dummy", + strict: true, + }); + + test("a draft without a quote reference is rejected", async () => { + const response = await strictMock.app.inject({ + method: "POST", + url: "/dummy/orders/quotes", + payload: { version: 1 }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe("InvalidJsonInput"); + }); + + test("a /me draft without a version is rejected", async () => { + const response = await strictMock.app.inject({ + method: "POST", + url: "/dummy/me/orders/quotes", + payload: { id: "2c4bb2c1-0f4f-4c1e-9d2f-9a1d3e4b5c6a" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].detailedErrorMessage).toContain("version"); + }); +}); diff --git a/src/services/order.ts b/src/services/order.ts index 0505f470..9f9a8027 100644 --- a/src/services/order.ts +++ b/src/services/order.ts @@ -1,5 +1,4 @@ import type { - InvalidInputError, OrderFromQuoteDraft, OrderImportDraft, OrderSearchRequest, @@ -7,6 +6,8 @@ import type { } from "@commercetools/platform-sdk"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { CommercetoolsError } from "#src/exceptions.ts"; +import { OrderFromQuoteDraftSchema } from "#src/schemas/generated/order-from-quote.ts"; +import { validateDraft } from "#src/validate.ts"; import { getRepositoryContext } from "../repositories/helpers.ts"; import type { OrderRepository } from "../repositories/order/index.ts"; import AbstractService from "./abstract.ts"; @@ -40,20 +41,13 @@ export class OrderService extends AbstractService { }>, reply: FastifyReply, ) { - const draft = request.body; - if (!draft?.quote?.id && !draft?.quote?.key) { - throw new CommercetoolsError( - { - code: "InvalidInput", - message: "Request body does not contain a quote reference.", - }, - 400, - ); + if (this.repository.strict) { + validateDraft(request.body, OrderFromQuoteDraftSchema); } const resource = await this.repository.createFromQuote( getRepositoryContext(request), - draft, + request.body, ); return reply.status(this.createStatusCode).send(resource); } From 0176b20fb8efa005f3f2646405e7cb2654c2d72c Mon Sep 17 00:00:00 2001 From: Kors van Loon Date: Thu, 27 Aug 2026 13:51:38 +0200 Subject: [PATCH 3/4] chore: sort imports in the regenerated schemas --- src/schemas/generated/business-unit.ts | 4 +--- src/schemas/generated/cart.ts | 2 +- src/schemas/generated/customer.ts | 2 +- src/schemas/generated/index.ts | 4 ++-- src/schemas/generated/payment.ts | 2 +- src/schemas/generated/review.ts | 2 +- src/schemas/generated/shopping-list.ts | 2 +- src/schemas/generated/standalone-price.ts | 2 +- 8 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/schemas/generated/business-unit.ts b/src/schemas/generated/business-unit.ts index 06c73b72..5dcdc942 100644 --- a/src/schemas/generated/business-unit.ts +++ b/src/schemas/generated/business-unit.ts @@ -10,10 +10,8 @@ import { BusinessUnitStatusSchema, BusinessUnitStoreModeSchema, BusinessUnitTypeSchema, - CompanyDraftSchema, - CustomFieldsDraftSchema, CustomerGroupAssignmentDraftSchema, - DivisionDraftSchema, + CustomFieldsDraftSchema, StoreResourceIdentifierSchema, } from "./common.ts"; diff --git a/src/schemas/generated/cart.ts b/src/schemas/generated/cart.ts index 9498e2be..8b271c97 100644 --- a/src/schemas/generated/cart.ts +++ b/src/schemas/generated/cart.ts @@ -8,10 +8,10 @@ import { CartOriginSchema, CountryCodeSchema, CurrencyCodeSchema, + CustomerGroupResourceIdentifierSchema, CustomFieldsDraftSchema, CustomLineItemDraftSchema, CustomShippingDraftSchema, - CustomerGroupResourceIdentifierSchema, ExternalTaxRateDraftSchema, InventoryModeSchema, LineItemDraftSchema, diff --git a/src/schemas/generated/customer.ts b/src/schemas/generated/customer.ts index a662f44a..3578c2c8 100644 --- a/src/schemas/generated/customer.ts +++ b/src/schemas/generated/customer.ts @@ -6,9 +6,9 @@ import { AuthenticationModeSchema, BaseAddressSchema, CartResourceIdentifierSchema, - CustomFieldsDraftSchema, CustomerGroupAssignmentDraftSchema, CustomerGroupResourceIdentifierSchema, + CustomFieldsDraftSchema, LocaleSchema, StoreResourceIdentifierSchema, } from "./common.ts"; diff --git a/src/schemas/generated/index.ts b/src/schemas/generated/index.ts index 052a89da..38672e2f 100644 --- a/src/schemas/generated/index.ts +++ b/src/schemas/generated/index.ts @@ -1,7 +1,6 @@ // This file is auto-generated by scripts/generate-schemas.ts // Do not edit manually. -export * from "./common.ts"; export * from "./associate-role.ts"; export * from "./attribute-group.ts"; export * from "./business-unit.ts"; @@ -9,6 +8,7 @@ export * from "./cart.ts"; export * from "./cart-discount.ts"; export * from "./category.ts"; export * from "./channel.ts"; +export * from "./common.ts"; export * from "./custom-object.ts"; export * from "./customer.ts"; export * from "./customer-group.ts"; @@ -34,8 +34,8 @@ export * from "./recurring-order.ts"; export * from "./review.ts"; export * from "./shipping-method.ts"; export * from "./shopping-list.ts"; -export * from "./standalone-price.ts"; export * from "./staged-quote.ts"; +export * from "./standalone-price.ts"; export * from "./state.ts"; export * from "./store.ts"; export * from "./subscription.ts"; diff --git a/src/schemas/generated/payment.ts b/src/schemas/generated/payment.ts index 94b544db..440801a4 100644 --- a/src/schemas/generated/payment.ts +++ b/src/schemas/generated/payment.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import { - CustomFieldsDraftSchema, CustomerResourceIdentifierSchema, + CustomFieldsDraftSchema, MoneySchema, PaymentMethodInfoDraftSchema, PaymentStatusDraftSchema, diff --git a/src/schemas/generated/review.ts b/src/schemas/generated/review.ts index 45e7bb1a..6500a8a5 100644 --- a/src/schemas/generated/review.ts +++ b/src/schemas/generated/review.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import { - CustomFieldsDraftSchema, CustomerResourceIdentifierSchema, + CustomFieldsDraftSchema, LocaleSchema, StateResourceIdentifierSchema, } from "./common.ts"; diff --git a/src/schemas/generated/shopping-list.ts b/src/schemas/generated/shopping-list.ts index 0e28bd31..90acdf8a 100644 --- a/src/schemas/generated/shopping-list.ts +++ b/src/schemas/generated/shopping-list.ts @@ -4,8 +4,8 @@ import { z } from "zod"; import { BusinessUnitResourceIdentifierSchema, - CustomFieldsDraftSchema, CustomerResourceIdentifierSchema, + CustomFieldsDraftSchema, LocalizedStringSchema, ShoppingListLineItemDraftSchema, StoreResourceIdentifierSchema, diff --git a/src/schemas/generated/standalone-price.ts b/src/schemas/generated/standalone-price.ts index c42ebb83..611adafd 100644 --- a/src/schemas/generated/standalone-price.ts +++ b/src/schemas/generated/standalone-price.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { ChannelResourceIdentifierSchema, CountryCodeSchema, - CustomFieldsDraftSchema, CustomerGroupResourceIdentifierSchema, + CustomFieldsDraftSchema, DiscountedPriceDraftSchema, MoneySchema, PriceTierDraftSchema, From 0c6462b9334390fce0e46089bf6defb4b74e3179 Mon Sep 17 00:00:00 2001 From: Kors van Loon Date: Thu, 27 Aug 2026 13:51:59 +0200 Subject: [PATCH 4/4] docs: name the right formatting command for generated schemas --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b9494935..2a6b1904 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,5 +7,5 @@ - Always run `pnpm biome check` after making changes and verify it reports no errors before considering your work done. Use `pnpm biome check --write --unsafe` to auto-fix formatting, import ordering, and unused import issues. - Update the changesets after making impactful changes. If your change is a bug fix, add a changeset with type "patch". If your change is a new feature, add a changeset with type "minor". If your change is a breaking change, add a changeset with type "major". Create the files directly, as `pnpm changeset` doesn't work well with agents. - If you are making API changes, make sure to update the documentation and examples listed in `README.md` -- When you add an endpoint that accepts a new draft type, add that draft to `DRAFT_SCHEMAS` and `DRAFT_FILE_MAP` in `scripts/generate-schemas.ts` and run `pnpm generate:schemas`, then validate the request body against the generated schema (see `AbstractService.post`, which does this for the standard create route). Don't hand-write a schema in `src/schemas/generated/`, and don't hand-roll the validation in the handler. The generator needs the commercetools OpenAPI spec checked out at `../commercetools-api-reference`; run `pnpm biome format --write src/schemas/generated` afterwards, because the generator emits unformatted output. +- When you add an endpoint that accepts a new draft type, add that draft to `DRAFT_SCHEMAS` and `DRAFT_FILE_MAP` in `scripts/generate-schemas.ts` and run `pnpm generate:schemas`, then validate the request body against the generated schema (see `AbstractService.post`, which does this for the standard create route). Don't hand-write a schema in `src/schemas/generated/`, and don't hand-roll the validation in the handler. The generator needs the commercetools OpenAPI spec checked out at `../commercetools-api-reference`; run `pnpm biome check --write --unsafe src/schemas/generated` afterwards, because the generator emits unformatted output with unsorted imports. - Hand-written schemas belong in `src/schemas/`, next to the generated directory. They are for the cases the generator cannot express — for example a union the spec flattens to its base type. \ No newline at end of file