Quantity limits for Line Items
minCartQuantity and maxCartQuantity fields, allowing for more granular control over purchase behavior directly at the inventory level.Why use this?
- Wholesale/B2B: enforce minimum order quantities for specific items.
- Inventory protection: prevent "denial of inventory" attacks where one user locks up all available stock in their cart.
- Limited releases: limit high-demand items (for example, "Max two per customer") to ensure fair distribution.
Let’s do a quick test and set a quantity limit for the Sweet Pea Candle (SKU: SPC-01) where each customer has to buy at least four of them but not more than 20 and see what happens when we try to only add two of them to our Cart.
Of course, to update an inventory entry, we have to find it first. To keep things simple, let’s pick the inventory entry that is not bound to any specific supply channel:
// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.
/**
* Updates the min and max quantity for a specific SKU.
*/
async function setMinMaxQuantityForSku(
sku: string,
min: number,
max: number
): Promise<any> {
const response = await apiRoot
.inventory()
.get({ queryArgs: { where: `sku="${sku}" and supplyChannel is not defined` } })
.execute();
const entry = response.body.results[0];
return apiRoot
.inventory()
.withId({ ID: entry.id })
.post({
body: {
version: entry.version,
actions: [
{
action: "setInventoryLimits",
minCartQuantity: min,
maxCartQuantity: max,
},
],
},
})
.execute();
}
/**
* Creates a Cart with the specified SKU.
*/
async function createCart(sku: string, quantity: number): Promise<any> {
return apiRoot
.carts()
.post({
body: {
currency: "EUR",
country: "DE",
lineItems: [{ sku, quantity }],
},
})
.execute();
}
// Execute the logic
async function run() {
const targetSku = "SPC-01";
try {
// 1. Set Limits
await setMinMaxQuantityForSku(targetSku, 4, 20);
console.log(`Limits updated! Min: 4, Max: 20`);
// 2. Create Cart with quantity (this will fail as 2 < 4)
const cartResponse = await createCart(targetSku, 2);
console.log(
"Cart created successfully!",
JSON.stringify(cartResponse.body.lineItems, null, 2)
);
} catch (error: any) {
// --- READABLE ERROR HANDLING ---
console.error("\nREQUEST FAILED");
if (error.body && error.body.errors) {
console.error(`Status Code: ${error.statusCode}`);
error.body.errors.forEach((err: any) => {
console.error(`Field: ${err.code} | Message: ${err.message}`);
});
} else {
console.error("System Error:", error.message);
}
}
}
run();
REQUEST FAILED
Status Code: 400
Field: LineItemQuantityBelowLimit | Message: Quantity '2' less than minimum '4'.
Looks like everything worked as expected and we couldn't add the Sweet Pea Candle to our Cart in a quantity that doesn't fall within the allowed range.
removeLineItem update action. Specifying 0 will now throw a “400 Bad Request error”. If you omit the quantity field entirely, it still removes the whole line item, which brings this validation in line with the Shopping Lists API.Configurable price rounding modes for Carts
roundingMode field, available as a Project setting and on the following resources: Cart, CartDraft, Order, OrderImportDraft, Quote, and QuoteRequest.This feature allows you to precisely define how rounding is applied to line item totals, tax calculations, and discount applications, ensuring consistency with your accounting requirements.
The supported modes are:
HalfEven: the classic commercetools behavior (Default).HalfUp: rounds to the "nearest neighbor," rounding up if both neighbors are equidistant.HalfDown: rounds to the "nearest neighbor," rounding down if both neighbors are equidistant.
Let’s create three Carts containing the same items:
- Evergreen Candle (SKU: EC-0993)
- Wine Bottle Opener (SKU: WOP-09)
- Ice Bucket (SKU: BUCK-023)
- Willow Teapot (SKU: WTP-09)
priceRoundingMode set for each:// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.
const SKUS = ["EC-0993", "WOP-09", "WTP-09", "BUCK-023"];
const CURRENCY = "EUR";
const COUNTRY = "DE";
type RoundingMode = "HalfEven" | "HalfUp" | "HalfDown";
/**
* Creates a Cart with the given rounding mode and predefined SKUs.
*/
async function createCart(roundingMode: RoundingMode): Promise<any> {
return apiRoot
.carts()
.post({
body: {
currency: CURRENCY,
country: COUNTRY,
priceRoundingMode: roundingMode,
lineItems: SKUS.map((sku) => ({ sku })),
},
})
.execute();
}
function formatMoney(centAmount: number): string {
return `${(centAmount / 100).toFixed(2)} ${CURRENCY}`;
}
// Execute the demo
async function run(): Promise<void> {
const modes: RoundingMode[] = ["HalfEven", "HalfUp", "HalfDown"];
console.log("=== Price Rounding Modes Demo ===\n");
try {
for (const mode of modes) {
console.log(`Creating cart with rounding mode: ${mode} ...`);
const response = await createCart(mode);
const cart = response.body;
console.log(
`✓ Cart created | Mode: ${mode} | ID: ${cart.id} | Total: ${formatMoney(
cart.totalPrice.centAmount
)}`
);
}
} catch (error: any) {
console.error("\nCART CREATION FAILED");
if (error.body && error.body.errors) {
console.error(`Status Code: ${error.statusCode}`);
error.body.errors.forEach((err: any) => {
console.error(`Field: ${err.code} | Message: ${err.message}`);
});
} else {
console.error("System Error:", error.message);
}
}
}
run();
=== Price Rounding Modes Demo ===
Creating cart with rounding mode: HalfEven ...
✓ Cart created | Mode: HalfEven | ID: 837e6b2b-4a71-426b-b620-2c71bfb79d8c | Total: 15.76 EUR
Creating cart with rounding mode: HalfUp ...
✓ Cart created | Mode: HalfUp | ID: d6f8dcc7-8bc9-40fb-8746-74ff13558e3e | Total: 15.76 EUR
Creating cart with rounding mode: HalfDown ...
✓ Cart created | Mode: HalfDown | ID: d9e1cca8-7d70-462e-b031-eba493903987 | Total: 15.75 EUR
The result clearly shows that the chosen rounding mode can lead to minor variations in the final results. Ultimately, choosing the right price rounding mode is key to ensuring that your price calculations are consistent and reliable across all your integrated systems.
Cart merging for externally authenticated Customers
We've all run into this: a customer shops on their phone as a "Guest," adds a few things to their cart, and then finally logs in. Poof! The items vanish, or even worse, they wipe out the stuff the customer had saved in their account last week.
This "Cart Merging" scenario is a classic in commerce.
Previously, the automatic "Merge Cart" logic was tightly coupled with the commercetools native password flow.
/login endpoint (our native auth), you could just pass the anonymousCartId, and the platform would smoothly merge it with the customer's existing Cart.// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.
// ID of a customer belonging to our project
const CUSTOMER_ID = "46fbc1a6-fd3c-424c-bbf5-096ab5da0f21";
/**
* Creates a customer Cart with a single Line Item.
*/
async function createCustomerCart(sku: string, quantity: number): Promise<any> {
return apiRoot
.carts()
.post({
body: {
currency: "EUR",
country: "DE",
customerId: CUSTOMER_ID,
lineItems: [{ sku, quantity }],
},
})
.execute();
}
/**
* Creates an anonymous Cart with a single Line Item.
*/
async function createAnonymousCart(
sku: string,
quantity: number
): Promise<any> {
return apiRoot
.carts()
.post({
body: {
currency: "EUR",
country: "DE",
lineItems: [{ sku, quantity }],
},
})
.execute();
}
/**
* Merges an anonymous Cart into a customer Cart.
*/
async function mergeCartsForCustomer(anonymousCartId: string): Promise<any> {
return apiRoot
.carts()
.customerIdWithCustomerIdValueMerge({ customerId: CUSTOMER_ID })
.post({
body: {
anonymousCart: { typeId: "cart", id: anonymousCartId },
mergeMode: "MergeWithExistingCustomerCart",
updateProductData: false,
},
})
.execute();
}
/**
* Prints a formatted summary of the Cart merge operation.
*/
function printMergeSummary(
customerCartId: string,
anonymousCartId: string,
mergedCart: any
): void {
console.log("=== Final Result ===");
console.log(`Customer Cart ID: ${customerCartId}`);
console.log(`Anonymous Cart ID: ${anonymousCartId}`);
console.log(`\nMerged Cart ID: ${mergedCart.id}`);
console.log(`Total Quantity: ${mergedCart.totalLineItemQuantity}`);
console.log("\nLine Items in Merged Cart:");
mergedCart.lineItems.forEach((item: any, index: number) => {
console.log(
` ${index + 1}. Product: ${item.productKey}, Quantity: ${
item.quantity
}, SKU: ${item.variant.sku || "N/A"}`
);
});
}
// Execute the logic
async function run() {
try {
console.log("=== Cart Merge Demo ===\n");
// Step 1: Create customer Cart
console.log("Step 1: Creating customer cart...");
const customerCartResponse = await createCustomerCart("CST-01", 2);
const customerCartId = customerCartResponse.body.id;
console.log(`✓ Customer cart created with ID: ${customerCartId}\n`);
// Step 2: Create anonymous Cart
console.log("Step 2: Creating anonymous cart...");
const anonymousCartResponse = await createAnonymousCart("CDG-09", 1);
const anonymousCartId = anonymousCartResponse.body.id;
console.log(`✓ Anonymous cart created with ID: ${anonymousCartId}\n`);
// Step 3: Merge Carts
console.log("Step 3: Merging anonymous cart into customer cart...");
const mergedCartResponse = await mergeCartsForCustomer(anonymousCartId);
console.log(`✓ Carts merged successfully!\n`);
// Step 4: Print summary
printMergeSummary(customerCartId, anonymousCartId, mergedCartResponse.body);
} catch (error: any) {
// --- READABLE ERROR HANDLING ---
console.error("\nCART MERGE FAILED");
if (error.body && error.body.errors) {
console.error(`Status Code: ${error.statusCode}`);
error.body.errors.forEach((err: any) => {
console.error(`Field: ${err.code} | Message: ${err.message}`);
});
} else {
console.error("System Error:", error.message);
}
}
}
run();
...
=== Final Result ===
Customer Cart ID: 121aab26-9ad6-4a95-bf86-2f3f1c139e19
Anonymous Cart ID: 22f58524-45c3-49c1-b89c-21ad5654b9c4
Merged Cart ID: 121aab26-9ad6-4a95-bf86-2f3f1c139e19
Total Quantity: 3
Line Items in Merged Cart:
1. Product: classic-serving-tray, Quantity: 2, SKU: CST-01
2. Product: crystal-drinking-glass, Quantity: 1, SKU: CDG-09
/login endpoint. Since we did not specify a Cart merging strategy, MergeWithExistingCustomerCart was used as the default.Recurring Orders
The Recurring Orders framework relies on three primary components working in tandem:
The Recurrence Policy
- Schedule Type: currently supports standard intervals (Days, Weeks, Months).
- Reusability: a single policy (like "Standard Monthly") can be referenced by thousands of different recurring orders.
The right price
After establishing at least one Recurrence Policy, we must define recurring prices for the products intended for subscription sales. These prices can be either Embedded or Standalone, provided they reference a previously defined Recurrence Policy.
The blueprint Cart
- Recurrence Info: Line Items in these Carts must include
recurrenceInfo, which links the item to a specific Recurrence Policy.
The Recurring Order resource
To properly set up and use this exciting new functionality, you have to follow these steps:
- Define a Recurrence Policy/interval for the subscription schedule (the "heartbeat"—daily, weekly, monthly).
- Add at least one price to your Product Variant that is tied to the Recurrence Policy (a "subscription price" if you will).
- Add the Product Variant to a customer Cart, specifying that the Line Item should use the price tied to a Recurrence Policy.
- Create a Recurring Order from that Cart using the
/recurring-ordersendpoint.
Let’s go ahead and give it a try by coding the steps described above
// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.
const CUSTOMER_ID = "46fbc1a6-fd3c-424c-bbf5-096ab5da0f21";
const SKU = "WTP-09";
const CURRENCY = "EUR";
const COUNTRY = "DE";
const PRICE_AMOUNT = 599; // in cents
const RECURRENCE_POLICY_KEY = "daily-subscription";
const expiryInDays = (days: number): string =>
new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
const formatMoney = (centAmount: number): string =>
`${(centAmount / 100).toFixed(2)} ${CURRENCY}`;
/**
* Creates a daily Recurrence Policy.
*/
async function createRecurrencePolicy(): Promise<any> {
return apiRoot
.recurrencePolicies()
.post({
body: {
key: RECURRENCE_POLICY_KEY,
schedule: {
type: "standard",
intervalUnit: "Days",
value: 1,
},
},
})
.execute();
}
/**
* Adds an embedded price to a Product Variant and ties it to the Recurrence Policy.
*/
async function addPriceToVariant(
productId: string,
productVersion: number,
recurrencePolicyId: string
): Promise<any> {
return apiRoot
.products()
.withId({ ID: productId })
.post({
body: {
version: productVersion,
actions: [
{
action: "addPrice",
sku: SKU,
price: {
value: {
type: "centPrecision",
currencyCode: CURRENCY,
centAmount: PRICE_AMOUNT,
},
country: COUNTRY,
recurrencePolicy: {
typeId: "recurrence-policy",
id: recurrencePolicyId,
},
},
},
{ action: "publish" },
],
},
})
.execute();
}
/**
* Fetches a product by SKU to get its ID and variant details.
*/
async function getProductBySku(sku: string): Promise<any> {
const response = await apiRoot
.productProjections()
.get({
queryArgs: {
where: `masterVariant(sku="${sku}")`,
},
})
.execute();
if (response.body.results.length === 0) {
throw new Error(`Product with SKU "${sku}" not found`);
}
return response.body.results[0];
}
/**
* Creates a customer Cart with a specific SKU and selects "subscription price".
*/
async function createCustomerCart(
sku: string,
recurrencePolicyId: string
): Promise<any> {
return apiRoot
.carts()
.post({
body: {
currency: CURRENCY,
country: COUNTRY,
customerId: CUSTOMER_ID,
lineItems: [
{
sku,
recurrenceInfo: {
recurrencePolicy: {
typeId: "recurrence-policy",
id: recurrencePolicyId,
},
priceSelectionMode: "Fixed", // or "Dynamic"
},
},
],
shippingAddress: {
country: COUNTRY,
},
},
})
.execute();
}
/**
* Creates a Recurring Order from a Cart.
*/
async function createRecurringOrder(
cartId: string,
cartVersion: number
): Promise<any> {
return apiRoot
.recurringOrders()
.post({
body: {
cart: {
typeId: "cart",
id: cartId,
},
cartVersion: cartVersion,
expiresAt: expiryInDays(5),
},
})
.execute();
}
// Execute the demo
async function run(): Promise<void> {
console.log("=== Recurring Orders Demo ===\n");
try {
// Step 1: Create Recurrence Policy
console.log("Step 1: Creating daily recurrence policy...");
const recurrencePolicyResponse = await createRecurrencePolicy();
const recurrencePolicy = recurrencePolicyResponse.body;
const recurrencePolicyId = recurrencePolicy.id;
console.log(`✓ Recurrence Policy created | ID: ${recurrencePolicyId}\n`);
// Step 2: Get Product by SKU and add price
console.log("Step 2: Fetching product and adding price...");
const product = await getProductBySku(SKU);
const { id: productId, version: productVersion } = product;
await addPriceToVariant(productId, productVersion, recurrencePolicyId);
console.log(
`✓ Price added to variant | Product ID: ${productId} | Variant SKU: ${SKU}\n`
);
// Step 3: Create customer Cart
console.log("Step 3: Creating customer cart...");
const cartResponse = await createCustomerCart(SKU, recurrencePolicyId);
const cart = cartResponse.body;
const cartId = cart.id;
const cartVersion = cart.version;
console.log(
`✓ Customer cart created | ID: ${cartId} | Total: ${formatMoney(
cart.totalPrice.centAmount
)}\n`
);
// Step 4: Create Recurring Order
console.log("Step 4: Creating recurring order...");
const recurringOrderResponse = await createRecurringOrder(
cartId,
cartVersion
);
const recurringOrder = recurringOrderResponse.body;
console.log(`✓ Recurring order created | ID: ${recurringOrder.id}\n`);
} catch (error: any) {
console.error("\nRECURRING ORDER CREATION FAILED");
if (error.body && error.body.errors) {
console.error(`Status Code: ${error.statusCode}`);
error.body.errors.forEach((err: any) => {
console.error(`Field: ${err.code} | Message: ${err.message}`);
});
} else {
console.error("System Error:", error.message);
}
}
}
run();
=== Recurring Orders Demo ===
Step 1: Creating daily recurrence policy...
✓ Recurrence Policy created | ID: a713e79b-9c76-4b26-bdf3-f1365c7bf7ae
Step 2: Fetching product and adding price...
✓ Price added to variant | SKU: WTP-09
Step 3: Creating customer cart...
✓ Cart created | ID: de80c89b-34cb-4405-a442-e9dcca1cf6a9 | Total: 5.99 EUR
Step 4: Creating recurring order...
✓ Recurring order created | ID: 31cb251c-87a8-4c5a-9a56-6660d5f0195e
As the code indicates, the subscription is configured to expire five days from the current date, which is displayed on the order's details page. This page also allows for managing the subscription, such as skipping occurrences or pausing/resuming the service. The "Linked Orders" tab tracks all individual orders generated under this recurring subscription. Currently, only one order is present (it might take a few minutes to show up), but more will appear daily, reflecting the set recurrence policy.
priceSelectionMode to "Fixed" when adding a Line Item to the Cart. That means the customer pays the original price every time the subscription renews, even if the product's price changes later. The other option is "Dynamic," which always grabs the current price when a new order is created for the subscription.