diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index a1e9734abd..9792ef5e30 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -1123,6 +1123,102 @@ medusaIntegrationTestRunner({ ) }) + it("should successfully complete cart without shipping for digital products", async () => { + /** + * Product has a shipping profile so cart item should not require shipping + */ + const product = ( + await api.post( + `/admin/products`, + { + title: "Product without inventory management", + 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", + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + + let cart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + shipping_address: shippingAddressData, + }, + storeHeadersWithCustomer + ) + ).data.cart + + cart = ( + await api.post( + `/store/carts/${cart.id}/line-items`, + { + variant_id: product.variants[0].id, + quantity: 1, + }, + storeHeaders + ) + ).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 + ) + + expect(cart.items[0].requires_shipping).toEqual(false) + + const response = await api.post( + `/store/carts/${cart.id}/complete`, + {}, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.order).toEqual( + expect.objectContaining({ + shipping_methods: [], + items: expect.arrayContaining([ + expect.objectContaining({ + requires_shipping: false, + }), + ]), + }) + ) + }) + describe("with sale price lists", () => { let priceList diff --git a/integration-tests/http/__tests__/fixtures/order.ts b/integration-tests/http/__tests__/fixtures/order.ts index 8cf6c2c8a7..fe8bb208c9 100644 --- a/integration-tests/http/__tests__/fixtures/order.ts +++ b/integration-tests/http/__tests__/fixtures/order.ts @@ -114,7 +114,7 @@ export async function createOrderSeeder({ "/admin/products", { title: `Test fixture ${shippingProfile.id}`, - shipping_profile_id: shippingProfile.id, + shipping_profile_id: withoutShipping ? undefined : shippingProfile.id, options: [ { title: "size", values: ["large", "small"] }, { title: "color", values: ["green"] }, diff --git a/integration-tests/http/__tests__/order/admin/order.spec.ts b/integration-tests/http/__tests__/order/admin/order.spec.ts index 9f501b982f..50bada5858 100644 --- a/integration-tests/http/__tests__/order/admin/order.spec.ts +++ b/integration-tests/http/__tests__/order/admin/order.spec.ts @@ -668,7 +668,6 @@ medusaIntegrationTestRunner({ "/admin/products", { title: `Test fixture 2`, - shipping_profile_id: shippingProfile.id, options: [ { title: "size", values: ["large", "small"] }, { title: "color", values: ["green"] }, diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index ff3265f297..c9e1e5bce7 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -990,7 +990,7 @@ medusaIntegrationTestRunner({ is_tax_inclusive: true, is_custom_price: false, quantity: 1, - requires_shipping: true, + requires_shipping: false, // product doesn't have a shipping profile nor inventory items that require shipping subtitle: "Test product", title: "Test variant", unit_price: 3000, @@ -1006,7 +1006,7 @@ medusaIntegrationTestRunner({ metadata: { foo: "bar", }, - requires_shipping: true, + requires_shipping: true, // overriden when adding to cart subtitle: "Test subtitle", thumbnail: "some-url", title: "Test item", @@ -1040,7 +1040,7 @@ medusaIntegrationTestRunner({ is_tax_inclusive: false, is_custom_price: false, quantity: 1, - requires_shipping: true, + requires_shipping: false, subtitle: "Test product", title: "Test variant", unit_price: 2000, diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 1a33af58e9..7bf081a5a3 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -1218,7 +1218,6 @@ medusaIntegrationTestRunner({ "/admin/products", { title: "Test fixture", - shipping_profile_id: shippingProfile.id, options: [ { title: "size", values: ["large", "small"] }, { title: "color", values: ["green"] }, diff --git a/packages/admin/dashboard/src/components/inputs/combobox/combobox.tsx b/packages/admin/dashboard/src/components/inputs/combobox/combobox.tsx index 6302f036c7..a8eeb128b8 100644 --- a/packages/admin/dashboard/src/components/inputs/combobox/combobox.tsx +++ b/packages/admin/dashboard/src/components/inputs/combobox/combobox.tsx @@ -57,6 +57,7 @@ interface ComboboxProps isFetchingNextPage?: boolean onCreateOption?: (value: string) => void noResultsPlaceholder?: ReactNode + allowClear?: boolean } const ComboboxImpl = ( @@ -72,6 +73,7 @@ const ComboboxImpl = ( isFetchingNextPage, onCreateOption, noResultsPlaceholder, + allowClear, ...inputProps }: ComboboxProps, ref: ForwardedRef @@ -303,6 +305,18 @@ const ComboboxImpl = ( {...inputProps} /> + {allowClear && controlledValue && ( + + )} { return ( diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx index a366cc195d..8c2e9c79c7 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-form/product-create-form.tsx @@ -181,15 +181,6 @@ export const ProductCreateForm = ({ } if (currentTab === Tab.ORGANIZE) { - // TODO: this is temp until we add partial validation per tab - if (!form.getValues("shipping_profile_id")) { - form.setError("shipping_profile_id", { - type: "required", - message: t("products.shippingProfile.create.errors.required"), - }) - return - } - setTab(Tab.VARIANTS) } diff --git a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-organize-form/components/product-create-organize-section/product-create-details-organize-section.tsx b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-organize-form/components/product-create-organize-section/product-create-details-organize-section.tsx index dcd2a4f452..a0ca9e5260 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/components/product-create-organize-form/components/product-create-organize-section/product-create-details-organize-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-create/components/product-create-organize-form/components/product-create-organize-section/product-create-details-organize-section.tsx @@ -173,7 +173,9 @@ export const ProductCreateOrganizationSection = ({
- {t("products.fields.shipping_profile.label")} + + {t("products.fields.shipping_profile.label")} + diff --git a/packages/admin/dashboard/src/routes/products/product-create/constants.ts b/packages/admin/dashboard/src/routes/products/product-create/constants.ts index 8f35149355..0668e3f04d 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/constants.ts +++ b/packages/admin/dashboard/src/routes/products/product-create/constants.ts @@ -1,7 +1,7 @@ import { z } from "zod" -import { i18n } from "../../../components/utilities/i18n/i18n.tsx" -import { optionalFloat, optionalInt } from "../../../lib/validation.ts" -import { decorateVariantsWithDefaultValues } from "./utils.ts" +import { i18n } from "../../../components/utilities/i18n/i18n" +import { optionalFloat, optionalInt } from "../../../lib/validation" +import { decorateVariantsWithDefaultValues } from "./utils" export const MediaSchema = z.object({ id: z.string().optional(), @@ -64,7 +64,7 @@ export const ProductCreateSchema = z discountable: z.boolean(), type_id: z.string().optional(), collection_id: z.string().optional(), - shipping_profile_id: z.string(), // TODO: require min(1) when partial validation per tab is added + shipping_profile_id: z.string().optional(), categories: z.array(z.string()), tags: z.array(z.string()).optional(), sales_channels: z diff --git a/packages/admin/dashboard/src/routes/products/product-create/utils.ts b/packages/admin/dashboard/src/routes/products/product-create/utils.ts index 940877b892..d2f8ad59ae 100644 --- a/packages/admin/dashboard/src/routes/products/product-create/utils.ts +++ b/packages/admin/dashboard/src/routes/products/product-create/utils.ts @@ -24,7 +24,7 @@ export const normalizeProductFormValues = ( : undefined, images, collection_id: values.collection_id || undefined, - shipping_profile_id: values.shipping_profile_id, + shipping_profile_id: values.shipping_profile_id || undefined, categories: values.categories.map((id) => ({ id })), type_id: values.type_id || undefined, handle: values.handle || undefined, diff --git a/packages/admin/dashboard/src/routes/products/product-shipping-profile/components/product-organization-form/product-shipping-profile-form.tsx b/packages/admin/dashboard/src/routes/products/product-shipping-profile/components/product-organization-form/product-shipping-profile-form.tsx index ef15e13326..b48e4dd1d8 100644 --- a/packages/admin/dashboard/src/routes/products/product-shipping-profile/components/product-organization-form/product-shipping-profile-form.tsx +++ b/packages/admin/dashboard/src/routes/products/product-shipping-profile/components/product-organization-form/product-shipping-profile-form.tsx @@ -7,12 +7,13 @@ import { Form } from "../../../../../components/common/form" import { Combobox } from "../../../../../components/inputs/combobox" import { RouteDrawer, useRouteModal } from "../../../../../components/modals" import { KeyboundForm } from "../../../../../components/utilities/keybound-form" -import { useExtendableForm } from "../../../../../extensions" import { useUpdateProduct } from "../../../../../hooks/api/products" import { useComboboxData } from "../../../../../hooks/use-combobox-data" import { sdk } from "../../../../../lib/client" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" +import { se } from "date-fns/locale" +import { useEffect } from "react" type ProductShippingProfileFormProps = { product: HttpTypes.AdminProduct & { @@ -47,12 +48,15 @@ export const ProductShippingProfileForm = ({ resolver: zodResolver(ProductShippingProfileSchema), }) + const selectedShippingProfile = form.watch("shipping_profile_id") + const { mutateAsync, isPending } = useUpdateProduct(product.id) const handleSubmit = form.handleSubmit(async (data) => { await mutateAsync( { - shipping_profile_id: data.shipping_profile_id, + shipping_profile_id: + data.shipping_profile_id === "" ? null : data.shipping_profile_id, }, { onSuccess: ({ product }) => { @@ -70,6 +74,12 @@ export const ProductShippingProfileForm = ({ ) }) + useEffect(() => { + if (typeof selectedShippingProfile === "undefined") { + form.setValue("shipping_profile_id", "") + } + }, [selectedShippingProfile]) + return ( @@ -87,6 +97,7 @@ export const ProductShippingProfileForm = ({ | null } +type AddItemProductDTO = ProductDTO & { + shipping_profile: { id: string } +} + export interface PrepareVariantLineItemInput extends ProductVariantDTO { inventory_items: { inventory: InventoryItemDTO }[] calculated_price: { @@ -106,17 +111,19 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) { compareAtUnitPrice = variant.calculated_price.original_amount } - // Note: If any of the items require shipping, we enable fulfillment - // unless explicitly set to not require shipping by the item in the request - const someInventoryRequiresShipping = variant?.inventory_items?.length - ? variant.inventory_items.some( - (inventoryItem) => !!inventoryItem.inventory.requires_shipping - ) - : true + const hasShippingProfile = isDefined( + (variant?.product as AddItemProductDTO)?.shipping_profile?.id + ) + const someInventoryRequiresShipping = !!variant?.inventory_items?.some( + (inventoryItem) => !!inventoryItem.inventory.requires_shipping + ) + + // Note: If any of the items require shipping or product has a shipping profile set, + // we enable fulfillment unless explicitly set to not require shipping by the item in the request const requiresShipping = isDefined(item?.requires_shipping) ? item.requires_shipping - : someInventoryRequiresShipping + : hasShippingProfile || someInventoryRequiresShipping let lineItem: any = { quantity: item?.quantity, diff --git a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts index 75fde56594..1b2e64d317 100644 --- a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts @@ -35,12 +35,12 @@ const cartFields = ["completed_at"].concat(cartFieldsForPricingContext) export const addToCartWorkflowId = "add-to-cart" /** - * This workflow adds a product variant to a cart as a line item. It's executed by the + * This workflow adds a product variant to a cart as a line item. It's executed by the * [Add Line Item Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidlineitems). - * + * * You can use this workflow within your own customizations or custom workflows, allowing you to wrap custom logic around adding an item to the cart. * For example, you can use this workflow to add a line item to the cart with a custom price. - * + * * @example * const { result } = await addToCartWorkflow(container) * .run({ @@ -59,11 +59,11 @@ export const addToCartWorkflowId = "add-to-cart" * ] * } * }) - * + * * @summary - * + * * Add a line item to a cart. - * + * * @property hooks.validate - This hook is executed before all operations. You can consume this hook to perform any custom validation. If validation fails, you can throw an error to stop the workflow execution. */ export const addToCartWorkflow = createWorkflow( diff --git a/packages/core/core-flows/src/product/workflows/create-products.ts b/packages/core/core-flows/src/product/workflows/create-products.ts index d0884fbca4..d5469c7efb 100644 --- a/packages/core/core-flows/src/product/workflows/create-products.ts +++ b/packages/core/core-flows/src/product/workflows/create-products.ts @@ -19,11 +19,7 @@ import { transform, createStep, } from "@medusajs/framework/workflows-sdk" -import { - createRemoteLinkStep, - emitEventStep, - useQueryGraphStep, -} from "../../common" +import { createRemoteLinkStep, emitEventStep } from "../../common" import { associateProductsWithSalesChannelsStep } from "../../sales-channel" import { createProductsStep } from "../steps/create-products" import { createProductVariantsWorkflow } from "./create-product-variants" @@ -36,16 +32,6 @@ export interface ValidateProductInputStepInput { * The products to validate. */ products: Omit[] - - /** - * The shipping profiles to validate. - */ - shippingProfiles: { - /** - * The shipping profile's ID. - */ - id: string - }[] } const validateProductInputStepId = "validate-product-input" @@ -87,7 +73,7 @@ const validateProductInputStepId = "validate-product-input" export const validateProductInputStep = createStep( validateProductInputStepId, async (data: ValidateProductInputStepInput) => { - const { products, shippingProfiles } = data + const { products } = data const missingOptionsProductTitles = products .filter((product) => !product.options?.length) @@ -101,25 +87,6 @@ export const validateProductInputStep = createStep( )}].` ) } - - const existingProfileIds = new Set(shippingProfiles.map((p) => p.id)) - - const missingShippingProfileProductTitles = products - .filter( - (product) => - !product.shipping_profile_id || - !existingProfileIds.has(product.shipping_profile_id) - ) - .map((product) => product.title) - - if (missingShippingProfileProductTitles.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Shipping profile is not provided for: [${missingShippingProfileProductTitles.join( - ", " - )}].` - ) - } } ) @@ -190,14 +157,10 @@ export const createProductsWorkflow = createWorkflow( createProductsWorkflowId, (input: WorkflowData) => { // Passing prices to the product module will fail, we want to keep them for after the product is created. - const { products: productWithoutExternalRelations, shippingPorfileIds } = - transform({ input }, (data) => { - const shippingPorfileIds: string[] = [] + const { products: productWithoutExternalRelations } = transform( + { input }, + (data) => { const productsData = data.input.products.map((p) => { - if (p.shipping_profile_id) { - shippingPorfileIds.push(p.shipping_profile_id) - } - return { ...p, sales_channels: undefined, @@ -206,18 +169,11 @@ export const createProductsWorkflow = createWorkflow( } }) - return { products: productsData, shippingPorfileIds } - }) + return { products: productsData } + } + ) - const { data: shippingProfiles } = useQueryGraphStep({ - entity: "shipping_profile", - fields: ["id"], - filters: { - id: shippingPorfileIds, - }, - }) - - validateProductInputStep({ products: input.products, shippingProfiles }) + validateProductInputStep({ products: input.products }) const createdProducts = createProductsStep(productWithoutExternalRelations) @@ -240,16 +196,18 @@ export const createProductsWorkflow = createWorkflow( const shippingProfileLinks = transform( { input, createdProducts }, (data) => { - return data.createdProducts.map((createdProduct, i) => { - return { - [Modules.PRODUCT]: { - product_id: createdProduct.id, - }, - [Modules.FULFILLMENT]: { - shipping_profile_id: data.input.products[i].shipping_profile_id, - }, - } - }) + return data.createdProducts + .map((createdProduct, i) => { + return { + [Modules.PRODUCT]: { + product_id: createdProduct.id, + }, + [Modules.FULFILLMENT]: { + shipping_profile_id: data.input.products[i].shipping_profile_id, + }, + } + }) + .filter((link) => !!link[Modules.FULFILLMENT].shipping_profile_id) } ) diff --git a/packages/core/core-flows/src/product/workflows/update-products.ts b/packages/core/core-flows/src/product/workflows/update-products.ts index 4e8c582ae8..5cd61245a5 100644 --- a/packages/core/core-flows/src/product/workflows/update-products.ts +++ b/packages/core/core-flows/src/product/workflows/update-products.ts @@ -10,6 +10,7 @@ import { Modules, ProductWorkflowEvents, arrayDifference, + isDefined, } from "@medusajs/framework/utils" import { WorkflowData, @@ -50,7 +51,7 @@ export type UpdateProductsWorkflowInputSelector = { /** * The shipping profile to set. */ - shipping_profile_id?: string + shipping_profile_id?: string | null } } & AdditionalData @@ -73,7 +74,7 @@ export type UpdateProductsWorkflowInputProducts = { /** * The shipping profile to set. */ - shipping_profile_id?: string + shipping_profile_id?: string | null })[] } & AdditionalData @@ -152,12 +153,12 @@ function findProductsWithShippingProfiles({ if ("products" in input) { const discardedProductIds: string[] = input.products - .filter((p) => !p.shipping_profile_id) + .filter((p) => !isDefined(p.shipping_profile_id)) .map((p) => p.id as string) return arrayDifference(productIds, discardedProductIds) } - return !input.update?.shipping_profile_id ? [] : productIds + return !isDefined(input.update?.shipping_profile_id) ? [] : productIds } function prepareSalesChannelLinks({ @@ -215,7 +216,7 @@ function prepareShippingProfileLinks({ } return input.products - .filter((p) => p.shipping_profile_id) + .filter((p) => typeof p.shipping_profile_id === "string") .map((p) => ({ [Modules.PRODUCT]: { product_id: p.id, @@ -226,7 +227,7 @@ function prepareShippingProfileLinks({ })) } - if (input.selector && input.update?.shipping_profile_id) { + if (input.selector && typeof input.update?.shipping_profile_id === "string") { return updatedProducts.map((p) => ({ [Modules.PRODUCT]: { product_id: p.id, diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index 678f135c06..26b6814445 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -213,7 +213,7 @@ export interface AdminCreateProduct { /** * The ID of the product's shipping profile. */ - shipping_profile_id: string + shipping_profile_id?: string /** * The product's categories. */ @@ -467,7 +467,7 @@ export interface AdminUpdateProduct { /** * The ID of the product's shipping profile. */ - shipping_profile_id?: string + shipping_profile_id?: string | null /** * The product's weight. */ diff --git a/packages/core/types/src/workflows/products/mutations.ts b/packages/core/types/src/workflows/products/mutations.ts index b62015107a..7214570532 100644 --- a/packages/core/types/src/workflows/products/mutations.ts +++ b/packages/core/types/src/workflows/products/mutations.ts @@ -36,7 +36,7 @@ export type CreateProductWorkflowInputDTO = Omit< /** * The product's shipping profile. */ - shipping_profile_id: string + shipping_profile_id?: string /** * The product's variants. */ @@ -45,5 +45,5 @@ export type CreateProductWorkflowInputDTO = Omit< export type UpdateProductWorkflowInputDTO = ProductTypes.UpsertProductDTO & { sales_channels?: { id: string }[] - shipping_profile_id?: string + shipping_profile_id?: string | null } diff --git a/packages/medusa/src/api/admin/products/validators.ts b/packages/medusa/src/api/admin/products/validators.ts index 2ddeb2859d..1cf590765e 100644 --- a/packages/medusa/src/api/admin/products/validators.ts +++ b/packages/medusa/src/api/admin/products/validators.ts @@ -232,7 +232,7 @@ export const CreateProduct = z options: z.array(CreateProductOption).optional(), variants: z.array(CreateProductVariant).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(), - shipping_profile_id: z.string(), + shipping_profile_id: z.string().optional(), weight: z.number().nullish(), length: z.number().nullish(), height: z.number().nullish(), @@ -267,7 +267,7 @@ export const UpdateProduct = z categories: z.array(IdAssociation).optional(), tags: z.array(IdAssociation).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(), - shipping_profile_id: z.string().optional(), + shipping_profile_id: z.string().nullish(), weight: z.number().nullish(), length: z.number().nullish(), height: z.number().nullish(),