feat(core-flows,link-modules,modules-sdk): add cart <> promotion link as source of truth (#6561)
what: - adds promotion cart link - update steps to create and remove links
This commit is contained in:
7
.changeset/silly-clouds-kneel.md
Normal file
7
.changeset/silly-clouds-kneel.md
Normal file
@@ -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
|
||||
@@ -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,
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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<IPromotionModuleService>(
|
||||
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 existingPromotions = await promotionService.list(
|
||||
{ id: existingCartPromotionLinks.map((l) => l.promotion_id) },
|
||||
{ take: null, select: ["code"] }
|
||||
)
|
||||
|
||||
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 actionsToCompute = await promotionModuleService.computeActions(
|
||||
promotionCodesToApply,
|
||||
const actionsToCompute = await promotionService.computeActions(
|
||||
existingPromotions.map((p) => p.code!),
|
||||
cart as any
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<IPromotionModuleService>(
|
||||
ModuleRegistrationName.PROMOTION
|
||||
)
|
||||
|
||||
const existingCartPromotionLinks = await remoteQuery({
|
||||
cart_promotion: {
|
||||
__args: { cart_id: [id] },
|
||||
fields: ["cart_id", "promotion_id"],
|
||||
},
|
||||
})
|
||||
|
||||
const promotionLinkMap = new Map<string, any>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": true,
|
||||
"sourceMap": false,
|
||||
"noImplicitReturns": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
|
||||
55
packages/link-modules/src/definitions/cart-promotion.ts
Normal file
55
packages/link-modules/src/definitions/cart-promotion.ts
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user