diff --git a/.changeset/silly-clouds-kneel.md b/.changeset/silly-clouds-kneel.md new file mode 100644 index 0000000000000..6a3ef0cd21b16 --- /dev/null +++ b/.changeset/silly-clouds-kneel.md @@ -0,0 +1,7 @@ +--- +"@medusajs/link-modules": patch +"@medusajs/modules-sdk": patch +"@medusajs/core-flows": patch +--- + +feat(core-flows,link-modules,modules-sdk): add cart <> promotion link as source of truth diff --git a/integration-tests/modules/__tests__/cart/store/add-promotions-to-cart.spec.ts b/integration-tests/modules/__tests__/cart/store/add-promotions-to-cart.spec.ts index 3a05145574789..22229b272d5ea 100644 --- a/integration-tests/modules/__tests__/cart/store/add-promotions-to-cart.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/add-promotions-to-cart.spec.ts @@ -1,4 +1,9 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + LinkModuleUtils, + ModuleRegistrationName, + Modules, + RemoteLink, +} from "@medusajs/modules-sdk" import { ICartModuleService, IPromotionModuleService } from "@medusajs/types" import { PromotionType } from "@medusajs/utils" import path from "path" @@ -18,6 +23,7 @@ describe("Store Carts API: Add promotions to cart", () => { let shutdownServer let cartModuleService: ICartModuleService let promotionModuleService: IPromotionModuleService + let remoteLinkService: RemoteLink beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) @@ -28,6 +34,7 @@ describe("Store Carts API: Add promotions to cart", () => { promotionModuleService = appContainer.resolve( ModuleRegistrationName.PROMOTION ) + remoteLinkService = appContainer.resolve(LinkModuleUtils.REMOTE_LINK) }) afterAll(async () => { @@ -119,6 +126,11 @@ describe("Store Carts API: Add promotions to cart", () => { }, ]) + await remoteLinkService.create({ + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }) + const api = useApi() as any const created = await api.post(`/store/carts/${cart.id}/promotions`, { @@ -247,6 +259,11 @@ describe("Store Carts API: Add promotions to cart", () => { ] ) + await remoteLinkService.create({ + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }) + const [adjustment] = await cartModuleService.addShippingMethodAdjustments( cart.id, [ diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index f77d89341eb82..ac8acc091d855 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -1,4 +1,9 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + LinkModuleUtils, + ModuleRegistrationName, + Modules, + RemoteLink, +} from "@medusajs/modules-sdk" import { ICartModuleService, ICustomerModuleService, @@ -31,7 +36,7 @@ describe("Store Carts API", () => { let customerModule: ICustomerModuleService let productModule: IProductModuleService let pricingModule: IPricingModuleService - let remoteLink + let remoteLink: RemoteLink let promotionModule: IPromotionModuleService let defaultRegion @@ -47,7 +52,7 @@ describe("Store Carts API", () => { customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER) productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT) pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING) - remoteLink = appContainer.resolve("remoteLink") + remoteLink = appContainer.resolve(LinkModuleUtils.REMOTE_LINK) promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION) }) @@ -351,6 +356,11 @@ describe("Store Carts API", () => { }, ]) + await remoteLink.create({ + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }) + const api = useApi() as any // Should remove earlier adjustments from other promocodes @@ -629,6 +639,11 @@ describe("Store Carts API", () => { }, ]) + await remoteLink.create({ + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }) + const api = useApi() as any const response = await api.post(`/store/carts/${cart.id}/line-items`, { variant_id: product.variants[0].id, diff --git a/integration-tests/modules/__tests__/cart/store/remove-promotions-from-cart.spec.ts b/integration-tests/modules/__tests__/cart/store/remove-promotions-from-cart.spec.ts index 286d113160272..fc7dedf925d45 100644 --- a/integration-tests/modules/__tests__/cart/store/remove-promotions-from-cart.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/remove-promotions-from-cart.spec.ts @@ -1,4 +1,9 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + LinkModuleUtils, + ModuleRegistrationName, + Modules, + RemoteLink, +} from "@medusajs/modules-sdk" import { ICartModuleService, IPromotionModuleService } from "@medusajs/types" import { PromotionType } from "@medusajs/utils" import path from "path" @@ -18,6 +23,7 @@ describe("Store Carts API: Remove promotions from cart", () => { let shutdownServer let cartModuleService: ICartModuleService let promotionModuleService: IPromotionModuleService + let remoteLinkService: RemoteLink beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) @@ -25,6 +31,7 @@ describe("Store Carts API: Remove promotions from cart", () => { shutdownServer = await startBootstrapApp({ cwd, env }) appContainer = getContainer() cartModuleService = appContainer.resolve(ModuleRegistrationName.CART) + remoteLinkService = appContainer.resolve(LinkModuleUtils.REMOTE_LINK) promotionModuleService = appContainer.resolve( ModuleRegistrationName.PROMOTION ) @@ -129,6 +136,17 @@ describe("Store Carts API: Remove promotions from cart", () => { }, ]) + await remoteLinkService.create([ + { + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }, + { + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotionToRemove.id }, + }, + ]) + const api = useApi() as any const response = await api.delete(`/store/carts/${cart.id}/promotions`, { @@ -263,6 +281,17 @@ describe("Store Carts API: Remove promotions from cart", () => { }, ]) + await remoteLinkService.create([ + { + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }, + { + [Modules.CART]: { cart_id: cart.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotionToRemove.id }, + }, + ]) + const api = useApi() as any const response = await api.delete(`/store/carts/${cart.id}/promotions`, { diff --git a/packages/core-flows/src/definition/cart/steps/get-actions-to-compute-from-promotions.ts b/packages/core-flows/src/definition/cart/steps/get-actions-to-compute-from-promotions.ts index 1efbd492f894d..b1eebded3e7fd 100644 --- a/packages/core-flows/src/definition/cart/steps/get-actions-to-compute-from-promotions.ts +++ b/packages/core-flows/src/definition/cart/steps/get-actions-to-compute-from-promotions.ts @@ -1,15 +1,9 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { LinkModuleUtils, ModuleRegistrationName } from "@medusajs/modules-sdk" import { CartDTO, IPromotionModuleService } from "@medusajs/types" -import { PromotionActions, deduplicate, isString } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" interface StepInput { cart: CartDTO - promoCodes?: string[] - action: - | PromotionActions.ADD - | PromotionActions.REMOVE - | PromotionActions.REPLACE } export const getActionsToComputeFromPromotionsStepId = @@ -17,46 +11,26 @@ export const getActionsToComputeFromPromotionsStepId = export const getActionsToComputeFromPromotionsStep = createStep( getActionsToComputeFromPromotionsStepId, async (data: StepInput, { container }) => { - const promotionModuleService: IPromotionModuleService = container.resolve( + const { cart } = data + const remoteQuery = container.resolve(LinkModuleUtils.REMOTE_QUERY) + const promotionService = container.resolve( ModuleRegistrationName.PROMOTION ) - const { action = PromotionActions.ADD, promoCodes, cart } = data + const existingCartPromotionLinks = await remoteQuery({ + cart_promotion: { + __args: { cart_id: [cart.id] }, + fields: ["id", "cart_id", "promotion_id", "deleted_at"], + }, + }) - if (!Array.isArray(promoCodes)) { - return new StepResponse([]) - } - - const appliedItemPromoCodes = cart.items - ?.map((item) => item.adjustments?.map((adjustment) => adjustment.code)) - .flat(1) - .filter(isString) as string[] - - const appliedShippingMethodPromoCodes = cart.shipping_methods - ?.map((shippingMethod) => - shippingMethod.adjustments?.map((adjustment) => adjustment.code) - ) - .flat(1) - .filter(isString) as string[] - - let promotionCodesToApply = deduplicate([ - ...promoCodes, - ...appliedItemPromoCodes, - ...appliedShippingMethodPromoCodes, - ]) - - if (action === PromotionActions.REMOVE) { - promotionCodesToApply = promotionCodesToApply.filter( - (code) => !promoCodes.includes(code) - ) - } - - if (action === PromotionActions.REPLACE) { - promotionCodesToApply = promoCodes - } + const existingPromotions = await promotionService.list( + { id: existingCartPromotionLinks.map((l) => l.promotion_id) }, + { take: null, select: ["code"] } + ) - const actionsToCompute = await promotionModuleService.computeActions( - promotionCodesToApply, + const actionsToCompute = await promotionService.computeActions( + existingPromotions.map((p) => p.code!), cart as any ) diff --git a/packages/core-flows/src/definition/cart/steps/index.ts b/packages/core-flows/src/definition/cart/steps/index.ts index c14bd9664327c..68df836551e35 100644 --- a/packages/core-flows/src/definition/cart/steps/index.ts +++ b/packages/core-flows/src/definition/cart/steps/index.ts @@ -12,5 +12,6 @@ export * from "./prepare-adjustments-from-promotion-actions" export * from "./remove-line-item-adjustments" export * from "./remove-shipping-method-adjustments" export * from "./retrieve-cart" +export * from "./update-cart-promotions" export * from "./update-carts" export * from "./validate-variants-existence" diff --git a/packages/core-flows/src/definition/cart/steps/update-cart-promotions.ts b/packages/core-flows/src/definition/cart/steps/update-cart-promotions.ts new file mode 100644 index 0000000000000..6411646098107 --- /dev/null +++ b/packages/core-flows/src/definition/cart/steps/update-cart-promotions.ts @@ -0,0 +1,107 @@ +import { + LinkModuleUtils, + ModuleRegistrationName, + Modules, +} from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { PromotionActions } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +interface StepInput { + id: string + promo_codes?: string[] + action?: + | PromotionActions.ADD + | PromotionActions.REMOVE + | PromotionActions.REPLACE +} + +export const updateCartPromotionsStepId = "update-cart-promotions" +export const updateCartPromotionsStep = createStep( + updateCartPromotionsStepId, + async (data: StepInput, { container }) => { + const { promo_codes = [], id, action = PromotionActions.ADD } = data + + const remoteLink = container.resolve(LinkModuleUtils.REMOTE_LINK) + const remoteQuery = container.resolve(LinkModuleUtils.REMOTE_QUERY) + const promotionService = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + const existingCartPromotionLinks = await remoteQuery({ + cart_promotion: { + __args: { cart_id: [id] }, + fields: ["cart_id", "promotion_id"], + }, + }) + + const promotionLinkMap = new Map( + existingCartPromotionLinks.map((link) => [link.promotion_id, link]) + ) + + const promotions = await promotionService.list( + { code: promo_codes }, + { select: ["id"] } + ) + + const linksToCreate: any[] = [] + const linksToDismiss: any[] = [] + + for (const promotion of promotions) { + const linkObject = { + [Modules.CART]: { cart_id: id }, + [Modules.PROMOTION]: { promotion_id: promotion.id }, + } + + if ([PromotionActions.ADD, PromotionActions.REPLACE].includes(action)) { + linksToCreate.push(linkObject) + } + + if (action === PromotionActions.REMOVE) { + const link = promotionLinkMap.get(promotion.id) + + if (link) { + linksToDismiss.push(linkObject) + } + } + } + + if (action === PromotionActions.REPLACE) { + for (const link of existingCartPromotionLinks) { + linksToDismiss.push({ + [Modules.CART]: { cart_id: link.cart_id }, + [Modules.PROMOTION]: { promotion_id: link.promotion_id }, + }) + } + } + + const linksToDismissPromise = linksToDismiss.length + ? remoteLink.dismiss(linksToDismiss) + : [] + + const linksToCreatePromise = linksToCreate.length + ? remoteLink.create(linksToCreate) + : [] + + const [_, createdLinks] = await Promise.all([ + linksToDismissPromise, + linksToCreatePromise, + ]) + + return new StepResponse(null, { + createdLinkIds: createdLinks.map((link) => link.id), + dismissedLinks: linksToDismiss, + }) + }, + async (revertData, { container }) => { + const remoteLink = container.resolve(LinkModuleUtils.REMOTE_LINK) + + if (revertData?.dismissedLinks?.length) { + await remoteLink.create(revertData.dismissedLinks) + } + + if (revertData?.createdLinkIds?.length) { + await remoteLink.delete(revertData.createdLinkIds) + } + } +) diff --git a/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts b/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts index 36faf12b13974..fd9e379149dd0 100644 --- a/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts +++ b/packages/core-flows/src/definition/cart/workflows/add-to-cart.ts @@ -19,7 +19,6 @@ import { prepareLineItemData } from "../utils/prepare-line-item-data" // TODO: The AddToCartWorkflow are missing the following steps: // - Confirm inventory exists (inventory module) // - Refresh/delete shipping methods (fulfillment module) -// - Create line item adjustments (promotion module) // - Update payment sessions (payment module) export const addToCartWorkflowId = "add-to-cart" diff --git a/packages/core-flows/src/definition/cart/workflows/update-cart-promotions.ts b/packages/core-flows/src/definition/cart/workflows/update-cart-promotions.ts index e83ec25c84949..78022217c95b0 100644 --- a/packages/core-flows/src/definition/cart/workflows/update-cart-promotions.ts +++ b/packages/core-flows/src/definition/cart/workflows/update-cart-promotions.ts @@ -12,6 +12,7 @@ import { removeLineItemAdjustmentsStep, removeShippingMethodAdjustmentsStep, retrieveCartStep, + updateCartPromotionsStep, } from "../steps" type WorkflowInput = { @@ -39,11 +40,15 @@ export const updateCartPromotionsWorkflow = createWorkflow( }, } + updateCartPromotionsStep({ + id: input.cartId, + promo_codes: input.promoCodes, + action: input.action || PromotionActions.ADD, + }) + const cart = retrieveCartStep(retrieveCartInput) const actions = getActionsToComputeFromPromotionsStep({ cart, - promoCodes: input.promoCodes, - action: input.action || PromotionActions.ADD, }) const { diff --git a/packages/core-flows/tsconfig.json b/packages/core-flows/tsconfig.json index d27e01678bb73..e01f3f8b52a0c 100644 --- a/packages/core-flows/tsconfig.json +++ b/packages/core-flows/tsconfig.json @@ -9,7 +9,7 @@ "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, - "sourceMap": true, + "sourceMap": false, "noImplicitReturns": true, "strictNullChecks": true, "strictFunctionTypes": true, diff --git a/packages/link-modules/src/definitions/cart-promotion.ts b/packages/link-modules/src/definitions/cart-promotion.ts new file mode 100644 index 0000000000000..c14221cda0163 --- /dev/null +++ b/packages/link-modules/src/definitions/cart-promotion.ts @@ -0,0 +1,55 @@ +import { Modules } from "@medusajs/modules-sdk" +import { ModuleJoinerConfig } from "@medusajs/types" +import { LINKS } from "../links" + +export const CartPromotion: ModuleJoinerConfig = { + serviceName: LINKS.CartPromotion, + isLink: true, + databaseConfig: { + tableName: "cart_promotion", + idPrefix: "cartpromo", + }, + alias: [ + { + name: ["cart_promotion", "cart_promotions"], + args: { + entity: "LinkCartPromotion", + }, + }, + ], + primaryKeys: ["id", "cart_id", "promotion_id"], + relationships: [ + { + serviceName: Modules.CART, + primaryKey: "id", + foreignKey: "cart_id", + alias: "cart", + }, + { + serviceName: Modules.PROMOTION, + primaryKey: "id", + foreignKey: "promotion_id", + alias: "promotion", + }, + ], + extends: [ + { + serviceName: Modules.CART, + relationship: { + serviceName: LINKS.CartPromotion, + primaryKey: "cart_id", + foreignKey: "id", + alias: "cart_link", + }, + }, + { + serviceName: Modules.PROMOTION, + relationship: { + serviceName: LINKS.CartPromotion, + primaryKey: "promotion_id", + foreignKey: "id", + alias: "promotion_link", + }, + }, + ], +} diff --git a/packages/link-modules/src/definitions/index.ts b/packages/link-modules/src/definitions/index.ts index aeb2f0dc1fc93..be6247d3619a7 100644 --- a/packages/link-modules/src/definitions/index.ts +++ b/packages/link-modules/src/definitions/index.ts @@ -1,5 +1,6 @@ export * from "./cart-customer" export * from "./cart-payment-collection" +export * from "./cart-promotion" export * from "./cart-region" export * from "./cart-sales-channel" export * from "./inventory-level-stock-location" diff --git a/packages/link-modules/src/links.ts b/packages/link-modules/src/links.ts index 5ea25bab21c5c..00716128bed1a 100644 --- a/packages/link-modules/src/links.ts +++ b/packages/link-modules/src/links.ts @@ -20,6 +20,12 @@ export const LINKS = { Modules.PAYMENT, "payment_collection_id" ), + CartPromotion: composeLinkName( + Modules.CART, + "cart_id", + Modules.PROMOTION, + "promotion_id" + ), // Internal services ProductShippingProfile: composeLinkName( diff --git a/packages/modules-sdk/src/definitions.ts b/packages/modules-sdk/src/definitions.ts index 86436a2c65ee0..8881451fa1dd3 100644 --- a/packages/modules-sdk/src/definitions.ts +++ b/packages/modules-sdk/src/definitions.ts @@ -6,6 +6,11 @@ import { import { upperCaseFirst } from "@medusajs/utils" +export enum LinkModuleUtils { + REMOTE_QUERY = "remoteQuery", + REMOTE_LINK = "remoteLink", +} + export enum Modules { AUTH = "auth", CACHE = "cacheService",