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
5 changes: 5 additions & 0 deletions .changeset/kind-flowers-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@labdigital/commercetools-mock": minor
---

feat(cart): add support for set line-item prices
67 changes: 67 additions & 0 deletions src/repositories/cart/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
CartSetDirectDiscountsAction,
CartSetLineItemCustomFieldAction,
CartSetLineItemCustomTypeAction,
CartSetLineItemPriceAction,
CartSetLineItemShippingDetailsAction,
CartSetLocaleAction,
CartSetShippingAddressAction,
Expand Down Expand Up @@ -740,6 +741,72 @@ export class CartUpdateHandler
}
}

setLineItemPrice(
context: RepositoryContext,
resource: Writable<Cart>,
{ lineItemId, lineItemKey, externalPrice }: CartSetLineItemPriceAction,
) {
const lineItem = resource.lineItems.find(
(x) =>
(lineItemId && x.id === lineItemId) ||
(lineItemKey && x.key === lineItemKey),
);

if (!lineItem) {
throw new CommercetoolsError<GeneralError>({
code: "General",
message: lineItemKey
? `A line item with key '${lineItemKey}' not found.`
: `A line item with ID '${lineItemId}' not found.`,
});
}

if (!externalPrice && lineItem.priceMode !== "ExternalPrice") {
return;
}

if (
externalPrice &&
externalPrice.currencyCode !== resource.totalPrice.currencyCode
) {
throw new CommercetoolsError<GeneralError>({
code: "General",
message: `Currency mismatch. Expected '${resource.totalPrice.currencyCode}' but got '${externalPrice.currencyCode}'.`,
});
}

if (externalPrice) {
lineItem.priceMode = "ExternalPrice";
const priceValue = createTypedMoney(externalPrice);

lineItem.price = lineItem.price ?? { id: uuidv4() };
lineItem.price.value = priceValue;
} else {
lineItem.priceMode = "Platform";

const price = selectPrice({
prices: lineItem.variant.prices,
currency: resource.totalPrice.currencyCode,
country: resource.country,
});

if (!price) {
throw new Error(
`No valid price found for ${lineItem.productId} for country ${resource.country} and currency ${resource.totalPrice.currencyCode}`,
);
}

lineItem.price = price;
}

const lineItemTotal = calculateLineItemTotalPrice(lineItem);
lineItem.totalPrice = createCentPrecisionMoney({
...lineItem.price!.value,
centAmount: lineItemTotal,
});
resource.totalPrice.centAmount = calculateCartTotalPrice(resource);
}

setLineItemShippingDetails(
context: RepositoryContext,
resource: Writable<Cart>,
Expand Down
211 changes: 211 additions & 0 deletions src/services/cart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,217 @@ describe("Cart Update Actions", () => {
]);
});

test("setLineItemPrice sets an external price for a line item", async () => {
const product = await supertest(ctMock.app)
.post("/dummy/products")
.send(productDraft)
.then((x) => x.body);

assert(product, "product not created");

const baseCartResponse = await supertest(ctMock.app)
.post("/dummy/carts")
.send({ currency: "EUR" });
expect(baseCartResponse.status).toBe(201);
const baseCart = baseCartResponse.body as Cart;

const addLineItemResponse = await supertest(ctMock.app)
.post(`/dummy/carts/${baseCart.id}`)
.send({
version: baseCart.version,
actions: [
{
action: "addLineItem",
sku: product.masterData.current.masterVariant.sku,
quantity: 2,
key: "line-item-key",
},
],
});
expect(addLineItemResponse.status).toBe(200);
const cartWithLineItem = addLineItemResponse.body as Cart;
const lineItem = cartWithLineItem.lineItems[0];
assert(lineItem, "lineItem not created");

const externalPrice: CentPrecisionMoney = {
type: "centPrecision",
currencyCode: "EUR",
centAmount: 2500,
fractionDigits: 2,
};

const response = await supertest(ctMock.app)
.post(`/dummy/carts/${cartWithLineItem.id}`)
.send({
version: cartWithLineItem.version,
actions: [
{
action: "setLineItemPrice",
lineItemKey: lineItem.key,
externalPrice,
},
],
});

expect(response.status).toBe(200);
expect(response.body.version).toBe(cartWithLineItem.version + 1);
expect(response.body.lineItems).toHaveLength(1);

const updatedLineItem = response.body.lineItems[0];
expect(updatedLineItem.priceMode).toBe("ExternalPrice");
expect(updatedLineItem.price.value.centAmount).toBe(
externalPrice.centAmount,
);
expect(updatedLineItem.price.value.currencyCode).toBe(
externalPrice.currencyCode,
);
expect(updatedLineItem.totalPrice.centAmount).toBe(
externalPrice.centAmount * updatedLineItem.quantity,
);
expect(response.body.totalPrice.centAmount).toBe(
externalPrice.centAmount * updatedLineItem.quantity,
);
});

test("setLineItemPrice fails when the money uses another currency", async () => {
const product = await supertest(ctMock.app)
.post("/dummy/products")
.send(productDraft)
.then((x) => x.body);

assert(product, "product not created");

const baseCartResponse = await supertest(ctMock.app)
.post("/dummy/carts")
.send({ currency: "EUR" });
expect(baseCartResponse.status).toBe(201);
const baseCart = baseCartResponse.body as Cart;

const addLineItemResponse = await supertest(ctMock.app)
.post(`/dummy/carts/${baseCart.id}`)
.send({
version: baseCart.version,
actions: [
{
action: "addLineItem",
sku: product.masterData.current.masterVariant.sku,
quantity: 1,
},
],
});
expect(addLineItemResponse.status).toBe(200);
const cartWithLineItem = addLineItemResponse.body as Cart;
const lineItem = cartWithLineItem.lineItems[0];
assert(lineItem, "lineItem not created");

const response = await supertest(ctMock.app)
.post(`/dummy/carts/${cartWithLineItem.id}`)
.send({
version: cartWithLineItem.version,
actions: [
{
action: "setLineItemPrice",
lineItemId: lineItem.id,
externalPrice: {
type: "centPrecision",
currencyCode: "USD",
centAmount: 5000,
fractionDigits: 2,
},
},
],
});

expect(response.status).toBe(400);
expect(response.body.message).toContain("Currency mismatch");
});

test("setLineItemPrice removes external price when no value is provided", async () => {
const product = await supertest(ctMock.app)
.post("/dummy/products")
.send(productDraft)
.then((x) => x.body);

assert(product, "product not created");

const baseCartResponse = await supertest(ctMock.app)
.post("/dummy/carts")
.send({ currency: "EUR" });
expect(baseCartResponse.status).toBe(201);
const baseCart = baseCartResponse.body as Cart;

const addLineItemResponse = await supertest(ctMock.app)
.post(`/dummy/carts/${baseCart.id}`)
.send({
version: baseCart.version,
actions: [
{
action: "addLineItem",
sku: product.masterData.current.masterVariant.sku,
quantity: 1,
},
],
});
expect(addLineItemResponse.status).toBe(200);
const cartWithLineItem = addLineItemResponse.body as Cart;
const lineItem = cartWithLineItem.lineItems[0];
assert(lineItem, "lineItem not created");

const externalPrice: CentPrecisionMoney = {
type: "centPrecision",
currencyCode: "EUR",
centAmount: 1000,
fractionDigits: 2,
};

const setExternalPriceResponse = await supertest(ctMock.app)
.post(`/dummy/carts/${cartWithLineItem.id}`)
.send({
version: cartWithLineItem.version,
actions: [
{
action: "setLineItemPrice",
lineItemId: lineItem.id,
externalPrice,
},
],
});
expect(setExternalPriceResponse.status).toBe(200);
const cartWithExternalPrice = setExternalPriceResponse.body as Cart;
expect(cartWithExternalPrice.lineItems[0].priceMode).toBe("ExternalPrice");

const resetResponse = await supertest(ctMock.app)
.post(`/dummy/carts/${cartWithExternalPrice.id}`)
.send({
version: cartWithExternalPrice.version,
actions: [
{
action: "setLineItemPrice",
lineItemId: lineItem.id,
},
],
});

expect(resetResponse.status).toBe(200);
expect(resetResponse.body.version).toBe(cartWithExternalPrice.version + 1);
expect(resetResponse.body.lineItems).toHaveLength(1);

const revertedLineItem = resetResponse.body.lineItems[0];
const expectedCentAmount =
product.masterData.current.masterVariant.prices?.[0].value.centAmount;
if (typeof expectedCentAmount !== "number") {
throw new Error("product price not found");
}
expect(revertedLineItem.priceMode).toBe("Platform");
expect(revertedLineItem.price.value.centAmount).toBe(expectedCentAmount);
expect(revertedLineItem.totalPrice.centAmount).toBe(
expectedCentAmount * revertedLineItem.quantity,
);
expect(resetResponse.body.totalPrice.centAmount).toBe(
expectedCentAmount * revertedLineItem.quantity,
);
});

test("setLineItemCustomField", async () => {
const product = await supertest(ctMock.app)
.post("/dummy/products")
Expand Down