Skip to content

Commit 462c3e8

Browse files
authored
feat(core-flows): cart complete shipping validate (medusajs#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
1 parent 3cf4307 commit 462c3e8

File tree

9 files changed

+506
-112
lines changed

9 files changed

+506
-112
lines changed

integration-tests/http/__tests__/cart/store/cart.spec.ts

Lines changed: 350 additions & 74 deletions
Large diffs are not rendered by default.

integration-tests/http/__tests__/fixtures/order.ts

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,17 @@ export async function createOrderSeeder({
2929
stockChannelOverride?: AdminStockLocation
3030
additionalProducts?: { variant_id: string; quantity: number }[]
3131
inventoryItemOverride?: AdminInventoryItem
32-
shippingProfileOverride?: AdminShippingProfile
32+
shippingProfileOverride?: AdminShippingProfile | AdminShippingProfile[]
3333
withoutShipping?: boolean
3434
}) {
3535
const publishableKey = await generatePublishableKey(container)
3636

37+
const shippingProfileOverrideArray = !shippingProfileOverride
38+
? undefined
39+
: Array.isArray(shippingProfileOverride)
40+
? shippingProfileOverride
41+
: [shippingProfileOverride]
42+
3743
const storeHeaders =
3844
storeHeaderOverride ??
3945
generateStoreHeaders({
@@ -92,7 +98,7 @@ export async function createOrderSeeder({
9298
)
9399

94100
const shippingProfile =
95-
shippingProfileOverride ??
101+
shippingProfileOverrideArray?.[0] ??
96102
(
97103
await api.post(
98104
`/admin/shipping-profiles`,
@@ -168,29 +174,38 @@ export async function createOrderSeeder({
168174
adminHeaders
169175
)
170176

171-
const shippingOption = (
172-
await api.post(
173-
`/admin/shipping-options`,
174-
{
175-
name: `Test shipping option ${fulfillmentSet.id}`,
176-
service_zone_id: fulfillmentSet.service_zones[0].id,
177-
shipping_profile_id: shippingProfile.id,
178-
provider_id: "manual_test-provider",
179-
price_type: "flat",
180-
type: {
181-
label: "Test type",
182-
description: "Test description",
183-
code: "test-code",
184-
},
185-
prices: [
186-
{ currency_code: "usd", amount: 1000 },
187-
{ region_id: region.id, amount: 1100 },
188-
],
189-
rules: [],
190-
},
191-
adminHeaders
192-
)
193-
).data.shipping_option
177+
/**
178+
* Create shipping options for each shipping profile provided
179+
*/
180+
const shippingOptions = await Promise.all(
181+
(shippingProfileOverrideArray || [shippingProfile]).map(async (sp) => {
182+
return (
183+
await api.post(
184+
`/admin/shipping-options`,
185+
{
186+
name: `Test shipping option ${fulfillmentSet.id}`,
187+
service_zone_id: fulfillmentSet.service_zones[0].id,
188+
shipping_profile_id: sp.id,
189+
provider_id: "manual_test-provider",
190+
price_type: "flat",
191+
type: {
192+
label: "Test type",
193+
description: "Test description",
194+
code: "test-code",
195+
},
196+
prices: [
197+
{ currency_code: "usd", amount: 1000 },
198+
{ region_id: region.id, amount: 1100 },
199+
],
200+
rules: [],
201+
},
202+
adminHeaders
203+
)
204+
).data.shipping_option
205+
})
206+
)
207+
208+
const shippingOption = shippingOptions[0]
194209

195210
const cart = (
196211
await api.post(
@@ -226,10 +241,15 @@ export async function createOrderSeeder({
226241
).data.cart
227242

228243
if (!withoutShipping) {
229-
await api.post(
230-
`/store/carts/${cart.id}/shipping-methods`,
231-
{ option_id: shippingOption.id },
232-
storeHeaders
244+
// Create shipping methods for each shipping option so shipping profiles of products in the cart are supported
245+
await Promise.all(
246+
shippingOptions.map(async (so) => {
247+
await api.post(
248+
`/store/carts/${cart.id}/shipping-methods`,
249+
{ option_id: so.id },
250+
storeHeaders
251+
)
252+
})
233253
)
234254
}
235255

integration-tests/http/__tests__/order/admin/order.spec.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -798,7 +798,7 @@ medusaIntegrationTestRunner({
798798
],
799799
stockChannelOverride,
800800
inventoryItemOverride,
801-
shippingProfileOverride: shippingProfile,
801+
shippingProfileOverride: [shippingProfile, shippingProfileOverride],
802802
})
803803
order = seeder.order
804804
order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
@@ -932,6 +932,7 @@ medusaIntegrationTestRunner({
932932
.post(
933933
`/admin/orders/${order.id}/fulfillments`,
934934
{
935+
shipping_option_id: seeder.shippingOption.id, // shipping option with the "regular" shipping profile
935936
location_id: stockChannelOverride.id,
936937
items: [{ id: orderItemId, quantity: 1 }],
937938
},
@@ -946,6 +947,9 @@ medusaIntegrationTestRunner({
946947
})
947948

948949
it("should only create fulfillments grouped by shipping requirement", async () => {
950+
const item1Id = order.items.find((i) => i.requires_shipping).id
951+
const item2Id = order.items.find((i) => !i.requires_shipping).id
952+
949953
const {
950954
response: { data },
951955
} = await api
@@ -955,11 +959,11 @@ medusaIntegrationTestRunner({
955959
location_id: seeder.stockLocation.id,
956960
items: [
957961
{
958-
id: order.items[0].id,
962+
id: item1Id,
959963
quantity: 1,
960964
},
961965
{
962-
id: order.items[1].id,
966+
id: item2Id,
963967
quantity: 1,
964968
},
965969
],
@@ -979,7 +983,7 @@ medusaIntegrationTestRunner({
979983
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
980984
{
981985
location_id: seeder.stockLocation.id,
982-
items: [{ id: order.items[0].id, quantity: 1 }],
986+
items: [{ id: item1Id, quantity: 1 }],
983987
},
984988
adminHeaders
985989
)
@@ -992,7 +996,7 @@ medusaIntegrationTestRunner({
992996
`/admin/orders/${order.id}/fulfillments?fields=+fulfillments.id,fulfillments.requires_shipping`,
993997
{
994998
location_id: seeder.stockLocation.id,
995-
items: [{ id: order.items[1].id, quantity: 1 }],
999+
items: [{ id: item2Id, quantity: 1 }],
9961000
},
9971001
adminHeaders
9981002
)

integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ medusaIntegrationTestRunner({
266266

267267
const inventoryItem = await inventoryModule.createInventoryItems({
268268
sku: "inv-1234",
269+
requires_shipping: false,
269270
})
270271

271272
await inventoryModule.createInventoryLevels([
@@ -743,7 +744,7 @@ medusaIntegrationTestRunner({
743744
title: "Test item",
744745
subtitle: "Test subtitle",
745746
thumbnail: "some-url",
746-
requires_shipping: true,
747+
requires_shipping: false,
747748
is_discountable: false,
748749
is_tax_inclusive: false,
749750
unit_price: 3000,
@@ -825,7 +826,7 @@ medusaIntegrationTestRunner({
825826
precision: 20,
826827
value: "3000",
827828
},
828-
requires_shipping: true,
829+
requires_shipping: false,
829830
subtitle: "Test subtitle",
830831
thumbnail: "some-url",
831832
title: "Test item",

integration-tests/modules/__tests__/cart/store/carts.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,7 @@ medusaIntegrationTestRunner({
11611161
`/admin/inventory-items`,
11621162
{
11631163
sku: "12345",
1164+
requires_shipping: false,
11641165
},
11651166
adminHeaders
11661167
)

packages/core/core-flows/src/cart/steps/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ export * from "./validate-variant-prices"
3030
export * from "./validate-cart"
3131
export * from "./validate-line-item-prices"
3232
export * from "./validate-shipping-methods-data"
33-
export * from "./validate-shipping-options-price"
33+
export * from "./validate-shipping-options-price"
34+
export * from "./validate-shipping"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
CartLineItemDTO,
3+
CartWorkflowDTO,
4+
ProductVariantDTO,
5+
ShippingOptionDTO,
6+
} from "@medusajs/types"
7+
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
8+
9+
import { MedusaError } from "../../../../utils/dist/common"
10+
11+
export type ValidateShippingInput = {
12+
cart: Omit<CartWorkflowDTO, "items"> & {
13+
items: (CartLineItemDTO & {
14+
variant: ProductVariantDTO
15+
})[]
16+
}
17+
shippingOptions: ShippingOptionDTO[]
18+
}
19+
20+
export const validateShippingStepId = "validate-shipping"
21+
/**
22+
* This step validates shipping data when cart is completed.
23+
*
24+
* It ensures that a shipping method is selected if there is an item in the cart that requires shipping.
25+
* It also ensures that product's shipping profile mathes the selected shipping options.
26+
*/
27+
export const validateShippingStep = createStep(
28+
validateShippingStepId,
29+
async (data: ValidateShippingInput) => {
30+
const { cart, shippingOptions } = data
31+
32+
const optionProfileMap: Map<string, string> = new Map(
33+
shippingOptions.map((option) => [option.id, option.shipping_profile_id])
34+
)
35+
36+
const cartItemsWithShipping =
37+
cart.items?.filter((item) => item.requires_shipping) || []
38+
39+
const cartShippingMethods = cart.shipping_methods || []
40+
41+
if (cartItemsWithShipping.length > 0 && cartShippingMethods.length === 0) {
42+
throw new MedusaError(
43+
MedusaError.Types.INVALID_DATA,
44+
"No shipping method selected but the cart contains items that require shipping."
45+
)
46+
}
47+
48+
const requiredShippingPorfiles = cartItemsWithShipping.map(
49+
(item) => (item.variant.product as any)?.shipping_profile?.id
50+
)
51+
52+
const availableShippingPorfiles = cartShippingMethods.map((method) =>
53+
optionProfileMap.get(method.shipping_option_id!)
54+
)
55+
56+
const missingShippingPorfiles = requiredShippingPorfiles.filter(
57+
(profile) => !availableShippingPorfiles.includes(profile)
58+
)
59+
60+
if (missingShippingPorfiles.length > 0) {
61+
throw new MedusaError(
62+
MedusaError.Types.INVALID_DATA,
63+
"The cart items require shipping profiles that are not satisfied by the current shipping methods"
64+
)
65+
}
66+
67+
return new StepResponse(void 0)
68+
}
69+
)

packages/core/core-flows/src/cart/utils/fields.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export const completeCartFields = [
9999
"payment_collection.payment_sessions.*",
100100
"items.variant.id",
101101
"items.variant.product.id",
102+
"items.variant.product.shipping_profile.id",
102103
"items.variant.manage_inventory",
103104
"items.variant.allow_backorder",
104105
"items.variant.inventory_items.inventory_item_id",

packages/core/core-flows/src/cart/workflows/complete-cart.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import {
2525
import { createOrdersStep } from "../../order/steps/create-orders"
2626
import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session"
2727
import { registerUsageStep } from "../../promotion/steps/register-usage"
28-
import { updateCartsStep, validateCartPaymentsStep } from "../steps"
28+
import {
29+
updateCartsStep,
30+
validateCartPaymentsStep,
31+
validateShippingStep,
32+
} from "../steps"
2933
import { reserveInventoryStep } from "../steps/reserve-inventory"
3034
import { completeCartFields } from "../utils/fields"
3135
import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input"
@@ -101,6 +105,8 @@ export const completeCartWorkflow = createWorkflow(
101105
fields: completeCartFields,
102106
variables: { id: input.id },
103107
list: false,
108+
}).config({
109+
name: "cart-query",
104110
})
105111

106112
const validate = createHook("validate", {
@@ -112,6 +118,21 @@ export const completeCartWorkflow = createWorkflow(
112118
const order = when("create-order", { orderId }, ({ orderId }) => {
113119
return !orderId
114120
}).then(() => {
121+
const cartOptionIds = transform({ cart }, ({ cart }) => {
122+
return cart.shipping_methods?.map((sm) => sm.shipping_option_id)
123+
})
124+
125+
const shippingOptions = useRemoteQueryStep({
126+
entry_point: "shipping_option",
127+
fields: ["id", "shipping_profile_id"],
128+
variables: { id: cartOptionIds },
129+
list: true,
130+
}).config({
131+
name: "shipping-options-query",
132+
})
133+
134+
validateShippingStep({ cart, shippingOptions })
135+
115136
const paymentSessions = validateCartPaymentsStep({ cart })
116137

117138
const payment = authorizePaymentSessionStep({

0 commit comments

Comments
 (0)