From fdc2b722d91f446499123d71386e64d9da9265eb Mon Sep 17 00:00:00 2001 From: Nicolas Gorga <62995075+NicolasGorga@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:49:16 -0300 Subject: [PATCH] fix(core-flows, medusa): Prevent cart addresses duplication on update (#13841) * Allow id field in addresses properties for cart update validator * Update cart addresses in update step where id is provided, both reference and nested fields * Add tests * Add changeset * Remove unnecessary map step * Review changes --- .changeset/rare-crabs-cheer.md | 7 + .../cart/store/cart.workflows.spec.ts | 284 ++++++++++++++++++ .../core-flows/src/cart/steps/update-carts.ts | 43 ++- .../medusa/src/api/store/carts/validators.ts | 8 +- 4 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 .changeset/rare-crabs-cheer.md diff --git a/.changeset/rare-crabs-cheer.md b/.changeset/rare-crabs-cheer.md new file mode 100644 index 0000000000..114ea9e3b6 --- /dev/null +++ b/.changeset/rare-crabs-cheer.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +--- + +fix(medusa): Allow 'id' in shipping_address and billing_address in validator of store udpate cart route +fix(core-flows): Update addresses nested fields when 'id' is provided in payload for update-carts step 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 ece9dbb2b7..9cd55f7355 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -1444,6 +1444,290 @@ medusaIntegrationTestRunner({ ) expect(cart.items?.length).toEqual(1) }) + + it("should update cart shipping address fields", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const regions = await regionModuleService.createRegions([ + { + name: "US", + currency_code: "usd", + countries: ["us"], + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: regions[0].id, + shipping_address: { + first_name: "John", + last_name: "Doe", + address_1: "123 Main St", + city: "New York", + country_code: "us", + postal_code: "10001", + }, + }) + + const shippingAddressId = cart.shipping_address?.id + + await updateCartWorkflow(appContainer).run({ + input: { + id: cart.id, + shipping_address: { + id: shippingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "456 Oak Ave", + city: "Los Angeles", + country_code: "us", + postal_code: "90001", + }, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["shipping_address"], + }) + + expect(cart.shipping_address).toEqual( + expect.objectContaining({ + id: shippingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "456 Oak Ave", + city: "Los Angeles", + country_code: "us", + postal_code: "90001", + }) + ) + }) + + it("should update cart billing address fields", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const regions = await regionModuleService.createRegions([ + { + name: "US", + currency_code: "usd", + countries: ["us"], + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: regions[0].id, + billing_address: { + first_name: "John", + last_name: "Doe", + address_1: "123 Main St", + city: "New York", + country_code: "us", + postal_code: "10001", + }, + }) + + const billingAddressId = cart.billing_address?.id + + await updateCartWorkflow(appContainer).run({ + input: { + id: cart.id, + billing_address: { + id: billingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "456 Oak Ave", + city: "Los Angeles", + country_code: "us", + postal_code: "90001", + }, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["billing_address"], + }) + + expect(cart.billing_address).toEqual( + expect.objectContaining({ + id: billingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "456 Oak Ave", + city: "Los Angeles", + country_code: "us", + postal_code: "90001", + }) + ) + }) + + it("should update both shipping and billing addresses simultaneously", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const regions = await regionModuleService.createRegions([ + { + name: "US", + currency_code: "usd", + countries: ["us"], + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: regions[0].id, + shipping_address: { + first_name: "John", + last_name: "Doe", + address_1: "123 Main St", + city: "New York", + country_code: "us", + postal_code: "10001", + }, + billing_address: { + first_name: "John", + last_name: "Doe", + address_1: "789 Business Blvd", + city: "Chicago", + country_code: "us", + postal_code: "60601", + }, + }) + + const shippingAddressId = cart.shipping_address?.id + const billingAddressId = cart.billing_address?.id + + await updateCartWorkflow(appContainer).run({ + input: { + id: cart.id, + shipping_address: { + id: shippingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "456 Oak Ave", + city: "Los Angeles", + country_code: "us", + postal_code: "90001", + }, + billing_address: { + id: billingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "321 Corporate Dr", + city: "San Francisco", + country_code: "us", + postal_code: "94102", + }, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["shipping_address", "billing_address"], + }) + + expect(cart.shipping_address).toEqual( + expect.objectContaining({ + id: shippingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "456 Oak Ave", + city: "Los Angeles", + postal_code: "90001", + }) + ) + + expect(cart.billing_address).toEqual( + expect.objectContaining({ + id: billingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "321 Corporate Dr", + city: "San Francisco", + postal_code: "94102", + }) + ) + }) + + it("should rollback address updates on workflow failure", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const regions = await regionModuleService.createRegions([ + { + name: "US", + currency_code: "usd", + countries: ["us"], + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: regions[0].id, + shipping_address: { + first_name: "John", + last_name: "Doe", + address_1: "123 Main St", + city: "New York", + country_code: "us", + postal_code: "10001", + }, + }) + + const originalShippingAddress = { ...cart.shipping_address } + const shippingAddressId = cart.shipping_address?.id + + const workflow = updateCartWorkflow(appContainer) + + workflow.appendAction("throw", "update-carts", { + invoke: async function failStep() { + throw new Error("Simulated failure") + }, + }) + + const { errors } = await workflow.run({ + input: { + id: cart.id, + shipping_address: { + id: shippingAddressId, + first_name: "Jane", + last_name: "Smith", + address_1: "456 Oak Ave", + city: "Los Angeles", + country_code: "us", + postal_code: "90001", + }, + }, + throwOnError: false, + }) + + expect(errors).toBeDefined() + expect(errors?.length).toBeGreaterThan(0) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["shipping_address"], + }) + + expect(cart.shipping_address).toEqual( + expect.objectContaining({ + id: shippingAddressId, + first_name: originalShippingAddress.first_name, + last_name: originalShippingAddress.last_name, + address_1: originalShippingAddress.address_1, + city: originalShippingAddress.city, + postal_code: originalShippingAddress.postal_code, + }) + ) + }) }) describe("AddToCartWorkflow", () => { diff --git a/packages/core/core-flows/src/cart/steps/update-carts.ts b/packages/core/core-flows/src/cart/steps/update-carts.ts index 2d5962e615..23f48350be 100644 --- a/packages/core/core-flows/src/cart/steps/update-carts.ts +++ b/packages/core/core-flows/src/cart/steps/update-carts.ts @@ -1,5 +1,6 @@ import { ICartModuleService, + UpdateAddressDTO, UpdateCartDTO, UpdateCartWorkflowInputDTO, } from "@medusajs/framework/types" @@ -46,17 +47,53 @@ export const updateCartsStep = createStep( { select: selects, relations } ) + // Since service factory udpate method will correctly keep the reference to the addresses, + // but won't update its fields, we do this separately + const addressesInput = data + .flatMap((cart) => [cart.shipping_address, cart.billing_address]) + .filter((address) => !!address) + let addressesToUpdateIds: string[] = [] + const addressesToUpdate = addressesInput.filter( + (address): address is UpdateAddressDTO => { + if ("id" in address && !!address.id) { + addressesToUpdateIds.push(address.id as string) + return true + } + return false + } + ) + const addressesBeforeUpdate = await cartModule.listAddresses({ + id: addressesToUpdate.map((address) => address.id), + }) + if (addressesToUpdate.length) { + await cartModule.updateAddresses(addressesToUpdate) + } + const updatedCart = await cartModule.updateCarts(data) - return new StepResponse(updatedCart, cartsBeforeUpdate) + return new StepResponse(updatedCart, { + cartsBeforeUpdate, + addressesBeforeUpdate, + }) }, - async (cartsBeforeUpdate, { container }) => { - if (!cartsBeforeUpdate) { + async (dataToCompensate, { container }) => { + if (!dataToCompensate) { return } + const { cartsBeforeUpdate, addressesBeforeUpdate } = dataToCompensate + const cartModule = container.resolve(Modules.CART) + const addressesToUpdate: UpdateAddressDTO[] = [] + for (const address of addressesBeforeUpdate) { + addressesToUpdate.push({ + ...address, + metadata: address.metadata ?? undefined + }) + } + await cartModule.updateAddresses(addressesToUpdate) + const dataToUpdate: UpdateCartDTO[] = [] for (const cart of cartsBeforeUpdate) { diff --git a/packages/medusa/src/api/store/carts/validators.ts b/packages/medusa/src/api/store/carts/validators.ts index e9b1100fc6..6d39694363 100644 --- a/packages/medusa/src/api/store/carts/validators.ts +++ b/packages/medusa/src/api/store/carts/validators.ts @@ -43,13 +43,17 @@ export const StoreRemoveCartPromotions = z }) .strict() +const StoreCartUpsertAddress = AddressPayload.merge(z.object({ + id: z.string().optional(), +})) + export type StoreUpdateCartType = z.infer export const UpdateCart = z .object({ region_id: z.string().optional(), email: z.string().email().nullish(), - billing_address: z.union([AddressPayload, z.string()]).optional(), - shipping_address: z.union([AddressPayload, z.string()]).optional(), + billing_address: z.union([StoreCartUpsertAddress, z.string()]).optional(), + shipping_address: z.union([StoreCartUpsertAddress, z.string()]).optional(), sales_channel_id: z.string().nullish(), metadata: z.record(z.unknown()).nullish(), promo_codes: z.array(z.string()).optional(),