diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index d567e863c86bb..00c89075321d6 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -316,6 +316,49 @@ medusaIntegrationTestRunner({ ) }) + it("should create cart with shipping address country code when there is only one country assigned to the region", async () => { + const region = await regionModule.createRegions({ + name: "US", + currency_code: "usd", + countries: ["us"], + }) + + const response = await api.post( + `/store/carts`, + { + email: "tony@stark.com", + currency_code: "usd", + region_id: region.id, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: response.data.cart.id, + currency_code: "usd", + email: "tony@stark.com", + region: expect.objectContaining({ + id: region.id, + }), + shipping_address: { + id: expect.any(String), + country_code: "us", + first_name: null, + last_name: null, + company: null, + address_1: null, + address_2: null, + city: null, + province: null, + postal_code: null, + phone: null, + }, + }) + ) + }) + it("should create cart with default store sales channel", async () => { const sc = await scModule.createSalesChannels({ name: "Webshop", @@ -711,12 +754,6 @@ medusaIntegrationTestRunner({ it("should not generate tax lines if region is not present or automatic taxes is false", async () => { await setupTaxStructure(taxModule) - const region = await regionModule.createRegions({ - name: "US", - currency_code: "usd", - automatic_taxes: false, - }) - const cart = await cartModule.createCarts({ currency_code: "usd", email: "tony@stark.com", @@ -759,13 +796,15 @@ medusaIntegrationTestRunner({ }) ) - await cartModule.updateCarts(cart.id, { - region_id: region.id, + const region = await regionModule.createRegions({ + name: "US", + currency_code: "usd", + automatic_taxes: false, }) updated = await api.post( `/store/carts/${cart.id}`, - { email: "another@tax.com" }, + { email: "another@tax.com", region_id: region.id }, storeHeaders ) @@ -790,6 +829,7 @@ medusaIntegrationTestRunner({ const region = await regionModule.createRegions({ name: "us", currency_code: "usd", + countries: ["us"], }) const salesChannel = await scModule.createSalesChannels({ @@ -917,19 +957,21 @@ medusaIntegrationTestRunner({ }), sales_channel_id: salesChannel.id, shipping_address: expect.objectContaining({ - city: "ny", + // We clear the shipping address on region update and only set the country code if the region has one country + city: null, country_code: "us", - province: "ny", + province: null, }), shipping_methods: expect.arrayContaining([ expect.objectContaining({ shipping_option_id: shippingOption2.id, amount: 500, tax_lines: [ + // Since we clear the shipping address on region update, the tax lines are computed based on the new country code expect.objectContaining({ - description: "NY Default Rate", - code: "NYDEFAULT", - rate: 6, + description: "US Default Rate", + code: "US_DEF", + rate: 2, provider_id: "system", }), ], @@ -939,10 +981,11 @@ medusaIntegrationTestRunner({ shipping_option_id: shippingOption.id, amount: 500, tax_lines: [ + // Since we clear the shipping address on region update, the tax lines are computed based on the new country code expect.objectContaining({ - description: "NY Default Rate", - code: "NYDEFAULT", - rate: 6, + description: "US Default Rate", + code: "US_DEF", + rate: 2, provider_id: "system", }), ], @@ -954,9 +997,9 @@ medusaIntegrationTestRunner({ id: "item-1", tax_lines: [ expect.objectContaining({ - description: "NY Default Rate", - code: "NYDEFAULT", - rate: 6, + description: "US Default Rate", + code: "US_DEF", + rate: 2, provider_id: "system", }), ], @@ -966,55 +1009,187 @@ medusaIntegrationTestRunner({ }) ) - updated = await api.post( - `/store/carts/${cart.id}`, + // updated = await api.post( + // `/store/carts/${cart.id}`, + // { + // email: null, + // sales_channel_id: null, + // }, + // storeHeaders + // ) + + // expect(updated.status).toEqual(200) + // expect(updated.data.cart).toEqual( + // expect.objectContaining({ + // id: cart.id, + // currency_code: "usd", + // email: null, + // customer_id: null, + // sales_channel_id: null, + // items: [ + // expect.objectContaining({ + // id: "item-1", + // tax_lines: [ + // expect.objectContaining({ + // description: "NY Default Rate", + // code: "NYDEFAULT", + // rate: 6, + // provider_id: "system", + // }), + // ], + // adjustments: [], + // }), + // ], + // }) + // ) + }) + + it("should update tax lines on cart items when region changes", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.createRegions({ + name: "us", + currency_code: "usd", + countries: ["us"], + }) + + const otherRegion = await regionModule.createRegions({ + name: "dk", + currency_code: "dkk", + countries: ["dk"], + }) + + const salesChannel = await scModule.createSalesChannels({ + name: "Webshop", + }) + + const [productWithDefaultTax] = await productModule.createProducts([ { - email: null, - sales_channel_id: null, + title: "Test product default tax", + variants: [ + { title: "Test variant default tax", manage_inventory: false }, + ], + }, + ]) + + const [priceSetDefaultTax] = await pricingModule.createPriceSets([ + { + prices: [{ amount: 2000, currency_code: "usd" }], + }, + ]) + + await remoteLink.create([ + { + Product: { + variant_id: productWithDefaultTax.variants[0].id, + }, + Pricing: { price_set_id: priceSetDefaultTax.id }, + }, + ]) + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, + }, + adminHeaders + ) + + let response = await api.post( + `/store/carts`, + { + sales_channel_id: salesChannel.id, + shipping_address: { + country_code: "us", + }, + region_id: region.id, + items: [ + { + variant_id: productWithDefaultTax.variants[0].id, + quantity: 1, + }, + ], }, storeHeaders ) - expect(updated.status).toEqual(200) - expect(updated.data.cart).toEqual( + expect(response.data.cart).toEqual( expect.objectContaining({ - id: cart.id, + id: response.data.cart.id, currency_code: "usd", - email: null, - customer_id: null, - sales_channel_id: null, - items: [ + region_id: region.id, + items: expect.arrayContaining([ expect.objectContaining({ - id: "item-1", + unit_price: 2000, + quantity: 1, + title: "Test variant default tax", tax_lines: [ + // Uses the california default rate expect.objectContaining({ - description: "NY Default Rate", - code: "NYDEFAULT", - rate: 6, + description: "US Default Rate", + code: "US_DEF", + rate: 2, provider_id: "system", }), ], - adjustments: [], }), - ], + ]), + }) + ) + + response = await api.post( + `/store/carts/${response.data.cart.id}`, + { + region_id: otherRegion.id, + shipping_address: { + country_code: "dk", + }, + }, + storeHeaders + ) + + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: response.data.cart.id, + currency_code: "dkk", + region_id: otherRegion.id, + items: expect.arrayContaining([ + expect.objectContaining({ + unit_price: 2000, + quantity: 1, + title: "Test variant default tax", + tax_lines: [ + // Uses the danish default rate + expect.objectContaining({ + description: "Denmark Default Rate", + code: "DK_DEF", + rate: 25, + provider_id: "system", + }), + ], + }), + ]), }) ) }) - it("should update tax lines on cart items when region changes", async () => { + it("should update tax inclusivity on cart items when region changes", async () => { await setupTaxStructure(taxModule) - const region = await regionModule.createRegions({ - name: "us", - currency_code: "usd", - countries: ["us"], - }) - - const otherRegion = await regionModule.createRegions({ - name: "dk", - currency_code: "dkk", - countries: ["dk"], - }) + const [region, otherRegion] = await regionModule.createRegions([ + { + name: "us", + currency_code: "usd", + countries: ["us"], + }, + { + name: "dk", + currency_code: "dkk", + countries: ["dk"], + }, + ]) const salesChannel = await scModule.createSalesChannels({ name: "Webshop", @@ -1132,6 +1307,232 @@ medusaIntegrationTestRunner({ ) }) + it("should update region + set shipping address country code to dk when region has only one country", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.createRegions({ + name: "us", + currency_code: "usd", + countries: ["us"], + }) + + const otherRegion = await regionModule.createRegions({ + name: "dk", + currency_code: "usd", + countries: ["dk"], + }) + + const cart = await cartModule.createCarts({ + currency_code: "eur", + region_id: region.id, + email: "tony@stark.com", + shipping_address: { + country_code: "us", + }, + }) + + const updated = await api.post( + `/store/carts/${cart.id}`, + { + region_id: otherRegion.id, + }, + storeHeaders + ) + + expect(updated.status).toEqual(200) + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + region: expect.objectContaining({ + id: otherRegion.id, + currency_code: "usd", + countries: [expect.objectContaining({ iso_2: "dk" })], + }), + shipping_address: expect.objectContaining({ + country_code: "dk", + }), + }) + ) + }) + + it("should update region + set shipping address to null when region has more than one country", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.createRegions({ + name: "us", + currency_code: "usd", + countries: ["us"], + }) + + const otherRegion = await regionModule.createRegions({ + name: "dk", + currency_code: "eur", + countries: ["dk", "no"], + }) + + const cart = await cartModule.createCarts({ + currency_code: "eur", + region_id: region.id, + email: "tony@stark.com", + shipping_address: { + country_code: "us", + }, + }) + + const updated = await api.post( + `/store/carts/${cart.id}`, + { + region_id: otherRegion.id, + }, + storeHeaders + ) + + expect(updated.status).toEqual(200) + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "eur", + region: expect.objectContaining({ + id: otherRegion.id, + currency_code: "eur", + countries: [ + expect.objectContaining({ iso_2: "dk" }), + expect.objectContaining({ iso_2: "no" }), + ], + }), + shipping_address: null, + }) + ) + }) + + it("should update region and shipping address when country code is within region", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.createRegions({ + name: "us", + currency_code: "usd", + countries: ["us"], + }) + + const otherRegion = await regionModule.createRegions({ + name: "dk", + currency_code: "eur", + countries: ["dk", "no"], + }) + + const cart = await cartModule.createCarts({ + currency_code: "eur", + region_id: region.id, + email: "tony@stark.com", + shipping_address: { + country_code: "us", + }, + }) + + const updated = await api.post( + `/store/carts/${cart.id}`, + { + region_id: otherRegion.id, + shipping_address: { + country_code: "dk", + }, + }, + storeHeaders + ) + + expect(updated.status).toEqual(200) + expect(updated.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "eur", + region: expect.objectContaining({ + id: otherRegion.id, + currency_code: "eur", + countries: [ + expect.objectContaining({ iso_2: "dk" }), + expect.objectContaining({ iso_2: "no" }), + ], + }), + shipping_address: expect.objectContaining({ + country_code: "dk", + }), + }) + ) + }) + + it("should throw when updating shipping address country code when country is not within region", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.createRegions({ + name: "Unites States", + currency_code: "usd", + countries: ["us"], + }) + + const cart = await cartModule.createCarts({ + currency_code: "eur", + email: "tony@stark.com", + region_id: region.id, + shipping_address: { + country_code: "us", + }, + }) + + let errResponse = await api + .post( + `/store/carts/${cart.id}`, + { + shipping_address: { + country_code: "dk", + }, + }, + storeHeaders + ) + .catch((e) => e) + + expect(errResponse.response.status).toEqual(400) + expect(errResponse.response.data.message).toEqual( + `Country with code dk is not within region ${region.name}` + ) + }) + + it("should throw when updating region and shipping address, but shipping address country code is not within region", async () => { + await setupTaxStructure(taxModule) + + const region = await regionModule.createRegions({ + name: "Unites States", + currency_code: "usd", + countries: ["us"], + }) + + const cart = await cartModule.createCarts({ + currency_code: "eur", + email: "tony@stark.com", + shipping_address: { + country_code: "us", + }, + }) + + let errResponse = await api + .post( + `/store/carts/${cart.id}`, + { + region_id: region.id, + shipping_address: { + country_code: "dk", + }, + }, + storeHeaders + ) + .catch((e) => e) + + expect(errResponse.response.status).toEqual(400) + expect(errResponse.response.data.message).toEqual( + `Country with code dk is not within region ${region.name}` + ) + }) + it("should remove invalid shipping methods", async () => { await setupTaxStructure(taxModule) @@ -1714,9 +2115,10 @@ medusaIntegrationTestRunner({ ) expect(response.status).toEqual(200) + expect(response.data.cart.items).toHaveLength(2) expect(response.data.cart).toEqual( expect.objectContaining({ - items: [ + items: expect.arrayContaining([ expect.objectContaining({ unit_price: 1500, quantity: 2, @@ -1727,7 +2129,7 @@ medusaIntegrationTestRunner({ quantity: 1, title: "S / Black", }), - ], + ]), subtotal: 4500, }) ) diff --git a/packages/core/core-flows/src/cart/steps/find-one-or-any-region.ts b/packages/core/core-flows/src/cart/steps/find-one-or-any-region.ts index a6a3d28e523a2..fd2ee158e509e 100644 --- a/packages/core/core-flows/src/cart/steps/find-one-or-any-region.ts +++ b/packages/core/core-flows/src/cart/steps/find-one-or-any-region.ts @@ -18,7 +18,9 @@ export const findOneOrAnyRegionStep = createStep( if (data.regionId) { try { - const region = await service.retrieveRegion(data.regionId) + const region = await service.retrieveRegion(data.regionId, { + relations: ["countries"], + }) return new StepResponse(region) } catch (error) { return new StepResponse(null) @@ -31,9 +33,12 @@ export const findOneOrAnyRegionStep = createStep( throw new MedusaError(MedusaError.Types.NOT_FOUND, "Store not found") } - const [region] = await service.listRegions({ - id: store.default_region_id, - }) + const [region] = await service.listRegions( + { + id: store.default_region_id, + }, + { relations: ["countries"] } + ) if (!region) { return new StepResponse(null) diff --git a/packages/core/core-flows/src/cart/workflows/create-carts.ts b/packages/core/core-flows/src/cart/workflows/create-carts.ts index 30cce402df3e1..e62ca7d673c5e 100644 --- a/packages/core/core-flows/src/cart/workflows/create-carts.ts +++ b/packages/core/core-flows/src/cart/workflows/create-carts.ts @@ -27,9 +27,6 @@ import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-colle import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" -// TODO: The createCartWorkflow are missing the following steps: -// - Refresh/delete shipping methods (fulfillment module) - export const createCartWorkflowId = "create-cart" /** * This workflow creates a cart. @@ -119,6 +116,13 @@ export const createCartWorkflow = createWorkflow( data_.sales_channel_id = data.salesChannel.id } + // If there is only one country in the region, we prepare a shipping address with that country's code. + if (!data.input.shipping_address && data.region.countries.length === 1) { + data_.shipping_address = { + country_code: data.region.countries[0].iso_2, + } + } + return data_ } ) diff --git a/packages/core/core-flows/src/cart/workflows/update-cart.ts b/packages/core/core-flows/src/cart/workflows/update-cart.ts index 6a11cfb4b615b..79fd7c2789df4 100644 --- a/packages/core/core-flows/src/cart/workflows/update-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-cart.ts @@ -2,11 +2,7 @@ import { AdditionalData, UpdateCartWorkflowInputDTO, } from "@medusajs/framework/types" -import { - MedusaError, - PromotionActions, - isPresent, -} from "@medusajs/framework/utils" +import { MedusaError, PromotionActions } from "@medusajs/framework/utils" import { WorkflowData, WorkflowResponse, @@ -14,10 +10,10 @@ import { createWorkflow, parallelize, transform, + when, } from "@medusajs/framework/workflows-sdk" import { useRemoteQueryStep } from "../../common" import { - findOneOrAnyRegionStep, findOrCreateCustomerStep, findSalesChannelStep, refreshCartShippingMethodsStep, @@ -35,35 +31,85 @@ export const updateCartWorkflowId = "update-cart" export const updateCartWorkflow = createWorkflow( updateCartWorkflowId, (input: WorkflowData) => { - const [salesChannel, region, customerData] = parallelize( + const cartToUpdate = useRemoteQueryStep({ + entry_point: "cart", + variables: { id: input.id }, + fields: ["id", "shipping_address.*", "region.*", "region.countries.*"], + list: false, + throw_if_key_not_found: true, + }).config({ name: "get-cart" }) + + const [salesChannel, customerData] = parallelize( findSalesChannelStep({ salesChannelId: input.sales_channel_id, }), - findOneOrAnyRegionStep({ - regionId: input.region_id, - }), findOrCreateCustomerStep({ customerId: input.customer_id, email: input.email, }) ) + const newRegion = when({ input }, (data) => { + return !!data.input.region_id + }).then(() => { + return useRemoteQueryStep({ + entry_point: "region", + variables: { id: input.region_id }, + fields: ["id", "countries.*", "currency_code", "name"], + list: false, + throw_if_key_not_found: true, + }).config({ name: "get-region" }) + }) + + const region = transform({ cartToUpdate, newRegion }, (data) => { + return data.newRegion ?? data.cartToUpdate.region + }) + const cartInput = transform( - { input, region, customerData, salesChannel }, + { input, region, customerData, salesChannel, cartToUpdate }, (data) => { const { promo_codes, ...updateCartData } = data.input - const data_ = { ...updateCartData } - if (isPresent(updateCartData.region_id)) { - if (!data.region) { + const data_ = { + ...updateCartData, + currency_code: data.region?.currency_code, + region_id: data.region?.id, // This is either the region from the input or the region from the cart or null + } + + // When the region is updated, we do a few things: + // - We need to make sure the provided shipping address country code is in the new region + // - We clear the shipping address if the new region has more than one country + const regionIsNew = data.region?.id !== data.cartToUpdate.region?.id + const shippingAddress = data.input.shipping_address + + if (shippingAddress?.country_code) { + const country = data.region.countries.find( + (c) => c.iso_2 === shippingAddress.country_code + ) + + if (!country) { throw new MedusaError( - MedusaError.Types.NOT_FOUND, - "Region not found" + MedusaError.Types.INVALID_DATA, + `Country with code ${shippingAddress.country_code} is not within region ${data.region.name}` ) } - data_.currency_code = data.region.currency_code - data_.region_id = data.region.id + data_.shipping_address = { + ...shippingAddress, + country_code: country.iso_2, + } + } + + if (regionIsNew) { + if (data.region.countries.length === 1) { + data_.shipping_address = { + country_code: data.region.countries[0].iso_2, + } + } + + if (!data_.shipping_address?.country_code) { + data_.shipping_address = null + } } if ( diff --git a/packages/core/types/src/cart/workflows.ts b/packages/core/types/src/cart/workflows.ts index ba4c4804380b8..86bc65a8e4553 100644 --- a/packages/core/types/src/cart/workflows.ts +++ b/packages/core/types/src/cart/workflows.ts @@ -5,7 +5,11 @@ import { ProductDTO } from "../product" import { RegionDTO } from "../region" import { BigNumberInput } from "../totals" import { CartDTO, CartLineItemDTO } from "./common" -import { UpdateLineItemDTO } from "./mutations" +import { + CreateAddressDTO, + UpdateAddressDTO, + UpdateLineItemDTO, +} from "./mutations" export interface CreateCartCreateLineItemDTO { quantity: BigNumberInput @@ -89,6 +93,8 @@ export interface UpdateCartWorkflowInputDTO { email?: string | null currency_code?: string metadata?: Record | null + shipping_address?: CreateAddressDTO | UpdateAddressDTO | null + billing_address?: CreateAddressDTO | UpdateAddressDTO | null } export interface CreatePaymentCollectionForCartWorkflowInputDTO {