Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/order-from-quote-schemas.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions .changeset/order-from-quote.md
Original file line number Diff line number Diff line change
@@ -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"`.
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
- 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 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.
4 changes: 4 additions & 0 deletions scripts/generate-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ const DRAFT_SCHEMAS = [
"DiscountGroupDraft",
"ExtensionDraft",
"InventoryEntryDraft",
"MyOrderFromQuoteDraft",
"MyQuoteRequestDraft",
"OrderEditDraft",
"OrderFromCartDraft",
"OrderFromQuoteDraft",
"PaymentDraft",
"ProductDraft",
"ProductDiscountDraft",
Expand Down Expand Up @@ -85,9 +87,11 @@ const DRAFT_FILE_MAP: Record<string, string> = {
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",
Expand Down
89 changes: 89 additions & 0 deletions src/repositories/order/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import type {
Delivery,
DuplicateFieldError,
GeneralError,
InvalidOperationError,
LineItem,
LineItemImportDraft,
Order,
OrderFromCartDraft,
OrderFromQuoteDraft,
OrderImportDraft,
OrderPagedSearchResponse,
OrderSearchRequest,
Product,
ProductVariant,
Quote,
ReferencedResourceNotFoundError,
ResourceNotFoundError,
ShippingInfo,
Expand All @@ -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,
Expand Down Expand Up @@ -145,6 +149,91 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
return await this.saveNew(context, resource);
}

async createFromQuote(
context: RepositoryContext,
draft: OrderFromQuoteDraft,
): Promise<Order> {
// 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<InvalidOperationError>(
{
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<InvalidOperationError>(
{
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<Order> = {
...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<Quote>;
await this._storage.add(context.projectKey, "quote", accepted);
}

return order;
}

async import(
context: RepositoryContext,
draft: OrderImportDraft,
Expand Down
46 changes: 46 additions & 0 deletions src/schemas/generated/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const ReferenceTypeIdSchema = z.enum([
"quote-request",
"recurrence-policy",
"recurring-order",
"reservation",
"review",
"shipping-method",
"shopping-list",
Expand All @@ -97,6 +98,7 @@ export const ReferenceTypeIdSchema = z.enum([
"subscription",
"tax-category",
"type",
"variant",
"zone",
]);

Expand Down Expand Up @@ -127,6 +129,7 @@ export const InventoryModeSchema = z.enum([
"None",
"TrackOnly",
"ReserveOnOrder",
"ReserveOnCart",
]);

export const PriceSelectionModeSchema = z.enum(["Fixed", "Dynamic"]);
Expand Down Expand Up @@ -402,6 +405,7 @@ export const ResourceTypeIdSchema = z.enum([
"product-selection",
"product-tailoring",
"quote",
"reservation",
"review",
"recurring-order",
"shipping",
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
});
Expand Down Expand Up @@ -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(),
});
Expand Down
5 changes: 5 additions & 0 deletions src/schemas/generated/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

import { z } from "zod";
import {
ExtensionAdditionalContextDraftSchema,
ExtensionDestinationSchema,
ExtensionResourceIdentifierSchema,
ExtensionTriggerSchema,
} from "./common.ts";

Expand All @@ -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(),
});
2 changes: 2 additions & 0 deletions src/schemas/generated/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions src/schemas/generated/inventory-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from "zod";
import {
ChannelResourceIdentifierSchema,
CustomFieldsDraftSchema,
InventoryEntryStockLevelsSchema,
} from "./common.ts";

export const InventoryEntryDraftSchema = z.object({
Expand All @@ -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(),
});
10 changes: 10 additions & 0 deletions src/schemas/generated/my-order-from-quote.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
22 changes: 22 additions & 0 deletions src/schemas/generated/order-from-quote.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
3 changes: 3 additions & 0 deletions src/schemas/generated/shipping-method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from "zod";
import {
CustomFieldsDraftSchema,
LocalizedStringSchema,
StoreResourceIdentifierSchema,
TaxCategoryResourceIdentifierSchema,
ZoneRateDraftSchema,
} from "./common.ts";
Expand All @@ -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(),
});
2 changes: 2 additions & 0 deletions src/schemas/generated/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
LocalizedStringSchema,
ProductSelectionSettingDraftSchema,
StoreCountrySchema,
StorefrontSchema,
} from "./common.ts";

export const StoreDraftSchema = z.object({
Expand All @@ -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(),
});
Loading
Loading