feat(core-flows): cart complete shipping validate (#10984)

**What**
- validate that there is a shipping method if any of the line items have requires_shipping=true
- validate that products shipping profile is supported by a shipping method on the cart
- update tests

---

CLOSES CMRC-683
This commit is contained in:
Frane Polić
2025-02-04 11:10:08 +01:00
committed by GitHub
parent 3cf4307296
commit 462c3e8057
9 changed files with 512 additions and 118 deletions

View File

@@ -96,7 +96,11 @@ medusaIntegrationTestRunner({
).data.region
product = (
await api.post("/admin/products", { ...medusaTshirtProduct, shipping_profile_id: shippingProfile.id }, adminHeaders)
await api.post(
"/admin/products",
{ ...medusaTshirtProduct, shipping_profile_id: shippingProfile.id },
adminHeaders
)
).data.product
salesChannel = (
@@ -995,87 +999,89 @@ medusaIntegrationTestRunner({
})
describe("POST /store/carts/:id/complete", () => {
beforeEach(async () => {
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
})
it("should successfully complete cart", async () => {
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
id: expect.any(String),
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 1500,
compare_at_unit_price: null,
quantity: 1,
}),
]),
})
)
})
describe("with sale price lists", () => {
let priceList
describe("should successfully complete cart", () => {
beforeEach(async () => {
priceList = (
const stockLocation = (
await api.post(
`/admin/price-lists`,
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
const fulfillmentSets = (
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
title: "test price list",
description: "test",
status: PriceListStatus.ACTIVE,
type: PriceListType.SALE,
prices: [
{
amount: 350,
currency_code: "usd",
variant_id: product.variants[0].id,
},
],
name: `Test-${shippingProfile.id}`,
type: "test-type",
},
adminHeaders
)
).data.price_list
).data.stock_location.fulfillment_sets
const fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`,
{
name: `Test-${shippingProfile.id}`,
geo_zones: [{ type: "country", country_code: "US" }],
},
adminHeaders
)
).data.fulfillment_set
await api.post(
`/store/carts/${cart.id}/line-items`,
{ variant_id: product.variants[0].id, quantity: 1 },
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{ add: ["manual_test-provider"] },
adminHeaders
)
const shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [{ currency_code: "usd", amount: 1000 }],
rules: [],
},
adminHeaders
)
).data.shipping_option
cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
@@ -1094,27 +1100,297 @@ medusaIntegrationTestRunner({
)
})
it("should add price from price list and set compare_at_unit_price for order item", async () => {
it("should successfully complete cart", async () => {
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{ variant_id: product.variants[0].id, quantity: 1 },
{},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
id: expect.any(String),
currency_code: "usd",
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 350,
compare_at_unit_price: 1500,
is_tax_inclusive: true,
quantity: 2,
unit_price: 1500,
compare_at_unit_price: null,
quantity: 1,
}),
]),
})
)
})
describe("with sale price lists", () => {
let priceList
beforeEach(async () => {
priceList = (
await api.post(
`/admin/price-lists`,
{
title: "test price list",
description: "test",
status: PriceListStatus.ACTIVE,
type: PriceListType.SALE,
prices: [
{
amount: 350,
currency_code: "usd",
variant_id: product.variants[0].id,
},
],
},
adminHeaders
)
).data.price_list
await api.post(
`/store/carts/${cart.id}/line-items`,
{ variant_id: product.variants[0].id, quantity: 1 },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
})
it("should add price from price list and set compare_at_unit_price for order item", async () => {
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{ variant_id: product.variants[0].id, quantity: 1 },
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
unit_price: 350,
compare_at_unit_price: 1500,
is_tax_inclusive: true,
quantity: 2,
}),
]),
})
)
})
})
})
describe("shipping validation", () => {
it("should fail to complete the cart if no shipping method is selected and items require shipping", async () => {
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
const response = await api
.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
.catch((e) => e)
expect(response.response.status).toEqual(400)
expect(response.response.data.message).toEqual(
"No shipping method selected but the cart contains items that require shipping."
)
})
it("should fail to complete the cart if the shipping profile of a product is not supported by the shipping method", async () => {
const stockLocation = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
const shippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{ name: `test-${stockLocation.id}`, type: "default" },
adminHeaders
)
).data.shipping_profile
const fulfillmentSets = (
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
name: `Test-${shippingProfile.id}`,
type: "test-type",
},
adminHeaders
)
).data.stock_location.fulfillment_sets
const fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`,
{
name: `Test-${shippingProfile.id}`,
geo_zones: [{ type: "country", country_code: "US" }],
},
adminHeaders
)
).data.fulfillment_set
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{ add: ["manual_test-provider"] },
adminHeaders
)
const shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [{ currency_code: "usd", amount: 1000 }],
rules: [],
},
adminHeaders
)
).data.shipping_option
const specialShippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{ name: "special-shipping-profile", type: "special" },
adminHeaders
)
).data.shipping_profile
const product = (
await api.post(
`/admin/products`,
{
title: "test product",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: "usd",
},
],
},
],
shipping_profile_id: specialShippingProfile.id, // --> product has a different shipping profile than
},
adminHeaders
)
).data.product
const cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
items: [{ variant_id: product.variants[0].id, quantity: 1 }],
promo_codes: [promotion.code],
},
storeHeadersWithCustomer
)
).data.cart
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
)
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
const response = await api
.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders)
.catch((e) => e)
expect(response.response.status).toEqual(400)
expect(response.response.data.message).toEqual(
"The cart items require shipping profiles that are not satisfied by the current shipping methods"
)
})
})
})

View File

@@ -29,11 +29,17 @@ export async function createOrderSeeder({
stockChannelOverride?: AdminStockLocation
additionalProducts?: { variant_id: string; quantity: number }[]
inventoryItemOverride?: AdminInventoryItem
shippingProfileOverride?: AdminShippingProfile
shippingProfileOverride?: AdminShippingProfile | AdminShippingProfile[]
withoutShipping?: boolean
}) {
const publishableKey = await generatePublishableKey(container)
const shippingProfileOverrideArray = !shippingProfileOverride
? undefined
: Array.isArray(shippingProfileOverride)
? shippingProfileOverride
: [shippingProfileOverride]
const storeHeaders =
storeHeaderOverride ??
generateStoreHeaders({
@@ -92,7 +98,7 @@ export async function createOrderSeeder({
)
const shippingProfile =
shippingProfileOverride ??
shippingProfileOverrideArray?.[0] ??
(
await api.post(
`/admin/shipping-profiles`,
@@ -168,29 +174,38 @@ export async function createOrderSeeder({
adminHeaders
)
const shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{ currency_code: "usd", amount: 1000 },
{ region_id: region.id, amount: 1100 },
],
rules: [],
},
adminHeaders
)
).data.shipping_option
/**
* Create shipping options for each shipping profile provided
*/
const shippingOptions = await Promise.all(
(shippingProfileOverrideArray || [shippingProfile]).map(async (sp) => {
return (
await api.post(
`/admin/shipping-options`,
{
name: `Test shipping option ${fulfillmentSet.id}`,
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: sp.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{ currency_code: "usd", amount: 1000 },
{ region_id: region.id, amount: 1100 },
],
rules: [],
},
adminHeaders
)
).data.shipping_option
})
)
const shippingOption = shippingOptions[0]
const cart = (
await api.post(
@@ -226,10 +241,15 @@ export async function createOrderSeeder({
).data.cart
if (!withoutShipping) {
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: shippingOption.id },
storeHeaders
// Create shipping methods for each shipping option so shipping profiles of products in the cart are supported
await Promise.all(
shippingOptions.map(async (so) => {
await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{ option_id: so.id },
storeHeaders
)
})
)
}

View File

@@ -798,7 +798,7 @@ medusaIntegrationTestRunner({
],
stockChannelOverride,
inventoryItemOverride,
shippingProfileOverride: shippingProfile,
shippingProfileOverride: [shippingProfile, shippingProfileOverride],
})
order = seeder.order
order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
@@ -932,6 +932,7 @@ medusaIntegrationTestRunner({
.post(
`/admin/orders/${order.id}/fulfillments`,
{
shipping_option_id: seeder.shippingOption.id, // shipping option with the "regular" shipping profile
location_id: stockChannelOverride.id,
items: [{ id: orderItemId, quantity: 1 }],
},
@@ -946,6 +947,9 @@ medusaIntegrationTestRunner({
})
it("should only create fulfillments grouped by shipping requirement", async () => {
const item1Id = order.items.find((i) => i.requires_shipping).id
const item2Id = order.items.find((i) => !i.requires_shipping).id
const {
response: { data },
} = await api
@@ -955,11 +959,11 @@ medusaIntegrationTestRunner({
location_id: seeder.stockLocation.id,
items: [
{
id: order.items[0].id,
id: item1Id,
quantity: 1,
},
{
id: order.items[1].id,
id: item2Id,
quantity: 1,
},
],
@@ -979,7 +983,7 @@ medusaIntegrationTestRunner({
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
{
location_id: seeder.stockLocation.id,
items: [{ id: order.items[0].id, quantity: 1 }],
items: [{ id: item1Id, quantity: 1 }],
},
adminHeaders
)
@@ -992,7 +996,7 @@ medusaIntegrationTestRunner({
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
{
location_id: seeder.stockLocation.id,
items: [{ id: order.items[1].id, quantity: 1 }],
items: [{ id: item2Id, quantity: 1 }],
},
adminHeaders
)

View File

@@ -266,6 +266,7 @@ medusaIntegrationTestRunner({
const inventoryItem = await inventoryModule.createInventoryItems({
sku: "inv-1234",
requires_shipping: false,
})
await inventoryModule.createInventoryLevels([
@@ -743,7 +744,7 @@ medusaIntegrationTestRunner({
title: "Test item",
subtitle: "Test subtitle",
thumbnail: "some-url",
requires_shipping: true,
requires_shipping: false,
is_discountable: false,
is_tax_inclusive: false,
unit_price: 3000,
@@ -825,7 +826,7 @@ medusaIntegrationTestRunner({
precision: 20,
value: "3000",
},
requires_shipping: true,
requires_shipping: false,
subtitle: "Test subtitle",
thumbnail: "some-url",
title: "Test item",

View File

@@ -1161,6 +1161,7 @@ medusaIntegrationTestRunner({
`/admin/inventory-items`,
{
sku: "12345",
requires_shipping: false,
},
adminHeaders
)

View File

@@ -30,4 +30,5 @@ export * from "./validate-variant-prices"
export * from "./validate-cart"
export * from "./validate-line-item-prices"
export * from "./validate-shipping-methods-data"
export * from "./validate-shipping-options-price"
export * from "./validate-shipping-options-price"
export * from "./validate-shipping"

View File

@@ -0,0 +1,69 @@
import {
CartLineItemDTO,
CartWorkflowDTO,
ProductVariantDTO,
ShippingOptionDTO,
} from "@medusajs/types"
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
import { MedusaError } from "../../../../utils/dist/common"
export type ValidateShippingInput = {
cart: Omit<CartWorkflowDTO, "items"> & {
items: (CartLineItemDTO & {
variant: ProductVariantDTO
})[]
}
shippingOptions: ShippingOptionDTO[]
}
export const validateShippingStepId = "validate-shipping"
/**
* This step validates shipping data when cart is completed.
*
* It ensures that a shipping method is selected if there is an item in the cart that requires shipping.
* It also ensures that product's shipping profile mathes the selected shipping options.
*/
export const validateShippingStep = createStep(
validateShippingStepId,
async (data: ValidateShippingInput) => {
const { cart, shippingOptions } = data
const optionProfileMap: Map<string, string> = new Map(
shippingOptions.map((option) => [option.id, option.shipping_profile_id])
)
const cartItemsWithShipping =
cart.items?.filter((item) => item.requires_shipping) || []
const cartShippingMethods = cart.shipping_methods || []
if (cartItemsWithShipping.length > 0 && cartShippingMethods.length === 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"No shipping method selected but the cart contains items that require shipping."
)
}
const requiredShippingPorfiles = cartItemsWithShipping.map(
(item) => (item.variant.product as any)?.shipping_profile?.id
)
const availableShippingPorfiles = cartShippingMethods.map((method) =>
optionProfileMap.get(method.shipping_option_id!)
)
const missingShippingPorfiles = requiredShippingPorfiles.filter(
(profile) => !availableShippingPorfiles.includes(profile)
)
if (missingShippingPorfiles.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The cart items require shipping profiles that are not satisfied by the current shipping methods"
)
}
return new StepResponse(void 0)
}
)

View File

@@ -99,6 +99,7 @@ export const completeCartFields = [
"payment_collection.payment_sessions.*",
"items.variant.id",
"items.variant.product.id",
"items.variant.product.shipping_profile.id",
"items.variant.manage_inventory",
"items.variant.allow_backorder",
"items.variant.inventory_items.inventory_item_id",

View File

@@ -25,7 +25,11 @@ import {
import { createOrdersStep } from "../../order/steps/create-orders"
import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session"
import { registerUsageStep } from "../../promotion/steps/register-usage"
import { updateCartsStep, validateCartPaymentsStep } from "../steps"
import {
updateCartsStep,
validateCartPaymentsStep,
validateShippingStep,
} from "../steps"
import { reserveInventoryStep } from "../steps/reserve-inventory"
import { completeCartFields } from "../utils/fields"
import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input"
@@ -101,6 +105,8 @@ export const completeCartWorkflow = createWorkflow(
fields: completeCartFields,
variables: { id: input.id },
list: false,
}).config({
name: "cart-query",
})
const validate = createHook("validate", {
@@ -112,6 +118,21 @@ export const completeCartWorkflow = createWorkflow(
const order = when("create-order", { orderId }, ({ orderId }) => {
return !orderId
}).then(() => {
const cartOptionIds = transform({ cart }, ({ cart }) => {
return cart.shipping_methods?.map((sm) => sm.shipping_option_id)
})
const shippingOptions = useRemoteQueryStep({
entry_point: "shipping_option",
fields: ["id", "shipping_profile_id"],
variables: { id: cartOptionIds },
list: true,
}).config({
name: "shipping-options-query",
})
validateShippingStep({ cart, shippingOptions })
const paymentSessions = validateCartPaymentsStep({ cart })
const payment = authorizePaymentSessionStep({