From 6367bccde88158d524dfa01e5a8123ffa3461c10 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 17 Dec 2024 11:10:30 +0100 Subject: [PATCH] feat(medusa,pricing): Cart pricing context with customer group (#10579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(carts): Fixes cart modifications not accounting for certain price lists (#10493) *What* * Fixes #10490 * Expands any available customer_id into its customer_group_ids for cart updates that add line items. *Why* * Cart updates from the storefront were overriding any valid price lists that were correctly being shown in the storefront's product pages. *How* * Adds a new workflow step that expands an optional customer_id into the customer_group_ids it belongs to. * Uses this step in the addToCartWorkflow and updateLineItemInCartWorkflow workflows. *Testing* * Using medusa-dev to test on a local backend. * Adds integration tests for the addToCart and updateLineItemInCart workflows. Co-authored-by: Riqwan Thamir * chore: update cart workflows to accept new pricing context * chore: add transfer specs * chore: fix specs * chore: modify types + specs * chore: add data migration + dashboard changes * chore: fix update line item workflow * chore: add changeset + unskip spec --------- Co-authored-by: Sergio Campamá --- .changeset/warm-comics-return.md | 10 + .../http/__tests__/cart/store/cart.spec.ts | 225 +++++++++++-- .../price-list/admin/price-list.spec.ts | 10 +- .../__tests__/product/store/product.spec.ts | 8 +- .../cart/store/cart.workflows.spec.ts | 310 +++++++++++++++++- .../product-variant-price-set.spec.ts | 10 +- .../price-lists/admin/price-lists.spec.ts | 22 +- .../price-list-configuration-form.tsx | 4 +- .../price-list-configuration.tsx | 6 +- .../price-list-create-form.tsx | 13 +- .../core/core-flows/src/cart/utils/fields.ts | 19 +- .../src/cart/workflows/add-to-cart.ts | 51 +-- .../list-shipping-options-for-cart.ts | 14 +- .../src/cart/workflows/refresh-cart-items.ts | 22 +- .../cart/workflows/transfer-cart-customer.ts | 5 + .../workflows/update-line-item-in-cart.ts | 44 ++- packages/core/types/src/cart/workflows.ts | 8 +- .../src/pricing/common/pricing-context.ts | 6 +- .../__tests__/filter-object-by-keys.spec.ts | 137 ++++++++ .../flatten-object-to-key-value-pairs.spec.ts | 36 ++ .../utils/src/common/filter-object-by-keys.ts | 86 +++++ .../flatten-object-to-key-value-pairs.ts | 116 +++++++ packages/core/utils/src/common/index.ts | 2 + .../carts/[id]/line-items/[line_id]/route.ts | 17 +- .../api/store/carts/[id]/line-items/route.ts | 24 +- .../products/set-pricing-context.ts | 5 +- packages/modules/cart/src/index.ts | 2 +- .../src/migrations/Migration20241212190401.ts | 11 + .../pricing/src/repositories/pricing.ts | 31 +- 29 files changed, 1089 insertions(+), 165 deletions(-) create mode 100644 .changeset/warm-comics-return.md create mode 100644 packages/core/utils/src/common/__tests__/filter-object-by-keys.spec.ts create mode 100644 packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts create mode 100644 packages/core/utils/src/common/filter-object-by-keys.ts create mode 100644 packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts create mode 100644 packages/modules/pricing/src/migrations/Migration20241212190401.ts diff --git a/.changeset/warm-comics-return.md b/.changeset/warm-comics-return.md new file mode 100644 index 0000000000000..1e8bcf080e016 --- /dev/null +++ b/.changeset/warm-comics-return.md @@ -0,0 +1,10 @@ +--- +"@medusajs/dashboard": patch +"@medusajs/core-flows": patch +"@medusajs/pricing": patch +"@medusajs/types": patch +"@medusajs/utils": patch +"@medusajs/medusa": patch +--- + +feat(medusa, pricing): Cart workflows handle pricing context accurately diff --git a/integration-tests/http/__tests__/cart/store/cart.spec.ts b/integration-tests/http/__tests__/cart/store/cart.spec.ts index 6005a7f6d5de1..6f3a73444fd69 100644 --- a/integration-tests/http/__tests__/cart/store/cart.spec.ts +++ b/integration-tests/http/__tests__/cart/store/cart.spec.ts @@ -271,7 +271,7 @@ medusaIntegrationTestRunner({ shipping_address: shippingAddressData, items: [{ variant_id: product.variants[0].id, quantity: 1 }], }, - storeHeadersWithCustomer + storeHeaders ) expect(response.status).toEqual(200) @@ -414,7 +414,7 @@ medusaIntegrationTestRunner({ items: [{ variant_id: product.variants[0].id, quantity: 1 }], promo_codes: [promotion.code], }, - storeHeadersWithCustomer + storeHeaders ) ).data.cart }) @@ -643,28 +643,61 @@ medusaIntegrationTestRunner({ }) describe("with sale price lists", () => { - let priceList - beforeEach(async () => { - priceList = ( + await api.post( + `/admin/price-lists`, + { + title: "test price list", + description: "test", + status: PriceListStatus.ACTIVE, + type: PriceListType.SALE, + prices: [ + { + amount: 350, + currency_code: "usd", + variant_id: product.variants[0].id, + }, + ], + }, + adminHeaders + ) + + const customerGroup = ( await api.post( - `/admin/price-lists`, - { - title: "test price list", - description: "test", - status: PriceListStatus.ACTIVE, - type: PriceListType.SALE, - prices: [ - { - amount: 350, - currency_code: "usd", - variant_id: product.variants[0].id, - }, - ], - }, + "/admin/customer-groups", + { name: "VIP" }, adminHeaders ) - ).data.price_list + ).data.customer_group + + await api.post( + `/admin/customer-groups/${customerGroup.id}/customers`, + { + add: [customer.id], + }, + adminHeaders + ) + + await api.post( + `/admin/price-lists`, + { + title: "test price list", + description: "test", + status: PriceListStatus.ACTIVE, + type: PriceListType.SALE, + prices: [ + { + amount: 200, + currency_code: "usd", + variant_id: product.variants[0].id, + }, + ], + rules: { + "customer.groups.id": [customerGroup.id], + }, + }, + adminHeaders + ) }) it("should add price from price list and set compare_at_unit_price", async () => { @@ -709,6 +742,158 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should add price from price list associated to a customer group when customer rules match", async () => { + const transferredCart = ( + await api.post( + `/store/carts/${cart.id}/customer`, + {}, + storeHeadersWithCustomer + ) + ).data.cart + + expect(transferredCart).toEqual( + expect.objectContaining({ + id: cart.id, + items: expect.arrayContaining([ + expect.objectContaining({ + unit_price: 200, + compare_at_unit_price: 1500, + is_tax_inclusive: true, + quantity: 1, + }), + ]), + }) + ) + + let response = await api.post( + `/store/carts/${cart.id}/line-items`, + { + variant_id: product.variants[0].id, + quantity: 1, + }, + storeHeadersWithCustomer + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: expect.arrayContaining([ + expect.objectContaining({ + unit_price: 200, + compare_at_unit_price: 1500, + is_tax_inclusive: true, + quantity: 2, + }), + ]), + }) + ) + }) + }) + }) + + describe("POST /store/carts/:id/line-items/:id", () => { + let item, customerGroup + + beforeEach(async () => { + cart = ( + await api.post( + `/store/carts`, + { + currency_code: "usd", + sales_channel_id: salesChannel.id, + region_id: region.id, + shipping_address: shippingAddressData, + items: [{ variant_id: product.variants[0].id, quantity: 1 }], + }, + storeHeadersWithCustomer + ) + ).data.cart + + item = cart.items[0] + + customerGroup = ( + await api.post( + "/admin/customer-groups", + { name: "VIP" }, + adminHeaders + ) + ).data.customer_group + + await api.post( + `/admin/customer-groups/${customerGroup.id}/customers`, + { + add: [customer.id], + }, + adminHeaders + ) + }) + + it("should update cart's line item", async () => { + let response = await api.post( + `/store/carts/${cart.id}/line-items/${item.id}`, + { + quantity: 2, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: expect.arrayContaining([ + expect.objectContaining({ + unit_price: 1500, + quantity: 2, + }), + ]), + }) + ) + + await api.post( + `/admin/price-lists`, + { + title: "test price list", + description: "test", + status: PriceListStatus.ACTIVE, + type: PriceListType.SALE, + prices: [ + { + amount: 200, + currency_code: "usd", + variant_id: product.variants[0].id, + }, + ], + rules: { + "customer.groups.id": [customerGroup.id], + }, + }, + adminHeaders + ) + + response = await api.post( + `/store/carts/${cart.id}/line-items/${item.id}`, + { quantity: 3 }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: expect.arrayContaining([ + expect.objectContaining({ + unit_price: 200, + quantity: 3, + }), + ]), + }) + ) }) }) diff --git a/integration-tests/http/__tests__/price-list/admin/price-list.spec.ts b/integration-tests/http/__tests__/price-list/admin/price-list.spec.ts index 341a902b5e869..5dbfb2c47f0f1 100644 --- a/integration-tests/http/__tests__/price-list/admin/price-list.spec.ts +++ b/integration-tests/http/__tests__/price-list/admin/price-list.spec.ts @@ -1,7 +1,7 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { - createAdminUser, adminHeaders, + createAdminUser, } from "../../../../helpers/create-admin-user" import { getPricelistFixture, @@ -102,7 +102,7 @@ medusaIntegrationTestRunner({ description: "Summer sale for VIP customers. 25% off selected items.", rules: { - customer_group_id: [customerGroup1.id], + "customer.groups.id": [customerGroup1.id], }, prices: [ { @@ -139,7 +139,7 @@ medusaIntegrationTestRunner({ starts_at: "2022-07-01T00:00:00.000Z", ends_at: "2022-07-31T00:00:00.000Z", rules: { - customer_group_id: [expect.stringContaining("cusgroup_")], + "customer.groups.id": [expect.stringContaining("cusgroup_")], }, prices: [ expect.objectContaining({ @@ -335,7 +335,7 @@ medusaIntegrationTestRunner({ starts_at: "2022-09-01T00:00:00.000Z", ends_at: "2022-12-31T00:00:00.000Z", rules: { - customer_group_id: [customerGroup1.id], + "customer.groups.id": [customerGroup1.id], }, } @@ -372,7 +372,7 @@ medusaIntegrationTestRunner({ }), ]), rules: { - customer_group_id: [customerGroup1.id], + "customer.groups.id": [customerGroup1.id], }, created_at: expect.any(String), updated_at: expect.any(String), diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index a30f1214b9831..503adfb9961d9 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -1105,7 +1105,7 @@ medusaIntegrationTestRunner({ variant_id: product.variants[0].id, }, ], - rules: { customer_group_id: [customerGroup.id] }, + rules: { "customer.groups.id": [customerGroup.id] }, }, adminHeaders ) @@ -1188,7 +1188,7 @@ medusaIntegrationTestRunner({ variant_id: product.variants[0].id, }, ], - rules: { customer_group_id: [customerGroup.id] }, + rules: { "customer.groups.id": [customerGroup.id] }, }, adminHeaders ) @@ -1783,7 +1783,7 @@ medusaIntegrationTestRunner({ variant_id: product.variants[0].id, }, ], - rules: { customer_group_id: [customerGroup.id] }, + rules: { "customer.groups.id": [customerGroup.id] }, }, adminHeaders ) @@ -1863,7 +1863,7 @@ medusaIntegrationTestRunner({ variant_id: product.variants[0].id, }, ], - rules: { customer_group_id: [customerGroup.id] }, + rules: { "customer.groups.id": [customerGroup.id] }, }, adminHeaders ) diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index f9a3ebb560e4b..c60fef4d6d7c0 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -29,6 +29,8 @@ import { import { ContainerRegistrationKeys, Modules, + PriceListStatus, + PriceListType, RuleOperator, } from "@medusajs/utils" import { @@ -786,7 +788,7 @@ medusaIntegrationTestRunner({ quantity: 1, }, ], - cart, + cart_id: cart.id, }, }) @@ -875,7 +877,7 @@ medusaIntegrationTestRunner({ quantity: 1, }, ], - cart, + cart_id: cart.id, }, throwOnError: false, }) @@ -906,7 +908,7 @@ medusaIntegrationTestRunner({ quantity: 1, }, ], - cart, + cart_id: cart.id, }, throwOnError: false, }) @@ -921,9 +923,307 @@ medusaIntegrationTestRunner({ }, ]) }) + + it("should add item to cart with price list", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const customer = await customerModule.createCustomers({ + first_name: "Test", + last_name: "Test", + }) + + const customer_group = await customerModule.createCustomerGroups({ + name: "Test Group", + }) + + await customerModule.addCustomerToGroup({ + customer_id: customer.id, + customer_group_id: customer_group.id, + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + customer_id: customer.id, + }) + + const [product] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + ]) + + const inventoryItem = await inventoryModule.createInventoryItems({ + sku: "inv-1234", + }) + + await inventoryModule.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: location.id, + stocked_quantity: 2, + }, + ]) + + const priceSet = await pricingModule.createPriceSets({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await pricingModule.createPricePreferences({ + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, + }) + + await pricingModule.createPriceLists([ + { + title: "test price list", + description: "test", + status: PriceListStatus.ACTIVE, + type: PriceListType.OVERRIDE, + prices: [ + { + amount: 1500, + currency_code: "usd", + price_set_id: priceSet.id, + }, + ], + rules: { + "customer.groups.id": [customer_group.id], + }, + }, + ]) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code", "sales_channel_id"], + }) + + await addToCartWorkflow(appContainer).run({ + input: { + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + cart_id: cart.id, + }, + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + relations: ["items"], + }) + + expect(cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: expect.arrayContaining([ + expect.objectContaining({ + unit_price: 1500, + is_tax_inclusive: true, + quantity: 1, + title: "Test variant", + }), + ]), + }) + ) + }) }) describe("updateLineItemInCartWorkflow", () => { + it("should update item in cart with price list", async () => { + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const customer = await customerModule.createCustomers({ + first_name: "Test", + last_name: "Test", + }) + + const customer_group = await customerModule.createCustomerGroups({ + name: "Test Group", + }) + + await customerModule.addCustomerToGroup({ + customer_id: customer.id, + customer_group_id: customer_group.id, + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const [product] = await productModule.createProducts([ + { + title: "Test product", + variants: [ + { + title: "Test variant", + }, + ], + }, + ]) + + const inventoryItem = await inventoryModule.createInventoryItems({ + sku: "inv-1234", + }) + + await inventoryModule.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: location.id, + stocked_quantity: 2, + }, + ]) + + const priceSet = await pricingModule.createPriceSets({ + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }) + + await pricingModule.createPriceLists([ + { + title: "test price list", + description: "test", + status: PriceListStatus.ACTIVE, + type: PriceListType.OVERRIDE, + prices: [ + { + amount: 1500, + currency_code: "usd", + price_set_id: priceSet.id, + }, + ], + rules: { + "customer.groups.id": [customer_group.id], + }, + }, + ]) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + let cart = await cartModuleService.createCarts({ + currency_code: "usd", + sales_channel_id: salesChannel.id, + customer_id: customer.id, + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + unit_price: 5000, + title: "Test item", + }, + ], + }) + + cart = await cartModuleService.retrieveCart(cart.id, { + select: ["id", "region_id", "currency_code"], + relations: ["items", "items.variant_id", "items.metadata"], + }) + + const item = cart.items?.[0]! + + const { errors } = await updateLineItemInCartWorkflow( + appContainer + ).run({ + input: { + cart_id: cart.id, + item_id: item.id, + update: { + metadata: { + foo: "bar", + }, + quantity: 2, + }, + }, + throwOnError: true, + }) + + const updatedItem = await cartModuleService.retrieveLineItem(item.id) + + expect(updatedItem).toEqual( + expect.objectContaining({ + id: item.id, + unit_price: 1500, + quantity: 2, + }) + ) + }) + describe("compensation", () => { it("should revert line item update to original state", async () => { expect.assertions(2) @@ -1024,8 +1324,8 @@ medusaIntegrationTestRunner({ const { errors } = await workflow.run({ input: { - cart, - item, + cart_id: cart.id, + item_id: item.id, update: { metadata: { foo: "bar", diff --git a/integration-tests/modules/__tests__/link-modules/product-variant-price-set.spec.ts b/integration-tests/modules/__tests__/link-modules/product-variant-price-set.spec.ts index dfbd9db37df0c..648143e2d0050 100644 --- a/integration-tests/modules/__tests__/link-modules/product-variant-price-set.spec.ts +++ b/integration-tests/modules/__tests__/link-modules/product-variant-price-set.spec.ts @@ -1,10 +1,10 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { IPricingModuleService, IProductModuleService } from "@medusajs/types" import { ContainerRegistrationKeys, Modules, remoteQueryObjectFromString, } from "@medusajs/utils" -import { medusaIntegrationTestRunner } from "@medusajs/test-utils" jest.setTimeout(50000) @@ -56,7 +56,7 @@ medusaIntegrationTestRunner({ amount: 5000, currency_code: "eur", rules: { - customer_group_id: "vip", + "customer.groups.id": "vip", }, }, ], @@ -75,7 +75,7 @@ medusaIntegrationTestRunner({ amount: 100, currency_code: "usd", rules: { - customer_group_id: "vip", + "customer.groups.id": "vip", }, }, ], @@ -107,7 +107,9 @@ medusaIntegrationTestRunner({ "variants.calculated_price": { context: { currency_code: "usd", - customer_group_id: "vip", + customer: { + groups: [{ id: "vip" }], + }, }, }, }, diff --git a/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts b/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts index 140b7cf52e7d4..98f6608eaf505 100644 --- a/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts +++ b/integration-tests/modules/__tests__/price-lists/admin/price-lists.spec.ts @@ -1,3 +1,4 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { ICustomerModuleService, IPricingModuleService, @@ -5,7 +6,6 @@ import { IRegionModuleService, } from "@medusajs/types" import { Modules, PriceListStatus, PriceListType } from "@medusajs/utils" -import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { createAdminUser } from "../../../../helpers/create-admin-user" import { createVariantPriceSet } from "../../../helpers/create-variant-price-set" @@ -94,7 +94,7 @@ medusaIntegrationTestRunner({ }, ], rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, }, ]) @@ -116,7 +116,7 @@ medusaIntegrationTestRunner({ updated_at: expect.any(String), deleted_at: null, rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, prices: [ { @@ -185,7 +185,7 @@ medusaIntegrationTestRunner({ }, ], rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, }, { @@ -206,7 +206,7 @@ medusaIntegrationTestRunner({ }, ], rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, }, ]) @@ -252,7 +252,7 @@ medusaIntegrationTestRunner({ }, ], rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, }, ]) @@ -276,7 +276,7 @@ medusaIntegrationTestRunner({ updated_at: expect.any(String), deleted_at: null, rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, prices: [ { @@ -342,7 +342,7 @@ medusaIntegrationTestRunner({ status: "active", starts_at: new Date(), rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, prices: [ { @@ -374,7 +374,7 @@ medusaIntegrationTestRunner({ updated_at: expect.any(String), deleted_at: null, rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, prices: [ expect.objectContaining({ @@ -519,7 +519,7 @@ medusaIntegrationTestRunner({ title: "new price list name", description: "new price list description", rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, } @@ -536,7 +536,7 @@ medusaIntegrationTestRunner({ title: "new price list name", description: "new price list description", rules: { - customer_group_id: [customerGroup.id], + "customer.groups.id": [customerGroup.id], }, }) ) diff --git a/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/components/price-list-configuration-form/price-list-configuration-form.tsx b/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/components/price-list-configuration-form/price-list-configuration-form.tsx index 1e16670cb6d39..ed3d34f99b277 100644 --- a/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/components/price-list-configuration-form/price-list-configuration-form.tsx +++ b/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/components/price-list-configuration-form/price-list-configuration-form.tsx @@ -95,9 +95,9 @@ export const PriceListConfigurationForm = ({ const rules = { ...priceList.rules } // preserve other rules set on the PL if (groupIds.length) { - rules["customer_group_id"] = groupIds + rules["customer.groups.id"] = groupIds } else { - delete rules["customer_group_id"] + delete rules["customer.groups.id"] } await mutateAsync( diff --git a/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/price-list-configuration.tsx b/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/price-list-configuration.tsx index 3caa0609043df..a30b937e3622f 100644 --- a/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/price-list-configuration.tsx +++ b/packages/admin/dashboard/src/routes/price-lists/price-list-configuration/price-list-configuration.tsx @@ -12,7 +12,7 @@ export const PriceListConfiguration = () => { const { price_list, isPending, isError, error } = usePriceList(id!) - const customerGroupIds = price_list?.rules.customer_group_id as + const customerGroupIds = price_list?.rules?.["customer.groups.id"] as | string[] | undefined @@ -37,8 +37,8 @@ export const PriceListConfiguration = () => { const isCustomerGroupsReady = isPending ? false : !!customerGroupIds?.length && isCustomerGroupsPending - ? false - : true + ? false + : true const ready = !isPending && !!price_list && isCustomerGroupsReady diff --git a/packages/admin/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-create-form.tsx b/packages/admin/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-create-form.tsx index d5a14b058a7bc..26c0bb43801ad 100644 --- a/packages/admin/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-create-form.tsx +++ b/packages/admin/dashboard/src/routes/price-lists/price-list-create/components/price-list-create-form/price-list-create-form.tsx @@ -82,7 +82,7 @@ export const PriceListCreateForm = ({ const { rules, products } = data const rulesPayload = rules?.customer_group_id?.length - ? { customer_group_id: rules.customer_group_id.map((cg) => cg.id) } + ? { "customer.groups.id": rules.customer_group_id.map((cg) => cg.id) } : undefined const prices = exctractPricesFromProducts(products, regions) @@ -120,10 +120,13 @@ export const PriceListCreateForm = ({ ) => { form.clearErrors(fields) - const values = fields.reduce((acc, key) => { - acc[key] = form.getValues(key) - return acc - }, {} as Record) + const values = fields.reduce( + (acc, key) => { + acc[key] = form.getValues(key) + return acc + }, + {} as Record + ) const validationResult = schema.safeParse(values) diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts index f3d6f7e69aa78..410e4067a2366 100644 --- a/packages/core/core-flows/src/cart/utils/fields.ts +++ b/packages/core/core-flows/src/cart/utils/fields.ts @@ -1,13 +1,14 @@ +// Always ensure that cartFieldsForPricingContext is present in cartFieldsForRefreshSteps export const cartFieldsForRefreshSteps = [ "id", "currency_code", "quantity", "subtotal", + "item_total", "total", "item_subtotal", "shipping_subtotal", "region_id", - "currency_code", "metadata", "completed_at", "sales_channel_id", @@ -100,6 +101,22 @@ export const completeCartFields = [ "items.variant.inventory_items.inventory.location_levels.stock_locations.sales_channels.name", ] +export const cartFieldsForPricingContext = [ + "id", + "sales_channel_id", + "currency_code", + "region_id", + "shipping_address.city", + "shipping_address.country_code", + "shipping_address.province", + "shipping_address.postal_code", + "item_total", + "total", + "customer.id", + "email", + "customer.groups.id", +] + export const productVariantsFields = [ "id", "title", 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 7050c005adc00..22beb5f262865 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 @@ -4,11 +4,12 @@ import { } from "@medusajs/framework/types" import { CartWorkflowEvents } from "@medusajs/framework/utils" import { + WorkflowData, createWorkflow, parallelize, transform, - WorkflowData, } from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "../../common" import { emitEventStep } from "../../common/steps/emit-event" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { @@ -18,11 +19,16 @@ import { } from "../steps" import { validateCartStep } from "../steps/validate-cart" import { validateVariantPricesStep } from "../steps/validate-variant-prices" -import { productVariantsFields } from "../utils/fields" +import { + cartFieldsForPricingContext, + productVariantsFields, +} from "../utils/fields" import { prepareLineItemData } from "../utils/prepare-line-item-data" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { refreshCartItemsWorkflow } from "./refresh-cart-items" +const cartFields = ["completed_at"].concat(cartFieldsForPricingContext) + export const addToCartWorkflowId = "add-to-cart" /** * This workflow adds items to a cart. @@ -30,20 +36,21 @@ export const addToCartWorkflowId = "add-to-cart" export const addToCartWorkflow = createWorkflow( addToCartWorkflowId, (input: WorkflowData) => { - validateCartStep(input) + const cartQuery = useQueryGraphStep({ + entity: "cart", + filters: { id: input.cart_id }, + fields: cartFields, + options: { throwIfKeyNotFound: true }, + }).config({ name: "get-cart" }) - const variantIds = transform({ input }, (data) => { - return (data.input.items ?? []).map((i) => i.variant_id) + const cart = transform({ cartQuery }, ({ cartQuery }) => { + return cartQuery.data[0] }) - // TODO: This is on par with the context used in v1.*, but we can be more flexible. - // TODO: create a common workflow to fetch variants and its prices - const pricingContext = transform({ cart: input.cart }, (data) => { - return { - currency_code: data.cart.currency_code, - region_id: data.cart.region_id, - customer_id: data.cart.customer_id, - } + validateCartStep({ cart }) + + const variantIds = transform({ input }, (data) => { + return (data.input.items ?? []).map((i) => i.variant_id) }) const variants = useRemoteQueryStep({ @@ -51,9 +58,7 @@ export const addToCartWorkflow = createWorkflow( fields: productVariantsFields, variables: { id: variantIds, - calculated_price: { - context: pricingContext, - }, + calculated_price: { context: cart }, }, throw_if_key_not_found: true, }) @@ -73,7 +78,7 @@ export const addToCartWorkflow = createWorkflow( variant.calculated_price.is_calculated_price_tax_inclusive, quantity: item.quantity, metadata: item?.metadata ?? {}, - cartId: data.input.cart.id, + cartId: input.cart_id, }) as CreateLineItemForCartDTO }) @@ -81,13 +86,13 @@ export const addToCartWorkflow = createWorkflow( }) const { itemsToCreate = [], itemsToUpdate = [] } = getLineItemActionsStep({ - id: input.cart.id, + id: cart.id, items: lineItems, }) confirmVariantInventoryWorkflow.runAsStep({ input: { - sales_channel_id: input.cart.sales_channel_id as string, + sales_channel_id: cart.sales_channel_id, variants, items: input.items, itemsToUpdate, @@ -96,22 +101,22 @@ export const addToCartWorkflow = createWorkflow( parallelize( createLineItemsStep({ - id: input.cart.id, + id: cart.id, items: itemsToCreate, }), updateLineItemsStep({ - id: input.cart.id, + id: cart.id, items: itemsToUpdate, }) ) refreshCartItemsWorkflow.runAsStep({ - input: { cart_id: input.cart.id }, + input: { cart_id: cart.id }, }) emitEventStep({ eventName: CartWorkflowEvents.UPDATED, - data: { id: input.cart.id }, + data: { id: cart.id }, }) } ) diff --git a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts index 0f048364138d7..f4a897dfb699d 100644 --- a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart.ts @@ -7,6 +7,7 @@ import { } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep, validatePresenceOfStep } from "../../common" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" +import { cartFieldsForPricingContext } from "../utils/fields" export const listShippingOptionsForCartWorkflowId = "list-shipping-options-for-cart" @@ -26,18 +27,7 @@ export const listShippingOptionsForCartWorkflow = createWorkflow( const cartQuery = useQueryGraphStep({ entity: "cart", filters: { id: input.cart_id }, - fields: [ - "id", - "sales_channel_id", - "currency_code", - "region_id", - "shipping_address.city", - "shipping_address.country_code", - "shipping_address.province", - "shipping_address.postal_code", - "item_total", - "total", - ], + fields: cartFieldsForPricingContext, options: { throwIfKeyNotFound: true }, }).config({ name: "get-cart" }) diff --git a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts index 46e1194d86213..ac5724c6c2bb7 100644 --- a/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts +++ b/packages/core/core-flows/src/cart/workflows/refresh-cart-items.ts @@ -1,4 +1,8 @@ -import { isDefined, PromotionActions } from "@medusajs/framework/utils" +import { + filterObjectByKeys, + isDefined, + PromotionActions, +} from "@medusajs/framework/utils" import { createWorkflow, transform, @@ -9,6 +13,7 @@ import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { updateLineItemsStep } from "../steps" import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { + cartFieldsForPricingContext, cartFieldsForRefreshSteps, productVariantsFields, } from "../utils/fields" @@ -41,16 +46,9 @@ export const refreshCartItemsWorkflow = createWorkflow( return (data.cart.items ?? []).map((i) => i.variant_id) }) - const pricingContext = transform( - { cart }, - ({ cart: { currency_code, region_id, customer_id } }) => { - return { - currency_code, - region_id, - customer_id, - } - } - ) + const cartPricingContext = transform({ cart }, ({ cart }) => { + return filterObjectByKeys(cart, cartFieldsForPricingContext) + }) const variants = useRemoteQueryStep({ entry_point: "variants", @@ -58,7 +56,7 @@ export const refreshCartItemsWorkflow = createWorkflow( variables: { id: variantIds, calculated_price: { - context: pricingContext, + context: cartPricingContext, }, }, throw_if_key_not_found: true, diff --git a/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts b/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts index 05ccc1e68dfc2..152c665cd75cb 100644 --- a/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts +++ b/packages/core/core-flows/src/cart/workflows/transfer-cart-customer.ts @@ -6,6 +6,7 @@ import { } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../common" import { updateCartsStep } from "../steps" +import { refreshCartItemsWorkflow } from "./refresh-cart-items" export const transferCartCustomerWorkflowId = "transfer-cart-customer" /** @@ -65,6 +66,10 @@ export const transferCartCustomerWorkflow = createWorkflow( ) updateCartsStep(cartInput) + + refreshCartItemsWorkflow.runAsStep({ + input: { cart_id: input.id }, + }) } ) } diff --git a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts index 528c4b01a4376..95f6e359af115 100644 --- a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts @@ -4,14 +4,20 @@ import { createWorkflow, transform, } from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "../../common" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { updateLineItemsStepWithSelector } from "../../line-item/steps" import { validateCartStep } from "../steps/validate-cart" import { validateVariantPricesStep } from "../steps/validate-variant-prices" -import { productVariantsFields } from "../utils/fields" +import { + cartFieldsForPricingContext, + productVariantsFields, +} from "../utils/fields" import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory" import { refreshCartItemsWorkflow } from "./refresh-cart-items" +const cartFields = cartFieldsForPricingContext.concat(["items.*"]) + export const updateLineItemInCartWorkflowId = "update-line-item-in-cart" /** * This workflow updates a cart's line item. @@ -19,19 +25,22 @@ export const updateLineItemInCartWorkflowId = "update-line-item-in-cart" export const updateLineItemInCartWorkflow = createWorkflow( updateLineItemInCartWorkflowId, (input: WorkflowData) => { - validateCartStep(input) + const cartQuery = useQueryGraphStep({ + entity: "cart", + filters: { id: input.cart_id }, + fields: cartFields, + options: { throwIfKeyNotFound: true }, + }).config({ name: "get-cart" }) - const variantIds = transform({ input }, (data) => { - return [data.input.item.variant_id] + const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0]) + const item = transform({ cart, input }, ({ cart, input }) => { + return cart.items.find((i) => i.id === input.item_id) }) - // TODO: This is on par with the context used in v1.*, but we can be more flexible. - const pricingContext = transform({ cart: input.cart }, (data) => { - return { - currency_code: data.cart.currency_code, - region_id: data.cart.region_id, - customer_id: data.cart.customer_id, - } + validateCartStep({ cart }) + + const variantIds = transform({ item }, ({ item }) => { + return [item.variant_id] }) const variants = useRemoteQueryStep({ @@ -40,7 +49,7 @@ export const updateLineItemInCartWorkflow = createWorkflow( variables: { id: variantIds, calculated_price: { - context: pricingContext, + context: cart, }, }, throw_if_key_not_found: true, @@ -48,13 +57,13 @@ export const updateLineItemInCartWorkflow = createWorkflow( validateVariantPricesStep({ variants }) - const items = transform({ input }, (data) => { - return [data.input.item] + const items = transform({ item }, ({ item }) => { + return [item] }) confirmVariantInventoryWorkflow.runAsStep({ input: { - sales_channel_id: input.cart.sales_channel_id as string, + sales_channel_id: cart.sales_channel_id, variants, items, }, @@ -62,7 +71,6 @@ export const updateLineItemInCartWorkflow = createWorkflow( const lineItemUpdate = transform({ input, variants }, (data) => { const variant = data.variants[0] - const item = data.input.item return { data: { @@ -72,7 +80,7 @@ export const updateLineItemInCartWorkflow = createWorkflow( !!variant.calculated_price.is_calculated_price_tax_inclusive, }, selector: { - id: item.id, + id: data.input.item_id, }, } }) @@ -80,7 +88,7 @@ export const updateLineItemInCartWorkflow = createWorkflow( updateLineItemsStepWithSelector(lineItemUpdate) refreshCartItemsWorkflow.runAsStep({ - input: { cart_id: input.cart.id }, + input: { cart_id: input.cart_id }, }) } ) diff --git a/packages/core/types/src/cart/workflows.ts b/packages/core/types/src/cart/workflows.ts index 92b8d6282ba72..c5f94c36fa111 100644 --- a/packages/core/types/src/cart/workflows.ts +++ b/packages/core/types/src/cart/workflows.ts @@ -4,7 +4,7 @@ import { PaymentCollectionDTO } from "../payment" import { ProductDTO } from "../product" import { RegionDTO } from "../region" import { BigNumberInput } from "../totals" -import { CartDTO, CartLineItemDTO } from "./common" +import { CartDTO } from "./common" import { CreateAddressDTO, UpdateAddressDTO, @@ -44,8 +44,8 @@ export interface CreateCartCreateLineItemDTO { } export interface UpdateLineItemInCartWorkflowInputDTO { - cart: CartDTO - item: CartLineItemDTO + cart_id: string + item_id: string update: Partial } @@ -80,8 +80,8 @@ export interface CreateCartWorkflowInputDTO { } export interface AddToCartWorkflowInputDTO { + cart_id: string items: CreateCartCreateLineItemDTO[] - cart: CartWorkflowDTO } export interface UpdateCartWorkflowInputDTO { diff --git a/packages/core/types/src/pricing/common/pricing-context.ts b/packages/core/types/src/pricing/common/pricing-context.ts index 13afe18ef47c8..b0d8a8420d01b 100644 --- a/packages/core/types/src/pricing/common/pricing-context.ts +++ b/packages/core/types/src/pricing/common/pricing-context.ts @@ -2,5 +2,9 @@ export type MedusaPricingContext = { region_id?: string currency_code?: string customer_id?: string - customer_group_id?: string[] + customer?: { + groups?: { + id: string + }[] + } } diff --git a/packages/core/utils/src/common/__tests__/filter-object-by-keys.spec.ts b/packages/core/utils/src/common/__tests__/filter-object-by-keys.spec.ts new file mode 100644 index 0000000000000..9cd316b17187c --- /dev/null +++ b/packages/core/utils/src/common/__tests__/filter-object-by-keys.spec.ts @@ -0,0 +1,137 @@ +import { filterObjectByKeys } from "../filter-object-by-keys" + +describe("filterObjectByKeys", function () { + it("should return an object with only the filtered keys", function () { + const cart = { + id: "cart_id", + customer: { + id: "cus_id", + groups: [ + { id: "group_1", name: "test" }, + { id: "group_2", name: "test 2" }, + ], + }, + items: [ + { + product_id: "product-1", + product: { id: "product-1" }, + }, + { + product_id: "product-2", + product: { id: "product-2" }, + }, + ], + shipping_method: null, + } + + let transformedObject = filterObjectByKeys(cart, [ + "id", + "customer.id", + "customer.groups.id", + "customer.groups.name", + "items.product", + ]) + + expect(transformedObject).toEqual({ + id: "cart_id", + customer: { + id: "cus_id", + groups: [ + { + id: "group_1", + name: "test", + }, + { + id: "group_2", + name: "test 2", + }, + ], + }, + items: [ + { + product: { + id: "product-1", + }, + }, + { + product: { + id: "product-2", + }, + }, + ], + }) + + transformedObject = filterObjectByKeys(cart, [ + "id", + "customer.id", + "customer.groups.id", + "customer.groups.name", + ]) + + expect(transformedObject).toEqual({ + id: "cart_id", + customer: { + id: "cus_id", + groups: [ + { + id: "group_1", + name: "test", + }, + { + id: "group_2", + name: "test 2", + }, + ], + }, + }) + + transformedObject = filterObjectByKeys(cart, [ + "id", + "customer.id", + "customer.groups.id", + ]) + + expect(transformedObject).toEqual({ + id: "cart_id", + customer: { + id: "cus_id", + groups: [ + { + id: "group_1", + }, + { + id: "group_2", + }, + ], + }, + }) + + transformedObject = filterObjectByKeys(cart, ["id", "customer.id"]) + + expect(transformedObject).toEqual({ + id: "cart_id", + customer: { + id: "cus_id", + }, + }) + + transformedObject = filterObjectByKeys(cart, ["id"]) + + expect(transformedObject).toEqual({ + id: "cart_id", + }) + + transformedObject = filterObjectByKeys(cart, []) + + expect(transformedObject).toEqual({}) + + transformedObject = filterObjectByKeys(cart, [ + "doesnotexist.doesnotexist", + "shipping_method.city", + ]) + + expect(transformedObject).toEqual({ + shipping_method: null, + }) + }) +}) diff --git a/packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts b/packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts new file mode 100644 index 0000000000000..404a0cf8ddbdc --- /dev/null +++ b/packages/core/utils/src/common/__tests__/flatten-object-to-key-value-pairs.spec.ts @@ -0,0 +1,36 @@ +import { flattenObjectToKeyValuePairs } from "../flatten-object-to-key-value-pairs" + +describe("flattenObjectToKeyValuePairs", function () { + it("should return only the properties path of the properties that are set to true", function () { + const cart = { + id: "cart_id", + customer: { + id: "cus_id", + groups: [ + { id: "group_1", name: "test" }, + { id: "group_2", name: "test 2" }, + ], + }, + items: [ + { + product_id: "product-1", + product: { id: "product-1" }, + }, + { + product_id: "product-2", + product: { id: "product-2" }, + }, + ], + } + + const keyValueParis = flattenObjectToKeyValuePairs(cart) + expect(keyValueParis).toEqual({ + id: "cart_id", + "customer.id": "cus_id", + "customer.groups.id": ["group_1", "group_2"], + "customer.groups.name": ["test", "test 2"], + "items.product_id": ["product-1", "product-2"], + "items.product.id": ["product-1", "product-2"], + }) + }) +}) diff --git a/packages/core/utils/src/common/filter-object-by-keys.ts b/packages/core/utils/src/common/filter-object-by-keys.ts new file mode 100644 index 0000000000000..63fed4c8e888f --- /dev/null +++ b/packages/core/utils/src/common/filter-object-by-keys.ts @@ -0,0 +1,86 @@ +import { isDefined } from "./is-defined" + +export function filterObjectByKeys(obj, paths) { + function buildObject(paths) { + const result = {} + + paths.forEach((path) => { + const parts = path.split(".") + + // Handle top-level properties + if (parts.length === 1) { + const [part] = parts + if (obj[part] !== undefined) { + result[part] = obj[part] + } + return + } + + let current = result + let source = obj + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isLast = i === parts.length - 1 + + if (!isDefined(current) || source === null) { + return + } + // Initialize the current path if it doesn't exist + if (!current[part]) { + if (Array.isArray(source[part])) { + current[part] = source[part].map(() => ({})) + } else if (source[part] === null) { + current[part] = null + } else if (isDefined(source[part])) { + current[part] = {} + } + } + + if (Array.isArray(source[part])) { + // Get the array path base (e.g., "customer.groups") + const arrayPath = parts.slice(0, i + 1).join(".") + // Find all paths that start with this array path + const relevantPaths = paths + .filter((p) => p.startsWith(arrayPath + ".")) + .map((p) => p.slice(arrayPath.length + 1)) // Remove the array path prefix + + // Update array items with all relevant properties + current[part] = source[part].map((item, idx) => { + const existingItem = current[part][idx] || {} + relevantPaths.forEach((subPath) => { + const value = subPath + .split(".") + .reduce((obj, key) => obj?.[key], item) + if (value !== undefined) { + let tempObj = existingItem + const keys = subPath.split(".") + + keys.slice(0, -1).forEach((key) => { + tempObj[key] = tempObj[key] || {} + tempObj = tempObj[key] + }) + + tempObj[keys[keys.length - 1]] = value + } + }) + + return existingItem + }) + break + } else { + if (isLast) { + current[part] = source[part] + } else { + current = current[part] + source = source[part] + } + } + } + }) + + return result + } + + return buildObject(paths) +} diff --git a/packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts b/packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts new file mode 100644 index 0000000000000..cfa50f4e6f8f7 --- /dev/null +++ b/packages/core/utils/src/common/flatten-object-to-key-value-pairs.ts @@ -0,0 +1,116 @@ +type NestedObject = { + [key: string]: any +} + +export function flattenObjectToKeyValuePairs(obj: NestedObject): NestedObject { + const result: NestedObject = {} + + // Find all paths that contain arrays of objects + function findArrayPaths( + obj: unknown, + currentPath: string[] = [] + ): string[][] { + const paths: string[][] = [] + + if (!obj || typeof obj !== "object") { + return paths + } + + // If it's an array of objects, add this path + if (Array.isArray(obj) && obj.length > 0 && typeof obj[0] === "object") { + paths.push(currentPath) + } + + // Check all properties + if (typeof obj === "object") { + Object.entries(obj as Record).forEach(([key, value]) => { + const newPath = [...currentPath, key] + paths.push(...findArrayPaths(value, newPath)) + }) + } + + return paths + } + + // Extract array values at a specific path + function getArrayValues(obj: unknown, path: string[]): unknown[] { + const arrayObj = path.reduce((acc: unknown, key: string) => { + if (acc && typeof acc === "object") { + return (acc as Record)[key] + } + return undefined + }, obj) + + if (!Array.isArray(arrayObj)) return [] + + return arrayObj + } + + // Process non-array paths + function processRegularPaths(obj: unknown, prefix = ""): void { + if (!obj || typeof obj !== "object") { + result[prefix] = obj + return + } + + if (Array.isArray(obj)) return + + Object.entries(obj as Record).forEach(([key, value]) => { + const newPrefix = prefix ? `${prefix}.${key}` : key + if (value && typeof value === "object" && !Array.isArray(value)) { + processRegularPaths(value, newPrefix) + } else if (!Array.isArray(value)) { + result[newPrefix] = value + } + }) + } + + // Process the object + processRegularPaths(obj) + + // Find and process array paths + const arrayPaths = findArrayPaths(obj) + arrayPaths.forEach((path) => { + const pathStr = path.join(".") + const arrayObjects = getArrayValues(obj, path) + + if (Array.isArray(arrayObjects) && arrayObjects.length > 0) { + // Get all possible keys from the array objects + const keys = new Set() + arrayObjects.forEach((item) => { + if (item && typeof item === "object") { + Object.keys(item as object).forEach((k) => keys.add(k)) + } + }) + + // Process each key + keys.forEach((key) => { + const values = arrayObjects + .map((item) => { + if (item && typeof item === "object") { + return (item as Record)[key] + } + return undefined + }) + .filter((v) => v !== undefined) + + if (values.length > 0) { + const newPath = `${pathStr}.${key}` + if (values.every((v) => typeof v === "object" && !Array.isArray(v))) { + // If these are all objects, recursively process them + const subObj = { [key]: values } + const subResult = flattenObjectToKeyValuePairs(subObj) + Object.entries(subResult).forEach(([k, v]) => { + const finalPath = `${pathStr}.${k}` + result[finalPath] = v + }) + } else { + result[newPath] = values + } + } + }) + } + }) + + return result +} diff --git a/packages/core/utils/src/common/index.ts b/packages/core/utils/src/common/index.ts index b690a56010d3d..f0421986ace0e 100644 --- a/packages/core/utils/src/common/index.ts +++ b/packages/core/utils/src/common/index.ts @@ -18,7 +18,9 @@ export * from "./dynamic-import" export * from "./env-editor" export * from "./errors" export * from "./file-system" +export * from "./filter-object-by-keys" export * from "./filter-operator-map" +export * from "./flatten-object-to-key-value-pairs" export * from "./generate-entity-id" export * from "./get-caller-file-path" export * from "./get-config-file" diff --git a/packages/medusa/src/api/store/carts/[id]/line-items/[line_id]/route.ts b/packages/medusa/src/api/store/carts/[id]/line-items/[line_id]/route.ts index d073568957fe5..5a4a4b8a91e15 100644 --- a/packages/medusa/src/api/store/carts/[id]/line-items/[line_id]/route.ts +++ b/packages/medusa/src/api/store/carts/[id]/line-items/[line_id]/route.ts @@ -2,10 +2,10 @@ import { deleteLineItemsWorkflow, updateLineItemInCartWorkflow, } from "@medusajs/core-flows" +import { prepareListQuery } from "@medusajs/framework" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" import { HttpTypes } from "@medusajs/framework/types" import { MedusaError } from "@medusajs/framework/utils" -import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" -import { prepareListQuery } from "@medusajs/framework" import { refetchCart } from "../../../helpers" import { StoreUpdateCartLineItemType } from "../../../validators" @@ -13,6 +13,7 @@ export const POST = async ( req: MedusaRequest, res: MedusaResponse ) => { + // TODO: Move this to the workflow when the query to line item is fixed const cart = await refetchCart( req.params.id, req.scope, @@ -39,14 +40,12 @@ export const POST = async ( ) } - const input = { - cart, - item, - update: req.validatedBody, - } - await updateLineItemInCartWorkflow(req.scope).run({ - input, + input: { + cart_id: req.params.id, + item_id: item.id, + update: req.validatedBody, + }, }) const updatedCart = await refetchCart( diff --git a/packages/medusa/src/api/store/carts/[id]/line-items/route.ts b/packages/medusa/src/api/store/carts/[id]/line-items/route.ts index 3e3c6d9307461..6ede47979d122 100644 --- a/packages/medusa/src/api/store/carts/[id]/line-items/route.ts +++ b/packages/medusa/src/api/store/carts/[id]/line-items/route.ts @@ -1,33 +1,25 @@ import { addToCartWorkflow } from "@medusajs/core-flows" import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" import { refetchCart } from "../../helpers" import { StoreAddCartLineItemType } from "../../validators" -import { HttpTypes } from "@medusajs/framework/types" export const POST = async ( req: MedusaRequest, res: MedusaResponse ) => { - const cart = await refetchCart( - req.params.id, - req.scope, - req.remoteQueryConfig.fields - ) - - const workflowInput = { - items: [req.validatedBody], - cart, - } - await addToCartWorkflow(req.scope).run({ - input: workflowInput, - } as any) + input: { + cart_id: req.params.id, + items: [req.validatedBody], + }, + }) - const updatedCart = await refetchCart( + const cart = await refetchCart( req.params.id, req.scope, req.remoteQueryConfig.fields ) - res.status(200).json({ cart: updatedCart }) + res.status(200).json({ cart }) } diff --git a/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts b/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts index b062851533a5a..8f74374e80ecf 100644 --- a/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts +++ b/packages/medusa/src/api/utils/middlewares/products/set-pricing-context.ts @@ -49,7 +49,10 @@ export function setPricingContext() { ["id"] ) - pricingContext.customer_group_id = customerGroups.map((cg) => cg.id) + pricingContext.customer = { groups: [] } + customerGroups.map((cg) => + pricingContext.customer?.groups?.push({ id: cg.id }) + ) } req.pricingContext = pricingContext diff --git a/packages/modules/cart/src/index.ts b/packages/modules/cart/src/index.ts index 08b6d2438e85d..e73b8dc888cc0 100644 --- a/packages/modules/cart/src/index.ts +++ b/packages/modules/cart/src/index.ts @@ -1,5 +1,5 @@ -import { CartModuleService } from "./services" import { Module, Modules } from "@medusajs/framework/utils" +import { CartModuleService } from "./services" export default Module(Modules.CART, { service: CartModuleService, diff --git a/packages/modules/pricing/src/migrations/Migration20241212190401.ts b/packages/modules/pricing/src/migrations/Migration20241212190401.ts new file mode 100644 index 0000000000000..77e862f03f2fb --- /dev/null +++ b/packages/modules/pricing/src/migrations/Migration20241212190401.ts @@ -0,0 +1,11 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20241212190401 extends Migration { + async up(): Promise { + this.addSql( + `UPDATE price_list_rule SET attribute = 'customer.groups.id' WHERE attribute = 'customer_group_id';` + ) + } + + async down(): Promise {} +} diff --git a/packages/modules/pricing/src/repositories/pricing.ts b/packages/modules/pricing/src/repositories/pricing.ts index 1b6f47a4b8653..96ee57a903936 100644 --- a/packages/modules/pricing/src/repositories/pricing.ts +++ b/packages/modules/pricing/src/repositories/pricing.ts @@ -1,4 +1,6 @@ import { + flattenObjectToKeyValuePairs, + isPresent, MedusaError, MikroOrmBase, PriceListStatus, @@ -58,6 +60,14 @@ export class PricingRepository return [] } + const flattenedContext = Object.entries( + flattenObjectToKeyValuePairs(context) + ).filter( + ([key, value]) => + (isPresent(value) && !Array.isArray(value)) || + (Array.isArray(value) && value.flat(1).length) + ) + // Gets all the prices where rules match for each of the contexts // that the price set is configured for const priceSubQueryKnex = knex({ @@ -135,19 +145,20 @@ export class PricingRepository priceBuilder .whereNull("price.price_list_id") .andWhere((withoutPriceListBuilder) => { - for (const [key, value] of Object.entries(context)) { + for (const [key, value] of flattenedContext) { withoutPriceListBuilder.orWhere((orBuilder) => { orBuilder.where("pr.attribute", key) if (typeof value === "number") { - orBuilder.where((operatorGroupBuilder) => { - buildOperatorQueries(operatorGroupBuilder, value) - }) + buildOperatorQueries(orBuilder, value) } else { - orBuilder.where({ "pr.value": value }) + const normalizeValue = Array.isArray(value) ? value : [value] + + orBuilder.whereIn("pr.value", normalizeValue) } }) } + withoutPriceListBuilder.orWhere("price.rules_count", "=", 0) }) }) @@ -171,7 +182,7 @@ export class PricingRepository }) .andWhere(function () { this.andWhere(function () { - for (const [key, value] of Object.entries(context)) { + for (const [key, value] of flattenedContext) { this.orWhere({ "plr.attribute": key }) this.where( "plr.value", @@ -185,14 +196,18 @@ export class PricingRepository this.andWhere(function () { this.andWhere((contextBuilder) => { - for (const [key, value] of Object.entries(context)) { + for (const [key, value] of flattenedContext) { contextBuilder.orWhere((orBuilder) => { orBuilder.where("pr.attribute", key) if (typeof value === "number") { buildOperatorQueries(orBuilder, value) } else { - orBuilder.where({ "pr.value": value }) + const normalizeValue = Array.isArray(value) + ? value + : [value] + + orBuilder.whereIn("pr.value", normalizeValue) } }) }