From f13c23a4b709e31f9b2d42e98acc6e342c494403 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Thu, 11 Dec 2025 15:40:11 +0100 Subject: [PATCH] feat(): Sync order translations (#14267) * feat(): Sync order translations * feat(): Sync order translations * tests * Create tender-melons-develop.md * fix tests * cleanup * cleanup --- .changeset/tender-melons-develop.md | 8 + .../admin/draft-order-translation.spec.ts | 485 ++++++++++++++++ .../exchanges/exchange-translation.spec.ts | 527 ++++++++++++++++++ .../order-edit-translation.spec.ts | 419 ++++++++++++++ .../order/admin/order-translation.spec.ts | 435 +++++++++++++++ .../modules/__tests__/order/order.spec.ts | 1 + .../core/core-flows/src/cart/steps/index.ts | 1 - .../steps/update-cart-items-translations.ts | 2 +- .../core/core-flows/src/cart/utils/fields.ts | 1 + .../src/cart/workflows/add-to-cart.ts | 3 +- .../src/cart/workflows/complete-cart.ts | 83 +-- .../src/cart/workflows/create-carts.ts | 2 +- packages/core/core-flows/src/common/index.ts | 1 + .../steps/get-translated-line-items.ts | 2 +- .../utils/apply-translations-to-items.ts | 0 .../workflows/update-draft-order.ts | 56 +- .../core/core-flows/src/order/steps/index.ts | 1 + .../steps/update-order-items-translations.ts | 208 +++++++ .../src/order/workflows/add-line-items.ts | 11 +- .../src/order/workflows/update-order.ts | 36 +- packages/core/types/src/order/common.ts | 5 + packages/core/types/src/order/mutations.ts | 10 + .../types/src/workflow/order/update-order.ts | 5 + .../src/api/admin/draft-orders/validators.ts | 2 + .../src/api/admin/orders/query-config.ts | 1 + .../medusa/src/api/admin/orders/validators.ts | 16 +- .../src/api/store/carts/query-config.ts | 1 + .../migrations/.snapshot-medusa-order.json | 9 + .../src/migrations/Migration20251210112909.ts | 13 + packages/modules/order/src/models/order.ts | 1 + 30 files changed, 2271 insertions(+), 74 deletions(-) create mode 100644 .changeset/tender-melons-develop.md create mode 100644 integration-tests/http/__tests__/draft-order/admin/draft-order-translation.spec.ts create mode 100644 integration-tests/http/__tests__/exchanges/exchange-translation.spec.ts create mode 100644 integration-tests/http/__tests__/order-edits/order-edit-translation.spec.ts create mode 100644 integration-tests/http/__tests__/order/admin/order-translation.spec.ts rename packages/core/core-flows/src/{cart => common}/steps/get-translated-line-items.ts (97%) rename packages/core/core-flows/src/{cart => common}/utils/apply-translations-to-items.ts (100%) create mode 100644 packages/core/core-flows/src/order/steps/update-order-items-translations.ts create mode 100644 packages/modules/order/src/migrations/Migration20251210112909.ts diff --git a/.changeset/tender-melons-develop.md b/.changeset/tender-melons-develop.md new file mode 100644 index 0000000000..6cf02537f8 --- /dev/null +++ b/.changeset/tender-melons-develop.md @@ -0,0 +1,8 @@ +--- +"@medusajs/medusa": patch +"@medusajs/order": patch +"@medusajs/core-flows": patch +"@medusajs/types": patch +--- + +feat(): Sync order translations diff --git a/integration-tests/http/__tests__/draft-order/admin/draft-order-translation.spec.ts b/integration-tests/http/__tests__/draft-order/admin/draft-order-translation.spec.ts new file mode 100644 index 0000000000..795094c047 --- /dev/null +++ b/integration-tests/http/__tests__/draft-order/admin/draft-order-translation.spec.ts @@ -0,0 +1,485 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { MedusaContainer } from "@medusajs/types" +import { Modules, ProductStatus } from "@medusajs/utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" +import { setupTaxStructure } from "../../../../modules/__tests__/fixtures" + +jest.setTimeout(300000) + +process.env.MEDUSA_FF_TRANSLATION = "true" + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Admin Draft Order Translation API", () => { + let appContainer: MedusaContainer + let region: { id: string } + let product: { id: string; variants: { id: string; title: string }[] } + let salesChannel: { id: string } + let shippingProfile: { id: string } + let stockLocation: { id: string } + let shippingOption: { id: string } + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + await setupTaxStructure(appContainer.resolve(Modules.TAX)) + await createAdminUser(dbConnection, adminHeaders, appContainer) + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + const storeModule = appContainer.resolve(Modules.STORE) + const [defaultStore] = await storeModule.listStores( + {}, + { select: ["id"], take: 1 } + ) + await storeModule.updateStores(defaultStore.id, { + supported_locales: [ + { locale_code: "en-US", is_default: true }, + { locale_code: "fr-FR" }, + { locale_code: "de-DE" }, + ], + }) + + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "default", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + 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 + ) + + product = ( + await api.post( + "/admin/products", + { + title: "Medusa T-Shirt", + description: "A comfortable cotton t-shirt", + handle: "t-shirt", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + options: [{ title: "Size", values: ["S", "M"] }], + variants: [ + { + title: "Small", + sku: "SHIRT-S", + options: { Size: "S" }, + manage_inventory: false, + prices: [{ amount: 1500, currency_code: "usd" }], + }, + { + title: "Medium", + sku: "SHIRT-M", + options: { Size: "M" }, + manage_inventory: false, + prices: [{ amount: 1500, currency_code: "usd" }], + }, + ], + }, + adminHeaders + ) + ).data.product + + const variantSmall = product.variants.find((v) => v.title === "Small") + const variantMedium = product.variants.find((v) => v.title === "Medium") + product.variants = [variantSmall!, variantMedium!] + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { name: "Test", type: "test-type" }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + const fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: "Test", + 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 + ) + + shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Test shipping option", + 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 + + await api.post( + "/admin/translations/batch", + { + create: [ + { + reference_id: product.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "T-Shirt Medusa", + description: "Un t-shirt en coton confortable", + }, + }, + { + reference_id: product.id, + reference: "product", + locale_code: "de-DE", + translations: { + title: "Medusa T-Shirt DE", + description: "Ein bequemes Baumwoll-T-Shirt", + }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Petit" }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Klein" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Moyen" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Mittel" }, + }, + ], + }, + adminHeaders + ) + }) + + describe("POST /admin/draft-orders/:id/edit/items (add items to draft order)", () => { + it("should translate items when adding to draft order with locale", async () => { + const draftOrder = ( + await api.post( + "/admin/draft-orders", + { + email: "test@test.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + locale: "fr-FR", + shipping_address: { + address_1: "123 Main St", + city: "Anytown", + country_code: "us", + postal_code: "12345", + first_name: "John", + }, + }, + adminHeaders + ) + ).data.draft_order + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit`, + {}, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/items`, + { + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/confirm`, + {}, + adminHeaders + ) + + const updatedDraftOrder = ( + await api.get(`/admin/draft-orders/${draftOrder.id}`, adminHeaders) + ).data.draft_order + + expect(updatedDraftOrder.items[0]).toEqual( + expect.objectContaining({ + product_title: "T-Shirt Medusa", + product_description: "Un t-shirt en coton confortable", + variant_title: "Petit", + }) + ) + }) + + it("should have original values when draft order has no locale", async () => { + const draftOrder = ( + await api.post( + "/admin/draft-orders", + { + email: "test@test.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + shipping_address: { + address_1: "123 Main St", + city: "Anytown", + country_code: "us", + postal_code: "12345", + first_name: "John", + }, + }, + adminHeaders + ) + ).data.draft_order + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit`, + {}, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/items`, + { + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/confirm`, + {}, + adminHeaders + ) + + const updatedDraftOrder = ( + await api.get(`/admin/draft-orders/${draftOrder.id}`, adminHeaders) + ).data.draft_order + + expect(updatedDraftOrder.items[0]).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt", + product_description: "A comfortable cotton t-shirt", + variant_title: "Small", + }) + ) + }) + + it("should translate multiple items added to draft order", async () => { + const draftOrder = ( + await api.post( + "/admin/draft-orders", + { + email: "test@test.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + locale: "de-DE", + shipping_address: { + address_1: "123 Main St", + city: "Anytown", + country_code: "us", + postal_code: "12345", + first_name: "John", + }, + }, + adminHeaders + ) + ).data.draft_order + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit`, + {}, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/items`, + { + items: [ + { variant_id: product.variants[0].id, quantity: 1 }, + { variant_id: product.variants[1].id, quantity: 2 }, + ], + }, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/confirm`, + {}, + adminHeaders + ) + + const updatedDraftOrder = ( + await api.get(`/admin/draft-orders/${draftOrder.id}`, adminHeaders) + ).data.draft_order + + expect(updatedDraftOrder.items).toHaveLength(2) + + const smallItem = updatedDraftOrder.items.find( + (item) => item.variant_id === product.variants[0].id + ) + const mediumItem = updatedDraftOrder.items.find( + (item) => item.variant_id === product.variants[1].id + ) + + expect(smallItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt DE", + variant_title: "Klein", + }) + ) + expect(mediumItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt DE", + variant_title: "Mittel", + }) + ) + }) + }) + + describe("POST /admin/draft-orders/:id (update draft order locale)", () => { + it("should re-translate all items when locale is updated", async () => { + const draftOrder = ( + await api.post( + "/admin/draft-orders", + { + email: "test@test.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + locale: "fr-FR", + shipping_address: { + address_1: "123 Main St", + city: "Anytown", + country_code: "us", + postal_code: "12345", + first_name: "John", + }, + }, + adminHeaders + ) + ).data.draft_order + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit`, + {}, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/items`, + { + items: [ + { variant_id: product.variants[0].id, quantity: 1 }, + { variant_id: product.variants[1].id, quantity: 1 }, + ], + }, + adminHeaders + ) + + await api.post( + `/admin/draft-orders/${draftOrder.id}/edit/confirm`, + {}, + adminHeaders + ) + + let updatedDraftOrder = ( + await api.get(`/admin/draft-orders/${draftOrder.id}`, adminHeaders) + ).data.draft_order + + const frenchSmallItem = updatedDraftOrder.items.find( + (item) => item.variant_id === product.variants[0].id + ) + expect(frenchSmallItem.variant_title).toEqual("Petit") + + await api.post( + `/admin/draft-orders/${draftOrder.id}`, + { locale: "de-DE" }, + adminHeaders + ) + + updatedDraftOrder = ( + await api.get(`/admin/draft-orders/${draftOrder.id}`, adminHeaders) + ).data.draft_order + + const germanSmallItem = updatedDraftOrder.items.find( + (item) => item.variant_id === product.variants[0].id + ) + const germanMediumItem = updatedDraftOrder.items.find( + (item) => item.variant_id === product.variants[1].id + ) + + expect(germanSmallItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt DE", + product_description: "Ein bequemes Baumwoll-T-Shirt", + variant_title: "Klein", + }) + ) + expect(germanMediumItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt DE", + variant_title: "Mittel", + }) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/exchanges/exchange-translation.spec.ts b/integration-tests/http/__tests__/exchanges/exchange-translation.spec.ts new file mode 100644 index 0000000000..f440a9acea --- /dev/null +++ b/integration-tests/http/__tests__/exchanges/exchange-translation.spec.ts @@ -0,0 +1,527 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { MedusaContainer } from "@medusajs/types" +import { Modules, ProductStatus, RuleOperator } from "@medusajs/utils" +import { + adminHeaders, + createAdminUser, + generatePublishableKey, + generateStoreHeaders, +} from "../../../helpers/create-admin-user" +import { setupTaxStructure } from "../../../modules/__tests__/fixtures" + +jest.setTimeout(300000) + +process.env.MEDUSA_FF_TRANSLATION = "true" + +const shippingAddressData = { + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + country_code: "us", + province: "CA", + postal_code: "94016", +} + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Exchange Translation API", () => { + let appContainer: MedusaContainer + let storeHeaders: { headers: { [key: string]: string } } + let region: { id: string } + let product: { id: string; variants: { id: string; title: string }[] } + let salesChannel: { id: string } + let shippingProfile: { id: string } + let stockLocation: { id: string } + let shippingOption: { id: string } + let outboundShippingOption: { id: string } + let inventoryItem: { id: string } + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + await setupTaxStructure(appContainer.resolve(Modules.TAX)) + await createAdminUser(dbConnection, adminHeaders, appContainer) + const publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + const storeModule = appContainer.resolve(Modules.STORE) + const [defaultStore] = await storeModule.listStores( + {}, + { select: ["id"], take: 1 } + ) + await storeModule.updateStores(defaultStore.id, { + supported_locales: [ + { locale_code: "en-US", is_default: true }, + { locale_code: "fr-FR" }, + { locale_code: "de-DE" }, + ], + }) + + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "default", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "test-variant" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItem.id}/location-levels`, + { location_id: stockLocation.id, stocked_quantity: 100 }, + adminHeaders + ) + + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + product = ( + await api.post( + "/admin/products", + { + title: "Medusa T-Shirt", + description: "A comfortable cotton t-shirt", + handle: "t-shirt", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + options: [{ title: "Size", values: ["S", "M"] }], + variants: [ + { + title: "Small", + sku: "SHIRT-S", + options: { Size: "S" }, + inventory_items: [ + { + inventory_item_id: inventoryItem.id, + required_quantity: 1, + }, + ], + prices: [{ amount: 1500, currency_code: "usd" }], + }, + { + title: "Medium", + sku: "SHIRT-M", + options: { Size: "M" }, + manage_inventory: false, + prices: [{ amount: 1500, currency_code: "usd" }], + }, + ], + }, + adminHeaders + ) + ).data.product + + const variantSmall = product.variants.find((v) => v.title === "Small") + const variantMedium = product.variants.find((v) => v.title === "Medium") + product.variants = [variantSmall!, variantMedium!] + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { name: "Test", type: "test-type" }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + const fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: "Test", + 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 + ) + + shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Test shipping option", + 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 + + outboundShippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Outbound shipping", + 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: 0 }], + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "false", + }, + ], + }, + adminHeaders + ) + ).data.shipping_option + + await api.post( + "/admin/translations/batch", + { + create: [ + { + reference_id: product.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "T-Shirt Medusa", + description: "Un t-shirt en coton confortable", + }, + }, + { + reference_id: product.id, + reference: "product", + locale_code: "de-DE", + translations: { + title: "Medusa T-Shirt DE", + description: "Ein bequemes Baumwoll-T-Shirt", + }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Petit" }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Klein" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Moyen" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Mittel" }, + }, + ], + }, + adminHeaders + ) + }) + + const createOrderFromCart = async (locale?: string) => { + const cart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + email: "test@example.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + locale, + shipping_address: shippingAddressData, + billing_address: shippingAddressData, + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + storeHeaders + ) + ).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 order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + + return (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .order + } + + describe("Exchange items translation", () => { + it("should translate new items in exchange using order locale", async () => { + const order = await createOrderFromCart("fr-FR") + + await api.post( + `/admin/orders/${order.id}/fulfillments`, + { + location_id: stockLocation.id, + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + + const exchange = ( + await api.post( + "/admin/exchanges", + { order_id: order.id, description: "Test exchange" }, + adminHeaders + ) + ).data.exchange + + // Add inbound item (item being returned) + await api.post( + `/admin/exchanges/${exchange.id}/inbound/items`, + { + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + + // Add outbound item (new item being sent) + await api.post( + `/admin/exchanges/${exchange.id}/outbound/items`, + { + items: [{ variant_id: product.variants[1].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/exchanges/${exchange.id}/outbound/shipping-method`, + { shipping_option_id: outboundShippingOption.id }, + adminHeaders + ) + + await api.post( + `/admin/exchanges/${exchange.id}/request`, + {}, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + const newItem = updatedOrder.items.find( + (item: any) => item.variant_id === product.variants[1].id + ) + + expect(newItem).toEqual( + expect.objectContaining({ + product_title: "T-Shirt Medusa", + variant_title: "Moyen", + }) + ) + }) + + it("should translate exchange items using German locale", async () => { + const order = await createOrderFromCart("de-DE") + + await api.post( + `/admin/orders/${order.id}/fulfillments`, + { + location_id: stockLocation.id, + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + + const exchange = ( + await api.post( + "/admin/exchanges", + { order_id: order.id, description: "Test exchange" }, + adminHeaders + ) + ).data.exchange + + // Add inbound item (item being returned) + await api.post( + `/admin/exchanges/${exchange.id}/inbound/items`, + { + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + + // Add outbound item (new item being sent) + await api.post( + `/admin/exchanges/${exchange.id}/outbound/items`, + { + items: [{ variant_id: product.variants[1].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/exchanges/${exchange.id}/outbound/shipping-method`, + { shipping_option_id: outboundShippingOption.id }, + adminHeaders + ) + + await api.post( + `/admin/exchanges/${exchange.id}/request`, + {}, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + const newItem = updatedOrder.items.find( + (item: any) => item.variant_id === product.variants[1].id + ) + + expect(newItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt DE", + product_description: "Ein bequemes Baumwoll-T-Shirt", + variant_title: "Mittel", + }) + ) + }) + + it("should have original values when order has no locale", async () => { + const order = await createOrderFromCart() + + await api.post( + `/admin/orders/${order.id}/fulfillments`, + { + location_id: stockLocation.id, + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + + const exchange = ( + await api.post( + "/admin/exchanges", + { order_id: order.id, description: "Test exchange" }, + adminHeaders + ) + ).data.exchange + + await api.post( + `/admin/exchanges/${exchange.id}/inbound/items`, + { + items: [{ id: order.items[0].id, quantity: 1 }], + }, + adminHeaders + ) + + // Add outbound item (new item being sent) + await api.post( + `/admin/exchanges/${exchange.id}/outbound/items`, + { + items: [{ variant_id: product.variants[1].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/exchanges/${exchange.id}/outbound/shipping-method`, + { shipping_option_id: outboundShippingOption.id }, + adminHeaders + ) + + await api.post( + `/admin/exchanges/${exchange.id}/request`, + {}, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + const newItem = updatedOrder.items.find( + (item: any) => item.variant_id === product.variants[1].id + ) + + expect(newItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt", + product_description: "A comfortable cotton t-shirt", + variant_title: "Medium", + }) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/order-edits/order-edit-translation.spec.ts b/integration-tests/http/__tests__/order-edits/order-edit-translation.spec.ts new file mode 100644 index 0000000000..d0cdb3d12b --- /dev/null +++ b/integration-tests/http/__tests__/order-edits/order-edit-translation.spec.ts @@ -0,0 +1,419 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { MedusaContainer } from "@medusajs/types" +import { Modules, ProductStatus } from "@medusajs/utils" +import { + adminHeaders, + createAdminUser, + generatePublishableKey, + generateStoreHeaders, +} from "../../../helpers/create-admin-user" +import { setupTaxStructure } from "../../../modules/__tests__/fixtures" + +jest.setTimeout(300000) + +process.env.MEDUSA_FF_TRANSLATION = "true" + +const shippingAddressData = { + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + country_code: "us", + province: "CA", + postal_code: "94016", +} + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Order Edit Translation API", () => { + let appContainer: MedusaContainer + let storeHeaders: { headers: { [key: string]: string } } + let region: { id: string } + let product: { id: string; variants: { id: string; title: string }[] } + let salesChannel: { id: string } + let shippingProfile: { id: string } + let stockLocation: { id: string } + let shippingOption: { id: string } + let inventoryItem: { id: string } + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + await setupTaxStructure(appContainer.resolve(Modules.TAX)) + await createAdminUser(dbConnection, adminHeaders, appContainer) + const publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + const storeModule = appContainer.resolve(Modules.STORE) + const [defaultStore] = await storeModule.listStores( + {}, + { select: ["id"], take: 1 } + ) + await storeModule.updateStores(defaultStore.id, { + supported_locales: [ + { locale_code: "en-US", is_default: true }, + { locale_code: "fr-FR" }, + { locale_code: "de-DE" }, + ], + }) + + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "default", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "test-variant" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItem.id}/location-levels`, + { location_id: stockLocation.id, stocked_quantity: 100 }, + adminHeaders + ) + + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + product = ( + await api.post( + "/admin/products", + { + title: "Medusa T-Shirt", + description: "A comfortable cotton t-shirt", + handle: "t-shirt", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + options: [{ title: "Size", values: ["S", "M"] }], + variants: [ + { + title: "Small", + sku: "SHIRT-S", + options: { Size: "S" }, + inventory_items: [ + { + inventory_item_id: inventoryItem.id, + required_quantity: 1, + }, + ], + prices: [{ amount: 1500, currency_code: "usd" }], + }, + { + title: "Medium", + sku: "SHIRT-M", + options: { Size: "M" }, + manage_inventory: false, + prices: [{ amount: 1500, currency_code: "usd" }], + }, + ], + }, + adminHeaders + ) + ).data.product + + const variantSmall = product.variants.find((v) => v.title === "Small") + const variantMedium = product.variants.find((v) => v.title === "Medium") + product.variants = [variantSmall!, variantMedium!] + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { name: "Test", type: "test-type" }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + const fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: "Test", + 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 + ) + + shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Test shipping option", + 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 + + await api.post( + "/admin/translations/batch", + { + create: [ + { + reference_id: product.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "T-Shirt Medusa", + description: "Un t-shirt en coton confortable", + }, + }, + { + reference_id: product.id, + reference: "product", + locale_code: "de-DE", + translations: { + title: "Medusa T-Shirt DE", + description: "Ein bequemes Baumwoll-T-Shirt", + }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Petit" }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Klein" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Moyen" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Mittel" }, + }, + ], + }, + adminHeaders + ) + }) + + const createOrderFromCart = async (locale?: string) => { + const cart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + email: "test@example.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + locale, + shipping_address: shippingAddressData, + billing_address: shippingAddressData, + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + storeHeaders + ) + ).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 order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + + return (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .order + } + + describe("POST /admin/order-edits/:id/items (add items during order edit)", () => { + it("should translate new items added during order edit using order locale", async () => { + const order = await createOrderFromCart("fr-FR") + + await api.post( + "/admin/order-edits", + { order_id: order.id }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/items`, + { + items: [{ variant_id: product.variants[1].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/confirm`, + {}, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + const newItem = updatedOrder.items.find( + (item) => item.variant_id === product.variants[1].id + ) + + expect(newItem).toEqual( + expect.objectContaining({ + product_title: "T-Shirt Medusa", + variant_title: "Moyen", + }) + ) + }) + + it("should have original values when order has no locale", async () => { + const order = await createOrderFromCart() + + await api.post( + "/admin/order-edits", + { order_id: order.id }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/items`, + { + items: [{ variant_id: product.variants[1].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/confirm`, + {}, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + const newItem = updatedOrder.items.find( + (item) => item.variant_id === product.variants[1].id + ) + + expect(newItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt", + product_description: "A comfortable cotton t-shirt", + variant_title: "Medium", + }) + ) + }) + + it("should translate items using German locale", async () => { + const order = await createOrderFromCart("de-DE") + + await api.post( + "/admin/order-edits", + { order_id: order.id }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/items`, + { + items: [{ variant_id: product.variants[1].id, quantity: 1 }], + }, + adminHeaders + ) + + await api.post( + `/admin/order-edits/${order.id}/confirm`, + {}, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + const newItem = updatedOrder.items.find( + (item) => item.variant_id === product.variants[1].id + ) + + expect(newItem).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt DE", + product_description: "Ein bequemes Baumwoll-T-Shirt", + variant_title: "Mittel", + }) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/order/admin/order-translation.spec.ts b/integration-tests/http/__tests__/order/admin/order-translation.spec.ts new file mode 100644 index 0000000000..78b2a5f995 --- /dev/null +++ b/integration-tests/http/__tests__/order/admin/order-translation.spec.ts @@ -0,0 +1,435 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { MedusaContainer } from "@medusajs/types" +import { Modules, ProductStatus, RuleOperator } from "@medusajs/utils" +import { + adminHeaders, + createAdminUser, + generatePublishableKey, + generateStoreHeaders, +} from "../../../../helpers/create-admin-user" +import { setupTaxStructure } from "../../../../modules/__tests__/fixtures" + +jest.setTimeout(300000) + +process.env.MEDUSA_FF_TRANSLATION = "true" + +const shippingAddressData = { + address_1: "test address 1", + address_2: "test address 2", + city: "SF", + country_code: "us", + province: "CA", + postal_code: "94016", +} + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Admin Order Translation API", () => { + let appContainer: MedusaContainer + let storeHeaders: { headers: { [key: string]: string } } + let region: { id: string } + let product: { id: string; variants: { id: string; title: string }[] } + let salesChannel: { id: string } + let shippingProfile: { id: string } + let stockLocation: { id: string } + let shippingOption: { id: string } + let returnShippingOption: { id: string } + let outboundShippingOption: { id: string } + let inventoryItem: { id: string } + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + await setupTaxStructure(appContainer.resolve(Modules.TAX)) + await createAdminUser(dbConnection, adminHeaders, appContainer) + const publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "Webshop", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + const storeModule = appContainer.resolve(Modules.STORE) + const [defaultStore] = await storeModule.listStores( + {}, + { select: ["id"], take: 1 } + ) + await storeModule.updateStores(defaultStore.id, { + supported_locales: [ + { locale_code: "en-US", is_default: true }, + { locale_code: "fr-FR" }, + { locale_code: "de-DE" }, + ], + }) + + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["us"] }, + adminHeaders + ) + ).data.region + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "default", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + inventoryItem = ( + await api.post( + `/admin/inventory-items`, + { sku: "test-variant" }, + adminHeaders + ) + ).data.inventory_item + + await api.post( + `/admin/inventory-items/${inventoryItem.id}/location-levels`, + { location_id: stockLocation.id, stocked_quantity: 100 }, + adminHeaders + ) + + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + // Create product with description for translation + product = ( + await api.post( + "/admin/products", + { + title: "Medusa T-Shirt", + description: "A comfortable cotton t-shirt", + handle: "t-shirt", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + options: [{ title: "Size", values: ["S", "M"] }], + variants: [ + { + title: "Small", + sku: "SHIRT-S", + options: { Size: "S" }, + inventory_items: [ + { + inventory_item_id: inventoryItem.id, + required_quantity: 1, + }, + ], + prices: [{ amount: 1500, currency_code: "usd" }], + }, + { + title: "Medium", + sku: "SHIRT-M", + options: { Size: "M" }, + manage_inventory: false, + prices: [{ amount: 1500, currency_code: "usd" }], + }, + ], + }, + adminHeaders + ) + ).data.product + + // Maintain predictable variants order + const variantSmall = product.variants.find((v) => v.title === "Small") + const variantMedium = product.variants.find((v) => v.title === "Medium") + product.variants = [variantSmall!, variantMedium!] + + // Setup fulfillment + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { name: "Test", type: "test-type" }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + const fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: "Test", + 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 + ) + + shippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Test shipping option", + 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 + + returnShippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Return shipping", + 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: 500 }], + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "true", + }, + ], + }, + adminHeaders + ) + ).data.shipping_option + + outboundShippingOption = ( + await api.post( + `/admin/shipping-options`, + { + name: "Outbound shipping", + 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: 0 }], + rules: [ + { + operator: RuleOperator.EQ, + attribute: "is_return", + value: "false", + }, + ], + }, + adminHeaders + ) + ).data.shipping_option + + // Create translations for product and variants + await api.post( + "/admin/translations/batch", + { + create: [ + { + reference_id: product.id, + reference: "product", + locale_code: "fr-FR", + translations: { + title: "T-Shirt Medusa", + description: "Un t-shirt en coton confortable", + }, + }, + { + reference_id: product.id, + reference: "product", + locale_code: "de-DE", + translations: { + title: "Medusa T-Shirt DE", + description: "Ein bequemes Baumwoll-T-Shirt", + }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Petit" }, + }, + { + reference_id: product.variants[0].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Klein" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "fr-FR", + translations: { title: "Moyen" }, + }, + { + reference_id: product.variants[1].id, + reference: "product_variant", + locale_code: "de-DE", + translations: { title: "Mittel" }, + }, + ], + }, + adminHeaders + ) + }) + + const createOrderFromCart = async (locale?: string) => { + const cart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + email: "test@example.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + locale, + shipping_address: shippingAddressData, + billing_address: shippingAddressData, + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + storeHeaders + ) + ).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 order = ( + await api.post(`/store/carts/${cart.id}/complete`, {}, storeHeaders) + ).data.order + + return (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data + .order + } + + describe("Order creation from cart with locale", () => { + it("should preserve locale and translated items when order is created from cart", async () => { + const order = await createOrderFromCart("fr-FR") + + expect(order.items[0]).toEqual( + expect.objectContaining({ + product_title: "T-Shirt Medusa", + product_description: "Un t-shirt en coton confortable", + variant_title: "Petit", + }) + ) + }) + + it("should have original values when order is created without locale", async () => { + const order = await createOrderFromCart() + + expect(order.items[0]).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt", + product_description: "A comfortable cotton t-shirt", + variant_title: "Small", + }) + ) + }) + }) + + describe("POST /admin/orders/:id (update order locale)", () => { + it("should re-translate all items when locale is updated", async () => { + const order = await createOrderFromCart("fr-FR") + + expect(order.items[0].variant_title).toEqual("Petit") + + await api.post( + `/admin/orders/${order.id}`, + { locale: "de-DE" }, + adminHeaders + ) + + const updatedOrder = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + + expect(updatedOrder.items[0]).toEqual( + expect.objectContaining({ + product_title: "Medusa T-Shirt DE", + product_description: "Ein bequemes Baumwoll-T-Shirt", + variant_title: "Klein", + }) + ) + }) + + it("should not re-translate items when updating other fields", async () => { + const order = await createOrderFromCart("fr-FR") + + await api.post( + `/admin/orders/${order.id}`, + { email: "updated@example.com" }, + adminHeaders + ) + + const updatedOrder = ( + await api.get( + `/admin/orders/${order.id}?fields=+email`, + adminHeaders + ) + ).data.order + + expect(updatedOrder.email).toEqual("updated@example.com") + expect(updatedOrder.items[0]).toEqual( + expect.objectContaining({ + product_title: "T-Shirt Medusa", + variant_title: "Petit", + }) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/order/order.spec.ts b/integration-tests/modules/__tests__/order/order.spec.ts index f56bc4b6cb..fca635d491 100644 --- a/integration-tests/modules/__tests__/order/order.spec.ts +++ b/integration-tests/modules/__tests__/order/order.spec.ts @@ -286,6 +286,7 @@ medusaIntegrationTestRunner({ payment_status: "not_paid", region_id: "test_region_id", fulfillments: [], + locale: null, metadata: { foo: "bar", }, diff --git a/packages/core/core-flows/src/cart/steps/index.ts b/packages/core/core-flows/src/cart/steps/index.ts index 27ad5c1dbd..106e9f64ef 100644 --- a/packages/core/core-flows/src/cart/steps/index.ts +++ b/packages/core/core-flows/src/cart/steps/index.ts @@ -10,7 +10,6 @@ export * from "./find-or-create-customer" export * from "./find-sales-channel" export * from "./get-actions-to-compute-from-promotions" export * from "./get-line-item-actions" -export * from "./get-translated-line-items" export * from "./update-cart-items-translations" export * from "./get-promotion-codes-to-apply" export * from "./get-variant-price-sets" diff --git a/packages/core/core-flows/src/cart/steps/update-cart-items-translations.ts b/packages/core/core-flows/src/cart/steps/update-cart-items-translations.ts index 7c6076f6e5..6b4e840dca 100644 --- a/packages/core/core-flows/src/cart/steps/update-cart-items-translations.ts +++ b/packages/core/core-flows/src/cart/steps/update-cart-items-translations.ts @@ -12,7 +12,7 @@ import { Modules, } from "@medusajs/framework/utils" import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" -import { applyTranslationsToItems } from "../utils/apply-translations-to-items" +import { applyTranslationsToItems } from "../../common/utils/apply-translations-to-items" import { productVariantsFields } from "../utils/fields" export interface UpdateCartItemsTranslationsStepInput { diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts index 0ba93a85b1..70b497d3bb 100644 --- a/packages/core/core-flows/src/cart/utils/fields.ts +++ b/packages/core/core-flows/src/cart/utils/fields.ts @@ -53,6 +53,7 @@ export const completeCartFields = [ "id", "currency_code", "email", + "locale", "created_at", "updated_at", "completed_at", 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 a04fcbe69c..30a4036a24 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 @@ -17,13 +17,12 @@ import { when, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" -import { useQueryGraphStep } from "../../common" +import { getTranslatedLineItemsStep, useQueryGraphStep } from "../../common" import { emitEventStep } from "../../common/steps/emit-event" import { acquireLockStep, releaseLockStep } from "../../locking" import { createLineItemsStep, getLineItemActionsStep, - getTranslatedLineItemsStep, updateLineItemsStep, } from "../steps" import { validateCartStep } from "../steps/validate-cart" diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index 8b5393c1ae..22bbf2702a 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -75,47 +75,47 @@ export const completeCartWorkflowId = "complete-cart" * You can use this workflow within your own customizations or custom workflows, allowing you to wrap custom logic around completing a cart. * For example, in the [Subscriptions recipe](https://docs.medusajs.com/resources/recipes/subscriptions/examples/standard#create-workflow), * this workflow is used within another workflow that creates a subscription order. - * + * * ## Cart Completion Idempotency - * + * * This workflow's logic is idempotent, meaning that if it is executed multiple times with the same input, it will not create duplicate orders. The * same order will be returned for subsequent executions with the same cart ID. This is necessary to avoid rolling back payments or causing * other side effects if the workflow is retried or fails due to transient errors. - * + * * So, if you use this workflow within your own, make sure your workflow's steps are idempotent as well to avoid unintended side effects. * Your workflow must also acquire and release locks around this workflow to prevent concurrent executions for the same cart. - * + * * The following sections cover some common scenarios and how to handle them. - * + * * ### Creating Links and Linked Records - * + * * In some cases, you might want to create custom links or linked records to the order. For example, you might want to create a link from the order to a * digital order. - * - * In such cases, ensure that your workflow's logic checks for existing links or records before creating new ones. You can query the + * + * In such cases, ensure that your workflow's logic checks for existing links or records before creating new ones. You can query the * [entry point of the link](https://docs.medusajs.com/learn/fundamentals/module-links/custom-columns#method-2-using-entry-point) * to check for existing links before creating new ones. - * + * * For example: - * + * * ```ts * import { * createWorkflow, * when, * WorkflowResponse * } from "@medusajs/framework/workflows-sdk" - * import { + * import { * useQueryGraphStep, * completeCartWorkflow, * acquireLockStep, * releaseLockStep * } from "@medusajs/framework/workflows-sdk" * import digitalProductOrderOrderLink from "../../links/digital-product-order" - * + * * type WorkflowInput = { * cart_id: string * } - * + * * const createDigitalProductOrderWorkflow = createWorkflow( * "create-digital-product-order", * (input: WorkflowInput) => { @@ -129,14 +129,14 @@ export const completeCartWorkflowId = "complete-cart" * id: input.cart_id * } * }) - * + * * const { data: existingLinks } = useQueryGraphStep({ * entity: digitalProductOrderOrderLink.entryPoint, * fields: ["digital_product_order.id"], * filters: { order_id: id }, * }).config({ name: "retrieve-existing-links" }); - * - * + * + * * const digital_product_order = when( * "create-digital-product-order-condition", * { existingLinks }, @@ -149,60 +149,60 @@ export const completeCartWorkflowId = "complete-cart" * .then(() => { * // create digital product order logic... * }) - * + * * // other workflow logic... - * + * * releaseLockStep({ * key: input.cart_id, * }) - * + * * return new WorkflowResponse({ * // workflow output... * }) * } * ) * ``` - * + * * ### Custom Validation with Conflicts - * + * * Some use cases require custom validation that may cause conflicts on subsequent executions of the workflow. * For example, if you're selling tickets to an event, you might want to validate that the tickets are available * on selected dates. - * + * * In this scenario, if the workflow is retried after the first execution, the validation * will fail since the tickets would have already been reserved in the first execution. This makes the cart * completion non-idempotent. - * + * * To handle these cases, you can create a step that throws an error if the validation fails. Then, in the compensation function, * you can cancel the order if the validation fails. For example: - * + * * ```ts * import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" * import { MedusaError } from "@medusajs/framework/utils" * import { cancelOrderWorkflow } from "@medusajs/medusa/core-flows" - * + * * type StepInput = { * order_id: string * // other input fields... * } - * + * * export const customCartValidationStep = createStep( * "custom-cart-validation", * async (input, { container }) => { * const isValid = true // replace with actual validation logic - * + * * if (!isValid) { * throw new MedusaError( * MedusaError.Types.INVALID_DATA, * "Custom cart validation failed" * ) * } - * + * * return new StepResponse(void 0, input.order_id) * }, * async (order_id, { container, context }) => { * if (!order_id) return - * + * * cancelOrderWorkflow(container).run({ * input: { * id: order_id, @@ -213,10 +213,10 @@ export const completeCartWorkflowId = "complete-cart" * } * ) * ``` - * + * * Then, in your custom workflow, only run the validation step if the order is being created for the first time. For example, * only run the validation if the link from the order to your custom data does not exist yet: - * + * * ```ts * import { * createWorkflow, @@ -225,11 +225,11 @@ export const completeCartWorkflowId = "complete-cart" * } from "@medusajs/framework/workflows-sdk" * import { useQueryGraphStep } from "@medusajs/framework/workflows-sdk" * import ticketOrderLink from "../../links/ticket-order" - * + * * type WorkflowInput = { * cart_id: string * } - * + * * const createTicketOrderWorkflow = createWorkflow( * "create-ticket-order", * (input: WorkflowInput) => { @@ -243,14 +243,14 @@ export const completeCartWorkflowId = "complete-cart" * id: input.cart_id * } * }) - * + * * const { data: existingLinks } = useQueryGraphStep({ * entity: ticketOrderLink.entryPoint, * fields: ["ticket.id"], * filters: { order_id: id }, * }).config({ name: "retrieve-existing-links" }); - * - * + * + * * const ticket_order = when( * "create-ticket-order-condition", * { existingLinks }, @@ -264,23 +264,23 @@ export const completeCartWorkflowId = "complete-cart" * customCartValidationStep({ order_id: id }) * // create ticket order logic... * }) - * + * * // other workflow logic... - * + * * releaseLockStep({ * key: input.cart_id, * }) - * + * * return new WorkflowResponse({ * // workflow output... * }) * } * ) * ``` - * + * * The first time this workflow is executed for a cart, the validation step will run and validate the cart. If the validation fails, * the order will be canceled in the compensation function. - * + * * If the validation is successful and the workflow is retried, the validation step will be skipped since the link from the order to the * ticket order already exists. This ensures that the workflow remains idempotent. * @@ -472,6 +472,7 @@ export const completeCartWorkflow = createWorkflow( status: OrderStatus.PENDING, email: cart.email, currency_code: cart.currency_code, + locale: cart.locale, shipping_address: shippingAddress, billing_address: billingAddress, no_notification: false, 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 5cd2e11e20..7b5e7869f3 100644 --- a/packages/core/core-flows/src/cart/workflows/create-carts.ts +++ b/packages/core/core-flows/src/cart/workflows/create-carts.ts @@ -24,7 +24,6 @@ import { findOneOrAnyRegionStep, findOrCreateCustomerStep, findSalesChannelStep, - getTranslatedLineItemsStep, } from "../steps" import { validateSalesChannelStep } from "../steps/validate-sales-channel" import { productVariantsFields } from "../utils/fields" @@ -35,6 +34,7 @@ import { getVariantsAndItemsWithPrices } from "./get-variants-and-items-with-pri import { refreshPaymentCollectionForCartWorkflow } from "./refresh-payment-collection" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" +import { getTranslatedLineItemsStep } from "../../common" /** * The data to create the cart, along with custom data that's passed to the workflow's hooks. diff --git a/packages/core/core-flows/src/common/index.ts b/packages/core/core-flows/src/common/index.ts index 5b9aec6228..24d158ebc7 100644 --- a/packages/core/core-flows/src/common/index.ts +++ b/packages/core/core-flows/src/common/index.ts @@ -12,3 +12,4 @@ export * from "./workflows/batch-links" export * from "./workflows/create-links" export * from "./workflows/dismiss-links" export * from "./workflows/update-links" +export * from "./steps/get-translated-line-items" diff --git a/packages/core/core-flows/src/cart/steps/get-translated-line-items.ts b/packages/core/core-flows/src/common/steps/get-translated-line-items.ts similarity index 97% rename from packages/core/core-flows/src/cart/steps/get-translated-line-items.ts rename to packages/core/core-flows/src/common/steps/get-translated-line-items.ts index 9b63cf8ae3..5af542d831 100644 --- a/packages/core/core-flows/src/cart/steps/get-translated-line-items.ts +++ b/packages/core/core-flows/src/common/steps/get-translated-line-items.ts @@ -10,7 +10,7 @@ import { applyTranslationsToItems } from "../utils/apply-translations-to-items" export interface GetTranslatedLineItemsStepInput { items: T[] | undefined variants: Partial[] - locale: string | undefined + locale: string | null | undefined } export const getTranslatedLineItemsStepId = "get-translated-line-items" diff --git a/packages/core/core-flows/src/cart/utils/apply-translations-to-items.ts b/packages/core/core-flows/src/common/utils/apply-translations-to-items.ts similarity index 100% rename from packages/core/core-flows/src/cart/utils/apply-translations-to-items.ts rename to packages/core/core-flows/src/common/utils/apply-translations-to-items.ts diff --git a/packages/core/core-flows/src/draft-order/workflows/update-draft-order.ts b/packages/core/core-flows/src/draft-order/workflows/update-draft-order.ts index 8c879a9838..2fde9de495 100644 --- a/packages/core/core-flows/src/draft-order/workflows/update-draft-order.ts +++ b/packages/core/core-flows/src/draft-order/workflows/update-draft-order.ts @@ -1,12 +1,3 @@ -import { Modules, OrderWorkflowEvents } from "@medusajs/framework/utils" -import { - createStep, - createWorkflow, - StepResponse, - transform, - WorkflowData, - WorkflowResponse, -} from "@medusajs/framework/workflows-sdk" import { IOrderModuleService, OrderDTO, @@ -14,10 +5,24 @@ import { UpdateOrderDTO, UpsertOrderAddressDTO, } from "@medusajs/framework/types" +import { Modules, OrderWorkflowEvents } from "@medusajs/framework/utils" +import { + createStep, + createWorkflow, + StepResponse, + transform, + when, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" import { emitEventStep, useRemoteQueryStep } from "../../common" -import { previewOrderChangeStep, registerOrderChangesStep } from "../../order" -import { validateDraftOrderStep } from "../steps/validate-draft-order" import { acquireLockStep, releaseLockStep } from "../../locking" +import { + previewOrderChangeStep, + registerOrderChangesStep, + updateOrderItemsTranslationsStep, +} from "../../order" +import { validateDraftOrderStep } from "../steps/validate-draft-order" export const updateDraftOrderWorkflowId = "update-draft-order" @@ -53,6 +58,11 @@ export interface UpdateDraftOrderWorkflowInput { * The ID of the sales channel to associate the draft order with. */ sales_channel_id?: string + /** + * The new locale of the draft order. When changed, all line items + * will be re-translated to the new locale. + */ + locale?: string | null /** * The new metadata of the draft order. */ @@ -166,6 +176,7 @@ export const updateDraftOrderWorkflow = createWorkflow( "sales_channel_id", "email", "customer_id", + "locale", "shipping_address.*", "billing_address.*", "metadata", @@ -306,12 +317,35 @@ export const updateDraftOrderWorkflow = createWorkflow( }) } + if (!!input.locale && input.locale !== order.locale) { + changes.push({ + change_type: "update_order" as const, + order_id: input.id, + created_by: input.user_id, + confirmed_by: input.user_id, + details: { + type: "locale", + old: order.locale, + new: updatedOrder.locale, + }, + }) + } + return changes } ) registerOrderChangesStep(orderChangeInput) + when({ input, order }, ({ input, order }) => { + return !!input.locale && input.locale !== order.locale + }).then(() => { + updateOrderItemsTranslationsStep({ + order_id: input.id, + locale: input.locale!, + }) + }) + emitEventStep({ eventName: OrderWorkflowEvents.UPDATED, data: { id: input.id }, diff --git a/packages/core/core-flows/src/order/steps/index.ts b/packages/core/core-flows/src/order/steps/index.ts index 026948b95d..a0422edaa6 100644 --- a/packages/core/core-flows/src/order/steps/index.ts +++ b/packages/core/core-flows/src/order/steps/index.ts @@ -35,5 +35,6 @@ export * from "./return/update-returns" export * from "./set-tax-lines-for-items" export * from "./update-order-change-actions" export * from "./update-order-changes" +export * from "./update-order-items-translations" export * from "./update-orders" export * from "./update-shipping-methods" diff --git a/packages/core/core-flows/src/order/steps/update-order-items-translations.ts b/packages/core/core-flows/src/order/steps/update-order-items-translations.ts new file mode 100644 index 0000000000..6727269825 --- /dev/null +++ b/packages/core/core-flows/src/order/steps/update-order-items-translations.ts @@ -0,0 +1,208 @@ +import { MedusaContainer } from "@medusajs/framework" +import { + IOrderModuleService, + ProductVariantDTO, + RemoteQueryFunction, +} from "@medusajs/framework/types" +import { + applyTranslations, + ContainerRegistrationKeys, + deduplicate, + FeatureFlag, + Modules, +} from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { applyTranslationsToItems } from "../../common/utils/apply-translations-to-items" +import { productVariantsFields } from "../utils/fields" + +export interface UpdateOrderItemsTranslationsStepInput { + order_id: string + locale: string + /** + * Pre-loaded items to avoid re-fetching. + */ + items?: { id: string; variant_id?: string; [key: string]: any }[] +} + +const BATCH_SIZE = 100 + +const lineItemFields = [ + "id", + "variant_id", + "product_id", + "title", + "subtitle", + "product_title", + "product_description", + "product_subtitle", + "product_type", + "product_collection", + "product_handle", + "variant_title", +] + +export const updateOrderItemsTranslationsStepId = + "update-order-items-translations" + +type ItemTranslationSnapshot = { + id: string + title: string + subtitle: string + product_title: string + product_description: string + product_subtitle: string + product_type: string + product_collection: string + product_handle: string + variant_title: string +} + +async function compensation( + originalItems: ItemTranslationSnapshot[] | undefined, + { container }: { container: MedusaContainer } +) { + if (!originalItems?.length) { + return + } + + const orderModule = container.resolve(Modules.ORDER) + + for (let i = 0; i < originalItems.length; i += BATCH_SIZE) { + const batch = originalItems.slice(i, i + BATCH_SIZE) + await orderModule.updateOrderLineItems( + batch.map((item) => ({ + selector: { id: item.id }, + data: { + title: item.title, + subtitle: item.subtitle, + product_title: item.product_title, + product_description: item.product_description, + product_subtitle: item.product_subtitle, + product_type: item.product_type, + product_collection: item.product_collection, + product_handle: item.product_handle, + variant_title: item.variant_title, + }, + })) + ) + } +} + +/** + * This step re-translates all order line items when the order's locale changes. + * It fetches items and their variants in batches to handle large orders gracefully. + */ +export const updateOrderItemsTranslationsStep = createStep( + updateOrderItemsTranslationsStepId, + async (data: UpdateOrderItemsTranslationsStepInput, { container }) => { + const originalItems: ItemTranslationSnapshot[] = [] + try { + const isTranslationEnabled = FeatureFlag.isFeatureEnabled("translation") + + if (!isTranslationEnabled || !data.locale) { + return new StepResponse(void 0, []) + } + + const orderModule = container.resolve(Modules.ORDER) + const query = container.resolve( + ContainerRegistrationKeys.QUERY + ) + + const processBatch = async ( + items: { id: string; variant_id?: string; [key: string]: any }[] + ) => { + const variantIds = deduplicate( + items + .map((item) => item.variant_id) + .filter((id): id is string => !!id) + ) + + if (variantIds.length === 0) { + return + } + + // Store original values before updating + for (const item of items) { + originalItems.push({ + id: item.id, + title: item.title, + subtitle: item.subtitle, + product_title: item.product_title, + product_description: item.product_description, + product_subtitle: item.product_subtitle, + product_type: item.product_type, + product_collection: item.product_collection, + product_handle: item.product_handle, + variant_title: item.variant_title, + }) + } + + const { data: variants } = await query.graph({ + entity: "variants", + filters: { id: variantIds }, + fields: productVariantsFields, + }) + + await applyTranslations({ + localeCode: data.locale, + objects: variants as Record[], + container, + }) + + const translatedItems = applyTranslationsToItems( + items as { variant_id?: string; [key: string]: any }[], + variants as Partial[] + ) + + const itemsToUpdate = translatedItems + .filter((item) => item.id) + .map((item) => ({ + selector: { id: item.id }, + data: { + title: item.title, + subtitle: item.subtitle, + product_title: item.product_title, + product_description: item.product_description, + product_subtitle: item.product_subtitle, + product_type: item.product_type, + product_collection: item.product_collection, + product_handle: item.product_handle, + variant_title: item.variant_title, + }, + })) + + if (itemsToUpdate.length > 0) { + await orderModule.updateOrderLineItems(itemsToUpdate) + } + } + + if (data.items?.length) { + await processBatch(data.items) + return new StepResponse(void 0, originalItems) + } + + const { data: orders } = await query.graph({ + entity: "orders", + filters: { id: data.order_id }, + fields: lineItemFields.map((f) => `items.${f}`), + }) + + const orderData = orders[0] as { + items?: { id: string; variant_id?: string }[] + } + const items = orderData?.items ?? [] + + // Process items in batches + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE) + await processBatch(batch) + } + + return new StepResponse(void 0, originalItems) + } catch (error) { + await compensation(originalItems, { container }) + throw error + } + }, + compensation +) diff --git a/packages/core/core-flows/src/order/workflows/add-line-items.ts b/packages/core/core-flows/src/order/workflows/add-line-items.ts index 926e30327f..99db7da14b 100644 --- a/packages/core/core-flows/src/order/workflows/add-line-items.ts +++ b/packages/core/core-flows/src/order/workflows/add-line-items.ts @@ -21,7 +21,7 @@ import { requiredVariantFieldsForInventoryConfirmation } from "../../cart/utils/ import { pricingContextResult } from "../../cart/utils/schemas" import { confirmVariantInventoryWorkflow } from "../../cart/workflows/confirm-variant-inventory" import { getVariantsAndItemsWithPrices } from "../../cart/workflows/get-variants-and-items-with-prices" -import { useQueryGraphStep } from "../../common" +import { getTranslatedLineItemsStep, useQueryGraphStep } from "../../common" import { createOrderLineItemsStep } from "../steps" import { productVariantsFields } from "../utils/fields" @@ -108,6 +108,7 @@ export const addOrderLineItemsWorkflow = createWorkflow( "customer_id", "email", "currency_code", + "locale", ], options: { throwIfKeyNotFound: true, isList: false }, }).config({ name: "order-query" }) @@ -176,9 +177,15 @@ export const addOrderLineItemsWorkflow = createWorkflow( }) }) + const translatedItems = getTranslatedLineItemsStep({ + items, + variants, + locale: order.locale, + }) + return new WorkflowResponse( createOrderLineItemsStep({ - items: items, + items: translatedItems, }) satisfies OrderAddLineItemWorkflowOutput, { hooks: [setPricingContext] as const, diff --git a/packages/core/core-flows/src/order/workflows/update-order.ts b/packages/core/core-flows/src/order/workflows/update-order.ts index 526b25066e..79872cd62b 100644 --- a/packages/core/core-flows/src/order/workflows/update-order.ts +++ b/packages/core/core-flows/src/order/workflows/update-order.ts @@ -1,4 +1,9 @@ import type { OrderDTO, OrderWorkflow } from "@medusajs/framework/types" +import { + OrderPreviewDTO, + RegisterOrderChangeDTO, + UpdateOrderDTO, +} from "@medusajs/framework/types" import { MedusaError, OrderWorkflowEvents, @@ -10,17 +15,14 @@ import { createStep, createWorkflow, transform, + when, } from "@medusajs/framework/workflows-sdk" -import { - OrderPreviewDTO, - RegisterOrderChangeDTO, - UpdateOrderDTO, -} from "@medusajs/framework/types" import { emitEventStep, useQueryGraphStep } from "../../common" import { previewOrderChangeStep, registerOrderChangesStep, + updateOrderItemsTranslationsStep, updateOrdersStep, } from "../steps" import { throwIfOrderIsCancelled } from "../utils/order-validation" @@ -128,6 +130,7 @@ export const updateOrderWorkflow = createWorkflow( "id", "status", "email", + "locale", "shipping_address.*", "billing_address.*", "metadata", @@ -235,12 +238,35 @@ export const updateOrderWorkflow = createWorkflow( }) } + if (!!input.locale && input.locale !== order.locale) { + changes.push({ + change_type: "update_order" as const, + order_id: input.id, + created_by: input.user_id, + confirmed_by: input.user_id, + details: { + type: "locale", + old: order.locale, + new: input.locale, + }, + }) + } + return changes } ) registerOrderChangesStep(orderChangeInput) + when("locale-changed", { input, order }, ({ input, order }) => { + return !!input.locale && input.locale !== order.locale + }).then(() => { + updateOrderItemsTranslationsStep({ + order_id: input.id, + locale: input.locale!, + }) + }) + emitEventStep({ eventName: OrderWorkflowEvents.UPDATED, data: { id: input.id }, diff --git a/packages/core/types/src/order/common.ts b/packages/core/types/src/order/common.ts index f6c9994f3d..53b2529600 100644 --- a/packages/core/types/src/order/common.ts +++ b/packages/core/types/src/order/common.ts @@ -1133,6 +1133,11 @@ export interface OrderDTO { */ is_draft_order?: boolean + /** + * The locale of the order. + */ + locale?: string | null + /** * Holds custom data in key-value pairs. */ diff --git a/packages/core/types/src/order/mutations.ts b/packages/core/types/src/order/mutations.ts index d9933064b1..0e615a181b 100644 --- a/packages/core/types/src/order/mutations.ts +++ b/packages/core/types/src/order/mutations.ts @@ -138,6 +138,11 @@ export interface CreateOrderDTO { */ currency_code?: string + /** + * The locale of the order. + */ + locale?: string | null + /** * The associated shipping address's ID. */ @@ -234,6 +239,11 @@ export interface UpdateOrderDTO { */ is_draft_order?: boolean + /** + * The locale of the order. + */ + locale?: string | null + /** * The items of the order. */ diff --git a/packages/core/types/src/workflow/order/update-order.ts b/packages/core/types/src/workflow/order/update-order.ts index f2712e7613..42e7fa338f 100644 --- a/packages/core/types/src/workflow/order/update-order.ts +++ b/packages/core/types/src/workflow/order/update-order.ts @@ -26,6 +26,11 @@ export type UpdateOrderWorkflowInput = { * The new email of the order. */ email?: string + /** + * The new locale of the order. When changed, all line items + * will be re-translated to the new locale. + */ + locale?: string | null /** * The new metadata of the order. */ diff --git a/packages/medusa/src/api/admin/draft-orders/validators.ts b/packages/medusa/src/api/admin/draft-orders/validators.ts index b02e140d33..d7d1057a7c 100644 --- a/packages/medusa/src/api/admin/draft-orders/validators.ts +++ b/packages/medusa/src/api/admin/draft-orders/validators.ts @@ -83,6 +83,7 @@ const CreateDraftOrder = z currency_code: z.string().nullish(), no_notification_order: z.boolean().optional(), shipping_methods: z.array(ShippingMethod).optional(), + locale: z.string().optional(), metadata: z.record(z.unknown()).nullish(), }) .strict() @@ -111,6 +112,7 @@ export const AdminUpdateDraftOrder = z.object({ shipping_address: AddressPayload.optional(), billing_address: AddressPayload.optional(), metadata: z.record(z.unknown()).nullish(), + locale: z.string().optional(), }) export type AdminAddDraftOrderPromotionsType = z.infer< diff --git a/packages/medusa/src/api/admin/orders/query-config.ts b/packages/medusa/src/api/admin/orders/query-config.ts index 4d3bac7dd5..db7b886785 100644 --- a/packages/medusa/src/api/admin/orders/query-config.ts +++ b/packages/medusa/src/api/admin/orders/query-config.ts @@ -7,6 +7,7 @@ export const defaultAdminOrderFields = [ "summary", "total", "metadata", + "locale", "created_at", "updated_at", ] diff --git a/packages/medusa/src/api/admin/orders/validators.ts b/packages/medusa/src/api/admin/orders/validators.ts index e18674ff66..54457bf322 100644 --- a/packages/medusa/src/api/admin/orders/validators.ts +++ b/packages/medusa/src/api/admin/orders/validators.ts @@ -9,15 +9,12 @@ import { export const AdminGetOrdersOrderParams = createSelectParams().merge( z.object({ - version: z.preprocess( - (val) => { - if (val && typeof val === "string") { - return parseInt(val) - } - return val - }, - z.number().optional() - ) + version: z.preprocess((val) => { + if (val && typeof val === "string") { + return parseInt(val) + } + return val + }, z.number().optional()), }) ) @@ -151,6 +148,7 @@ export const AdminUpdateOrder = z.object({ email: z.string().optional(), shipping_address: AddressPayload.optional(), billing_address: AddressPayload.optional(), + locale: z.string().nullish(), metadata: z.record(z.unknown()).nullish(), }) diff --git a/packages/medusa/src/api/store/carts/query-config.ts b/packages/medusa/src/api/store/carts/query-config.ts index a1c3f4aa40..32de4fb881 100644 --- a/packages/medusa/src/api/store/carts/query-config.ts +++ b/packages/medusa/src/api/store/carts/query-config.ts @@ -3,6 +3,7 @@ export const defaultStoreCartFields = [ "id", "currency_code", "email", + "locale", "region_id", "created_at", "updated_at", diff --git a/packages/modules/order/src/migrations/.snapshot-medusa-order.json b/packages/modules/order/src/migrations/.snapshot-medusa-order.json index c174cf53b5..37605f4d45 100644 --- a/packages/modules/order/src/migrations/.snapshot-medusa-order.json +++ b/packages/modules/order/src/migrations/.snapshot-medusa-order.json @@ -304,6 +304,15 @@ "nullable": false, "mappedType": "text" }, + "locale": { + "name": "locale", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, "no_notification": { "name": "no_notification", "type": "boolean", diff --git a/packages/modules/order/src/migrations/Migration20251210112909.ts b/packages/modules/order/src/migrations/Migration20251210112909.ts new file mode 100644 index 0000000000..9aafb2ae1c --- /dev/null +++ b/packages/modules/order/src/migrations/Migration20251210112909.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251210112909 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "order" add column if not exists "locale" text null;`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "order" drop column if exists "locale";`); + } + +} diff --git a/packages/modules/order/src/models/order.ts b/packages/modules/order/src/models/order.ts index f2a6489b00..40151bed95 100644 --- a/packages/modules/order/src/models/order.ts +++ b/packages/modules/order/src/models/order.ts @@ -20,6 +20,7 @@ const _Order = model is_draft_order: model.boolean().default(false), email: model.text().searchable().nullable(), currency_code: model.text(), + locale: model.text().nullable(), no_notification: model.boolean().nullable(), metadata: model.json().nullable(), canceled_at: model.dateTime().nullable(),