feat(workflows-sdk,core-flows,medusa,types): add workflow to update promotions to cart (#6474)

what:

- adds API + workflow to add/remove promotions in a cart
- minor fixes in promotions module
- minor type fixes in cart module
- typing fix in workflows-sdk (Thanks @adrien2p)
- fix step result in workflows-sdk (Thanks @adrien2p)

RESOLVES CORE-1768

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2024-02-26 12:43:57 +00:00
committed by GitHub
co-authored by Adrien de Peretti
parent 63be07031b
commit ac86362e81
30 changed files with 1187 additions and 101 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@medusajs/workflows-sdk": patch
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
"@medusajs/types": patch
---
feat(workflows-sdk,core-flows,medusa,types): add workflow to add promotions to cart
@@ -0,0 +1,309 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService, IPromotionModuleService } from "@medusajs/types"
import { PromotionType } from "@medusajs/utils"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("Store Carts API: Add promotions to cart", () => {
let dbConnection
let appContainer
let shutdownServer
let cartModuleService: ICartModuleService
let promotionModuleService: IPromotionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
cartModuleService = appContainer.resolve(ModuleRegistrationName.CART)
promotionModuleService = appContainer.resolve(
ModuleRegistrationName.PROMOTION
)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
describe("POST /store/carts/:id/promotions", () => {
it("should add line item adjustments to a cart based on promotions", async () => {
const appliedPromotion = await promotionModuleService.create({
code: "PROMOTION_APPLIED",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
value: "300",
apply_to_quantity: 1,
max_quantity: 1,
target_rules: [
{
attribute: "product_id",
operator: "eq",
values: "prod_tshirt",
},
],
},
})
const createdPromotion = await promotionModuleService.create({
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "1000",
apply_to_quantity: 1,
target_rules: [
{
attribute: "product_id",
operator: "eq",
values: "prod_mat",
},
],
},
})
const cart = await cartModuleService.create({
currency_code: "usd",
items: [
// Adjustment to add
{
id: "item-1",
unit_price: 2000,
quantity: 1,
title: "Test item",
product_id: "prod_mat",
} as any,
// This adjustment will be removed and recreated
{
id: "item-2",
unit_price: 1000,
quantity: 1,
title: "Test item",
product_id: "prod_tshirt",
} as any,
],
})
// Adjustment to keep
const [lineItemAdjustment] =
await cartModuleService.addLineItemAdjustments([
{
code: appliedPromotion.code!,
amount: 300,
item_id: "item-2",
promotion_id: appliedPromotion.id,
},
])
const api = useApi() as any
const created = await api.post(`/store/carts/${cart.id}/promotions`, {
promo_codes: [createdPromotion.code],
})
expect(created.status).toEqual(200)
expect(created.data.cart).toEqual(
expect.objectContaining({
id: expect.any(String),
items: expect.arrayContaining([
expect.objectContaining({
id: "item-1",
adjustments: expect.arrayContaining([
expect.objectContaining({
promotion_id: createdPromotion.id,
code: createdPromotion.code,
amount: 1000,
}),
]),
}),
expect.objectContaining({
adjustments: expect.arrayContaining([
expect.objectContaining({
id: expect.not.stringContaining(lineItemAdjustment.id),
promotion_id: appliedPromotion.id,
code: appliedPromotion.code,
amount: 300,
}),
]),
}),
]),
})
)
})
it("should add shipping method adjustments to a cart based on promotions", async () => {
const [appliedPromotion] = await promotionModuleService.create([
{
code: "PROMOTION_APPLIED",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer_id",
operator: "in",
values: ["cus_test"],
},
{
attribute: "currency_code",
operator: "in",
values: ["eur"],
},
],
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "each",
value: "100",
max_quantity: 1,
target_rules: [
{
attribute: "name",
operator: "in",
values: ["express"],
},
],
},
},
])
const [newPromotion] = await promotionModuleService.create([
{
code: "PROMOTION_NEW",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer_id",
operator: "in",
values: ["cus_test"],
},
{
attribute: "currency_code",
operator: "in",
values: ["eur"],
},
],
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "each",
value: "200",
max_quantity: 1,
target_rules: [
{
attribute: "name",
operator: "in",
values: ["express", "standard"],
},
],
},
},
])
const cart = await cartModuleService.create({
currency_code: "eur",
customer_id: "cus_test",
items: [
{
unit_price: 2000,
quantity: 1,
title: "Test item",
product_id: "prod_mat",
} as any,
],
})
const [express, standard] = await cartModuleService.addShippingMethods(
cart.id,
[
{
amount: 500,
name: "express",
},
{
amount: 500,
name: "standard",
},
]
)
const [adjustment] = await cartModuleService.addShippingMethodAdjustments(
cart.id,
[
{
shipping_method_id: express.id,
amount: 100,
code: appliedPromotion.code!,
},
]
)
const api = useApi() as any
const created = await api.post(`/store/carts/${cart.id}/promotions`, {
promo_codes: [newPromotion.code],
})
expect(created.status).toEqual(200)
expect(created.data.cart).toEqual(
expect.objectContaining({
id: expect.any(String),
items: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
}),
]),
shipping_methods: expect.arrayContaining([
expect.objectContaining({
id: express.id,
adjustments: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
amount: 200,
code: newPromotion.code,
}),
expect.objectContaining({
id: expect.not.stringContaining(adjustment.id),
amount: 100,
code: appliedPromotion.code,
}),
]),
}),
expect.objectContaining({
id: standard.id,
adjustments: [
expect.objectContaining({
id: expect.any(String),
amount: 200,
code: newPromotion.code,
}),
],
}),
]),
})
)
})
})
})
@@ -0,0 +1,300 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService, IPromotionModuleService } from "@medusajs/types"
import { PromotionType } from "@medusajs/utils"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("Store Carts API: Remove promotions from cart", () => {
let dbConnection
let appContainer
let shutdownServer
let cartModuleService: ICartModuleService
let promotionModuleService: IPromotionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
cartModuleService = appContainer.resolve(ModuleRegistrationName.CART)
promotionModuleService = appContainer.resolve(
ModuleRegistrationName.PROMOTION
)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
describe("DELETE /store/carts/:id/promotions", () => {
it("should remove line item adjustments from a cart based on promotions", async () => {
const appliedPromotion = await promotionModuleService.create({
code: "PROMOTION_APPLIED",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
value: "300",
apply_to_quantity: 1,
max_quantity: 1,
target_rules: [
{
attribute: "product_id",
operator: "eq",
values: "prod_tshirt",
},
],
},
})
const appliedPromotionToRemove = await promotionModuleService.create({
code: "PROMOTION_APPLIED_TO_REMOVE",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
target_type: "items",
allocation: "each",
value: "300",
apply_to_quantity: 1,
max_quantity: 1,
target_rules: [
{
attribute: "product_id",
operator: "eq",
values: "prod_tshirt",
},
],
},
})
const cart = await cartModuleService.create({
currency_code: "usd",
items: [
{
id: "item-1",
unit_price: 2000,
quantity: 1,
title: "Test item",
product_id: "prod_mat",
} as any,
{
id: "item-2",
unit_price: 1000,
quantity: 1,
title: "Test item",
product_id: "prod_tshirt",
} as any,
],
})
const [adjustment1, adjustment2] =
await cartModuleService.addLineItemAdjustments([
{
code: appliedPromotion.code!,
amount: 300,
item_id: "item-2",
promotion_id: appliedPromotionToRemove.id,
},
{
code: appliedPromotionToRemove.code!,
amount: 150,
item_id: "item-1",
promotion_id: appliedPromotionToRemove.id,
},
{
code: appliedPromotionToRemove.code!,
amount: 150,
item_id: "item-2",
promotion_id: appliedPromotionToRemove.id,
},
])
const api = useApi() as any
const response = await api.delete(`/store/carts/${cart.id}/promotions`, {
data: {
promo_codes: [appliedPromotionToRemove.code],
},
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: expect.any(String),
items: expect.arrayContaining([
expect.objectContaining({
id: "item-1",
adjustments: [],
}),
expect.objectContaining({
id: "item-2",
adjustments: [
expect.objectContaining({
amount: 300,
code: appliedPromotion.code,
}),
],
}),
]),
})
)
})
it("should add shipping method adjustments to a cart based on promotions", async () => {
const appliedPromotion = await promotionModuleService.create({
code: "PROMOTION_APPLIED",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer_id",
operator: "in",
values: ["cus_test"],
},
{
attribute: "currency_code",
operator: "in",
values: ["eur"],
},
],
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "each",
value: "100",
max_quantity: 1,
target_rules: [
{
attribute: "name",
operator: "in",
values: ["express"],
},
],
},
})
const appliedPromotionToRemove = await promotionModuleService.create({
code: "PROMOTION_APPLIED_TO_REMOVE",
type: PromotionType.STANDARD,
rules: [
{
attribute: "customer_id",
operator: "in",
values: ["cus_test"],
},
{
attribute: "currency_code",
operator: "in",
values: ["eur"],
},
],
application_method: {
type: "fixed",
target_type: "shipping_methods",
allocation: "each",
value: "100",
max_quantity: 1,
target_rules: [
{
attribute: "name",
operator: "in",
values: ["express"],
},
],
},
})
const cart = await cartModuleService.create({
currency_code: "eur",
customer_id: "cus_test",
items: [
{
unit_price: 2000,
quantity: 1,
title: "Test item",
product_id: "prod_mat",
},
],
})
const [express, standard] = await cartModuleService.addShippingMethods(
cart.id,
[
{
amount: 500,
name: "express",
},
{
amount: 500,
name: "standard",
},
]
)
await cartModuleService.addShippingMethodAdjustments(cart.id, [
{
shipping_method_id: express.id,
amount: 100,
code: appliedPromotion.code!,
},
{
shipping_method_id: express.id,
amount: 100,
code: appliedPromotionToRemove.code!,
},
])
const api = useApi() as any
const response = await api.delete(`/store/carts/${cart.id}/promotions`, {
data: { promo_codes: [appliedPromotionToRemove.code] },
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: expect.any(String),
items: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
}),
]),
shipping_methods: expect.arrayContaining([
expect.objectContaining({
id: express.id,
adjustments: [
expect.objectContaining({
amount: 100,
code: appliedPromotion.code,
}),
],
}),
expect.objectContaining({
id: standard.id,
adjustments: [],
}),
]),
})
)
})
})
})
@@ -291,12 +291,9 @@ describe("Cart Module Service", () => {
},
])
const updatedCart = await service.update(
createdCart.id,
{
email: "test@email.com",
}
)
const updatedCart = await service.update(createdCart.id, {
email: "test@email.com",
})
const [cart] = await service.list({ id: [createdCart.id] })
+3 -21
View File
@@ -2,12 +2,12 @@ import { BigNumberRawValue, DAL } from "@medusajs/types"
import {
BigNumber,
DALUtils,
MikroOrmBigNumberProperty,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
import {
BeforeCreate,
BeforeUpdate,
Cascade,
Collection,
Entity,
@@ -123,13 +123,13 @@ export default class LineItem {
@Property({ columnType: "boolean" })
is_tax_inclusive = false
@Property({ columnType: "numeric", nullable: true })
@MikroOrmBigNumberProperty({ nullable: true })
compare_at_unit_price?: BigNumber | number | null = null
@Property({ columnType: "jsonb", nullable: true })
raw_compare_at_unit_price: BigNumberRawValue | null = null
@Property({ columnType: "numeric" })
@MikroOrmBigNumberProperty()
unit_price: BigNumber | number
@Property({ columnType: "jsonb" })
@@ -171,28 +171,10 @@ export default class LineItem {
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "cali")
const val = new BigNumber(this.raw_unit_price ?? this.unit_price)
this.unit_price = val.numeric
this.raw_unit_price = val.raw!
}
@BeforeUpdate()
onUpdate() {
const val = new BigNumber(this.raw_unit_price ?? this.unit_price)
this.unit_price = val.numeric
this.raw_unit_price = val.raw as BigNumberRawValue
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "cali")
const val = new BigNumber(this.raw_unit_price ?? this.unit_price)
this.unit_price = val.numeric
this.raw_unit_price = val.raw!
}
}
+3 -21
View File
@@ -2,6 +2,7 @@ import { BigNumberRawValue, DAL } from "@medusajs/types"
import {
BigNumber,
DALUtils,
MikroOrmBigNumberProperty,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
@@ -19,7 +20,6 @@ import {
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { BeforeUpdate } from "typeorm"
import Cart from "./cart"
import ShippingMethodAdjustment from "./shipping-method-adjustment"
import ShippingMethodTaxLine from "./shipping-method-tax-line"
@@ -49,7 +49,7 @@ export default class ShippingMethod {
@ManyToOne({
entity: () => Cart,
cascade: [Cascade.REMOVE, Cascade.PERSIST, "soft-remove"] as any,
cascade: [Cascade.REMOVE, Cascade.PERSIST],
})
cart: Cart
@@ -59,7 +59,7 @@ export default class ShippingMethod {
@Property({ columnType: "jsonb", nullable: true })
description: string | null = null
@Property({ columnType: "numeric" })
@MikroOrmBigNumberProperty()
amount: BigNumber | number
@Property({ columnType: "jsonb" })
@@ -127,28 +127,10 @@ export default class ShippingMethod {
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "casm")
const val = new BigNumber(this.raw_amount ?? this.amount)
this.amount = val.numeric
this.raw_amount = val.raw!
}
@BeforeUpdate()
onUpdate() {
const val = new BigNumber(this.raw_amount ?? this.amount)
this.amount = val.numeric
this.raw_amount = val.raw as BigNumberRawValue
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "casm")
const val = new BigNumber(this.raw_amount ?? this.amount)
this.amount = val.numeric
this.raw_amount = val.raw!
}
}
@@ -0,0 +1,41 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
CreateLineItemAdjustmentDTO,
ICartModuleService,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
lineItemAdjustmentsToCreate: CreateLineItemAdjustmentDTO[]
}
export const createLineItemAdjustmentsStepId = "create-line-item-adjustments"
export const createLineItemAdjustmentsStep = createStep(
createLineItemAdjustmentsStepId,
async (data: StepInput, { container }) => {
const { lineItemAdjustmentsToCreate = [] } = data
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
const createdLineItemAdjustments =
await cartModuleService.addLineItemAdjustments(
lineItemAdjustmentsToCreate
)
return new StepResponse(void 0, createdLineItemAdjustments)
},
async (createdLineItemAdjustments, { container }) => {
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
if (!createdLineItemAdjustments?.length) {
return
}
await cartModuleService.softDeleteLineItemAdjustments(
createdLineItemAdjustments.map((c) => c.id)
)
}
)
@@ -0,0 +1,42 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
CreateShippingMethodAdjustmentDTO,
ICartModuleService,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
shippingMethodAdjustmentsToCreate: CreateShippingMethodAdjustmentDTO[]
}
export const createShippingMethodAdjustmentsStepId =
"create-shipping-method-adjustments"
export const createShippingMethodAdjustmentsStep = createStep(
createShippingMethodAdjustmentsStepId,
async (data: StepInput, { container }) => {
const { shippingMethodAdjustmentsToCreate = [] } = data
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
const createdShippingMethodAdjustments =
await cartModuleService.addShippingMethodAdjustments(
shippingMethodAdjustmentsToCreate
)
return new StepResponse(void 0, createdShippingMethodAdjustments)
},
async (createdShippingMethodAdjustments, { container }) => {
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
if (!createdShippingMethodAdjustments?.length) {
return
}
await cartModuleService.softDeleteShippingMethodAdjustments(
createdShippingMethodAdjustments.map((c) => c.id)
)
}
)
@@ -0,0 +1,54 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CartDTO, IPromotionModuleService } from "@medusajs/types"
import { deduplicate, isString } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
cart: CartDTO
promoCodes: string[]
removePromotions: boolean
}
export const getActionsToComputeFromPromotionsStepId =
"get-actions-to-compute-from-promotions"
export const getActionsToComputeFromPromotionsStep = createStep(
getActionsToComputeFromPromotionsStepId,
async (data: StepInput, { container }) => {
const promotionModuleService: IPromotionModuleService = container.resolve(
ModuleRegistrationName.PROMOTION
)
const { removePromotions = false, promoCodes = [], cart } = data
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 (removePromotions) {
promotionCodesToApply = promotionCodesToApply.filter(
(code) => !promoCodes.includes(code)
)
}
const actionsToCompute = await promotionModuleService.computeActions(
promotionCodesToApply,
cart as any
)
return new StepResponse(actionsToCompute)
}
)
@@ -1,5 +1,12 @@
export * from "./create-carts"
export * from "./create-line-item-adjustments"
export * from "./create-shipping-method-adjustments"
export * from "./find-one-or-any-region"
export * from "./find-or-create-customer"
export * from "./find-sales-channel"
export * from "./get-actions-to-compute-from-promotions"
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-carts"
@@ -0,0 +1,75 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
AddItemAdjustmentAction,
AddShippingMethodAdjustment,
ComputeActions,
IPromotionModuleService,
PromotionDTO,
RemoveItemAdjustmentAction,
RemoveShippingMethodAdjustment,
} from "@medusajs/types"
import { ComputedActions } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
actions: ComputeActions[]
}
export const prepareAdjustmentsFromPromotionActionsStepId =
"prepare-adjustments-from-promotion-actions"
export const prepareAdjustmentsFromPromotionActionsStep = createStep(
prepareAdjustmentsFromPromotionActionsStepId,
async (data: StepInput, { container }) => {
const promotionModuleService: IPromotionModuleService = container.resolve(
ModuleRegistrationName.PROMOTION
)
const { actions = [] } = data
const promotions = await promotionModuleService.list(
{ code: actions.map((a) => a.code) },
{ select: ["id", "code"] }
)
const promotionsMap = new Map<string, PromotionDTO>(
promotions.map((promotion) => [promotion.code!, promotion])
)
const lineItemAdjustmentsToCreate = actions
.filter((a) => a.action === ComputedActions.ADD_ITEM_ADJUSTMENT)
.map((action) => ({
code: action.code,
amount: (action as AddItemAdjustmentAction).amount,
item_id: (action as AddItemAdjustmentAction).item_id,
promotion_id: promotionsMap.get(action.code)?.id,
}))
const lineItemAdjustmentIdsToRemove = actions
.filter((a) => a.action === ComputedActions.REMOVE_ITEM_ADJUSTMENT)
.map((a) => (a as RemoveItemAdjustmentAction).adjustment_id)
const shippingMethodAdjustmentsToCreate = actions
.filter(
(a) => a.action === ComputedActions.ADD_SHIPPING_METHOD_ADJUSTMENT
)
.map((action) => ({
code: action.code,
amount: (action as AddShippingMethodAdjustment).amount,
shipping_method_id: (action as AddShippingMethodAdjustment)
.shipping_method_id,
promotion_id: promotionsMap.get(action.code)?.id,
}))
const shippingMethodAdjustmentIdsToRemove = actions
.filter(
(a) => a.action === ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT
)
.map((a) => (a as RemoveShippingMethodAdjustment).adjustment_id)
return new StepResponse({
lineItemAdjustmentsToCreate,
lineItemAdjustmentIdsToRemove,
shippingMethodAdjustmentsToCreate,
shippingMethodAdjustmentIdsToRemove,
})
}
)
@@ -0,0 +1,37 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
lineItemAdjustmentIdsToRemove: string[]
}
export const removeLineItemAdjustmentsStepId = "remove-line-item-adjustments"
export const removeLineItemAdjustmentsStep = createStep(
removeLineItemAdjustmentsStepId,
async (data: StepInput, { container }) => {
const { lineItemAdjustmentIdsToRemove = [] } = data
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
await cartModuleService.softDeleteLineItemAdjustments(
lineItemAdjustmentIdsToRemove
)
return new StepResponse(void 0, lineItemAdjustmentIdsToRemove)
},
async (lineItemAdjustmentIdsToRemove, { container }) => {
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
if (!lineItemAdjustmentIdsToRemove?.length) {
return
}
await cartModuleService.restoreLineItemAdjustments(
lineItemAdjustmentIdsToRemove
)
}
)
@@ -0,0 +1,38 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
shippingMethodAdjustmentIdsToRemove: string[]
}
export const removeShippingMethodAdjustmentsStepId =
"remove-shipping-method-adjustments"
export const removeShippingMethodAdjustmentsStep = createStep(
removeShippingMethodAdjustmentsStepId,
async (data: StepInput, { container }) => {
const { shippingMethodAdjustmentIdsToRemove = [] } = data
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
await cartModuleService.softDeleteShippingMethodAdjustments(
shippingMethodAdjustmentIdsToRemove
)
return new StepResponse(void 0, shippingMethodAdjustmentIdsToRemove)
},
async (shippingMethodAdjustmentIdsToRemove, { container }) => {
const cartModuleService: ICartModuleService = container.resolve(
ModuleRegistrationName.CART
)
if (!shippingMethodAdjustmentIdsToRemove?.length) {
return
}
await cartModuleService.restoreShippingMethodAdjustments(
shippingMethodAdjustmentIdsToRemove
)
}
)
@@ -0,0 +1,37 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CartDTO, FindConfig, ICartModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
cartId: string
config: FindConfig<CartDTO>
}
export const retrieveCartStepId = "retrieve-cart"
export const retrieveCartStep = createStep(
retrieveCartStepId,
async (data: StepInput, { container }) => {
const cartModuleService = container.resolve<ICartModuleService>(
ModuleRegistrationName.CART
)
const cart = await cartModuleService.retrieve(data.cartId, data.config)
// TODO: remove this when cart handles totals calculation
cart.items = cart.items?.map((item) => {
item.subtotal = item.unit_price
return item
})
// TODO: remove this when cart handles totals calculation
cart.shipping_methods = cart.shipping_methods?.map((shipping_method) => {
// TODO: should we align all amounts/prices fields to be unit_price?
shipping_method.subtotal = shipping_method.amount
return shipping_method
})
return new StepResponse(cart)
}
)
@@ -1,3 +1,3 @@
export * from "./create-carts"
export * from "./update-cart-promotions"
export * from "./update-carts"
@@ -0,0 +1,69 @@
import { CartDTO } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
parallelize,
} from "@medusajs/workflows-sdk"
import {
createLineItemAdjustmentsStep,
createShippingMethodAdjustmentsStep,
getActionsToComputeFromPromotionsStep,
prepareAdjustmentsFromPromotionActionsStep,
removeLineItemAdjustmentsStep,
removeShippingMethodAdjustmentsStep,
retrieveCartStep,
} from "../steps"
type WorkflowInput = {
promoCodes: string[]
cartId: string
removePromotions?: boolean
}
export const updateCartPromotionsWorkflowId = "update-cart-promotions"
export const updateCartPromotionsWorkflow = createWorkflow(
updateCartPromotionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<CartDTO> => {
const retrieveCartInput = {
cartId: input.cartId,
config: {
relations: [
"items",
"items.adjustments",
"shipping_methods",
"shipping_methods.adjustments",
],
},
}
const cart = retrieveCartStep(retrieveCartInput)
const actions = getActionsToComputeFromPromotionsStep({
cart,
promoCodes: input.promoCodes,
removePromotions: input.removePromotions || false,
})
const {
lineItemAdjustmentsToCreate,
lineItemAdjustmentIdsToRemove,
shippingMethodAdjustmentsToCreate,
shippingMethodAdjustmentIdsToRemove,
} = prepareAdjustmentsFromPromotionActionsStep({ actions })
parallelize(
removeLineItemAdjustmentsStep({ lineItemAdjustmentIdsToRemove }),
removeShippingMethodAdjustmentsStep({
shippingMethodAdjustmentIdsToRemove,
})
)
parallelize(
createLineItemAdjustmentsStep({ lineItemAdjustmentsToCreate }),
createShippingMethodAdjustmentsStep({ shippingMethodAdjustmentsToCreate })
)
return retrieveCartStep(retrieveCartInput).config({
name: "retrieve-cart-result-step",
})
}
)
@@ -0,0 +1,42 @@
import { updateCartPromotionsWorkflow } from "@medusajs/core-flows"
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { StorePostCartsCartPromotionsReq } from "../../validators"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const workflow = updateCartPromotionsWorkflow(req.scope)
const payload = req.validatedBody as StorePostCartsCartPromotionsReq
const { result, errors } = await workflow.run({
input: {
promoCodes: payload.promo_codes,
cartId: req.params.id,
},
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ cart: result })
}
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const workflow = updateCartPromotionsWorkflow(req.scope)
const payload = req.validatedBody as StorePostCartsCartPromotionsReq
const { result, errors } = await workflow.run({
input: {
promoCodes: payload.promo_codes,
cartId: req.params.id,
removePromotions: true,
},
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ cart: result })
}
@@ -3,8 +3,10 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import * as QueryConfig from "./query-config"
import {
StoreDeleteCartsCartPromotionsReq,
StoreGetCartsCartParams,
StorePostCartReq,
StorePostCartsCartPromotionsReq,
StorePostCartsCartReq,
} from "./validators"
@@ -38,4 +40,14 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/store/carts/:id",
middlewares: [transformBody(StorePostCartsCartReq)],
},
{
method: ["POST"],
matcher: "/store/carts/:id/promotions",
middlewares: [transformBody(StorePostCartsCartPromotionsReq)],
},
{
method: ["DELETE"],
matcher: "/store/carts/:id/promotions",
middlewares: [transformBody(StoreDeleteCartsCartPromotionsReq)],
},
]
@@ -52,6 +52,18 @@ export class StorePostCartReq {
metadata?: Record<string, unknown>
}
export class StorePostCartsCartPromotionsReq {
@IsArray()
@Type(() => String)
promo_codes: string[]
}
export class StoreDeleteCartsCartPromotionsReq {
@IsArray()
@Type(() => String)
promo_codes: string[]
}
export class StorePostCartsCartReq {
@IsOptional()
@IsString()
@@ -11,11 +11,11 @@ import {
CampaignBudgetType,
InjectManager,
InjectTransactionManager,
isString,
MedusaContext,
MedusaError,
ModulesSdkUtils,
PromotionType,
isString,
} from "@medusajs/utils"
import {
ApplicationMethod,
@@ -38,9 +38,9 @@ import {
UpdatePromotionDTO,
} from "@types"
import {
ComputeActionUtils,
allowedAllocationForQuantity,
areRulesValidForContext,
ComputeActionUtils,
validateApplicationMethodAttributes,
validatePromotionRuleAttributes,
} from "@utils"
@@ -166,10 +166,10 @@ export default class PromotionModuleService<
if (campaignBudget.type === CampaignBudgetType.SPEND) {
const campaignBudgetData = promotionCodeCampaignBudgetMap.get(
campaignBudget.id
) || { id: campaignBudget.id, used: campaignBudget.used || 0 }
) || { id: campaignBudget.id, used: campaignBudget.used ?? 0 }
campaignBudgetData.used =
(campaignBudgetData.used || 0) + computedAction.amount
(campaignBudgetData.used ?? 0) + computedAction.amount
if (
campaignBudget.limit &&
@@ -194,7 +194,7 @@ export default class PromotionModuleService<
const campaignBudgetData = {
id: campaignBudget.id,
used: (campaignBudget.used || 0) + 1,
used: (campaignBudget.used ?? 0) + 1,
}
if (
@@ -225,10 +225,12 @@ export default class PromotionModuleService<
}
}
@InjectManager("baseRepository_")
async computeActions(
promotionCodes: string[],
applicationContext: PromotionTypes.ComputeActionContext,
options: PromotionTypes.ComputeActionOptions = {}
options: PromotionTypes.ComputeActionOptions = {},
@MedusaContext() sharedContext: Context = {}
): Promise<PromotionTypes.ComputeActions[]> {
const { prevent_auto_promotions: preventAutoPromotions } = options
const computedActions: PromotionTypes.ComputeActions[] = []
@@ -238,12 +240,16 @@ export default class PromotionModuleService<
const appliedShippingCodes: string[] = []
const codeAdjustmentMap = new Map<
string,
PromotionTypes.ComputeActionAdjustmentLine
PromotionTypes.ComputeActionAdjustmentLine[]
>()
const methodIdPromoValueMap = new Map<string, number>()
const automaticPromotions = preventAutoPromotions
? []
: await this.list({ is_automatic: true }, { select: ["code"] })
: await this.list(
{ is_automatic: true },
{ select: ["code"] },
sharedContext
)
const automaticPromotionCodes = automaticPromotions.map((p) => p.code!)
const promotionCodesToApply = [
@@ -254,7 +260,11 @@ export default class PromotionModuleService<
items.forEach((item) => {
item.adjustments?.forEach((adjustment) => {
if (isString(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, adjustment)
const adjustments = codeAdjustmentMap.get(adjustment.code) || []
adjustments.push(adjustment)
codeAdjustmentMap.set(adjustment.code, adjustments)
appliedItemCodes.push(adjustment.code)
}
})
@@ -263,7 +273,11 @@ export default class PromotionModuleService<
shippingMethods.forEach((shippingMethod) => {
shippingMethod.adjustments?.forEach((adjustment) => {
if (isString(adjustment.code)) {
codeAdjustmentMap.set(adjustment.code, adjustment)
const adjustments = codeAdjustmentMap.get(adjustment.code) || []
adjustments.push(adjustment)
codeAdjustmentMap.set(adjustment.code, adjustments)
appliedShippingCodes.push(adjustment.code)
}
})
@@ -292,6 +306,7 @@ export default class PromotionModuleService<
}
)
const appliedCodes = [...appliedShippingCodes, ...appliedItemCodes]
const sortedPermissionsToApply = promotions
.filter((p) => promotionCodesToApply.includes(p.code!))
.sort(ComputeActionUtils.sortByBuyGetType)
@@ -300,7 +315,7 @@ export default class PromotionModuleService<
promotions.map((promotion) => [promotion.code!, promotion])
)
for (const appliedCode of [...appliedShippingCodes, ...appliedItemCodes]) {
for (const appliedCode of appliedCodes) {
const promotion = existingPromotionsMap.get(appliedCode)
if (!promotion) {
@@ -310,24 +325,28 @@ export default class PromotionModuleService<
)
}
if (promotionCodes.includes(appliedCode)) {
continue
}
if (appliedItemCodes.includes(appliedCode)) {
computedActions.push({
action: "removeItemAdjustment",
adjustment_id: codeAdjustmentMap.get(appliedCode)!.id,
code: appliedCode,
})
const adjustments = codeAdjustmentMap.get(appliedCode) || []
adjustments.forEach((adjustment) =>
computedActions.push({
action: "removeItemAdjustment",
adjustment_id: adjustment.id,
code: appliedCode,
})
)
}
if (appliedShippingCodes.includes(appliedCode)) {
computedActions.push({
action: "removeShippingMethodAdjustment",
adjustment_id: codeAdjustmentMap.get(appliedCode)!.id,
code: appliedCode,
})
const adjustments = codeAdjustmentMap.get(appliedCode) || []
adjustments.forEach((adjustment) =>
computedActions.push({
action: "removeShippingMethodAdjustment",
adjustment_id: adjustment.id,
code: appliedCode,
})
)
}
}
@@ -4,10 +4,12 @@ import {
ComputedActions,
MedusaError,
PromotionType,
isPresent,
} from "@medusajs/utils"
import { areRulesValidForContext } from "../validations"
import { computeActionForBudgetExceeded } from "./usage"
// TODO: calculations should eventually move to a totals util outside of the module
export function getComputedActionsForBuyGet(
promotion: PromotionTypes.PromotionDTO,
itemsContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS],
@@ -45,6 +47,7 @@ export function getComputedActionsForBuyGet(
const validItemsForTargetRules = itemsContext
.filter((item) => areRulesValidForContext(targetRules, item))
.filter((item) => isPresent(item.subtotal) && isPresent(item.quantity))
.sort((a, b) => {
const aPrice = a.subtotal / a.quantity
const bPrice = b.subtotal / b.quantity
@@ -55,7 +58,7 @@ export function getComputedActionsForBuyGet(
let remainingQtyToApply = applyToQuantity
for (const method of validItemsForTargetRules) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
const multiplier = Math.min(method.quantity, remainingQtyToApply)
const amount = (method.subtotal / method.quantity) * multiplier
const newRemainingQtyToApply = remainingQtyToApply - multiplier
@@ -49,6 +49,7 @@ export function getComputedActionsForItems(
)
}
// TODO: calculations should eventually move to a totals util outside of the module
export function applyPromotionToItems(
promotion: PromotionTypes.PromotionDTO,
items: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS],
@@ -63,7 +64,11 @@ export function applyPromotionToItems(
[allocation, allocationOverride].includes(ApplicationMethodAllocation.EACH)
) {
for (const method of items!) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
if (!method.subtotal || !method.quantity) {
continue
}
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
const quantityMultiplier = Math.min(
method.quantity,
applicationMethod?.max_quantity!
@@ -111,16 +116,20 @@ export function applyPromotionToItems(
)
) {
const totalApplicableValue = items!.reduce((acc, method) => {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
return (
acc +
(method.subtotal / method.quantity) * method.quantity -
appliedPromoValue
)
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
const perItemCost = method.subtotal
? method.subtotal / method.quantity
: 0
return acc + perItemCost * method.quantity - appliedPromoValue
}, 0)
for (const method of items!) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
if (!method.subtotal || !method.quantity) {
continue
}
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
const promotionValue = parseFloat(applicationMethod!.value!)
const applicableTotal =
(method.subtotal / method.quantity) * method.quantity -
@@ -55,7 +55,11 @@ export function applyPromotionToShippingMethods(
if (allocation === ApplicationMethodAllocation.EACH) {
for (const method of shippingMethods!) {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
if (!method.subtotal) {
continue
}
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
let promotionValue = parseFloat(applicationMethod!.value!)
const applicableTotal = method.subtotal - appliedPromoValue
@@ -93,9 +97,9 @@ export function applyPromotionToShippingMethods(
if (allocation === ApplicationMethodAllocation.ACROSS) {
const totalApplicableValue = shippingMethods!.reduce((acc, method) => {
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
return acc + method.subtotal - appliedPromoValue
return acc + (method.subtotal ?? 0) - appliedPromoValue
}, 0)
if (totalApplicableValue <= 0) {
@@ -103,9 +107,13 @@ export function applyPromotionToShippingMethods(
}
for (const method of shippingMethods!) {
if (!method.subtotal) {
continue
}
const promotionValue = parseFloat(applicationMethod!.value!)
const applicableTotal = method.subtotal
const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0
const appliedPromoValue = methodIdPromoValueMap.get(method.id) ?? 0
// TODO: should we worry about precision here?
let applicablePromotionValue =
@@ -24,7 +24,7 @@ export function computeActionForBudgetExceeded(
return
}
const campaignBudgetUsed = campaignBudget.used || 0
const campaignBudgetUsed = campaignBudget.used ?? 0
const totalUsed =
campaignBudget.type === CampaignBudgetType.SPEND
? campaignBudgetUsed + amount
+1 -1
View File
@@ -206,7 +206,7 @@ export interface CartShippingMethodDTO {
/**
* The price of the shipping method
*/
unit_price: number
amount: number
/**
* Whether the shipping method price is tax inclusive or not
+4 -4
View File
@@ -22,8 +22,8 @@ import {
} from "./common"
import {
CreateAddressDTO,
CreateAdjustmentDTO,
CreateCartDTO,
CreateLineItemAdjustmentDTO,
CreateLineItemDTO,
CreateLineItemForCartDTO,
CreateLineItemTaxLineDTO,
@@ -185,14 +185,14 @@ export interface ICartModuleService extends IModuleService {
): Promise<LineItemAdjustmentDTO[]>
addLineItemAdjustments(
data: CreateAdjustmentDTO[]
data: CreateLineItemAdjustmentDTO[]
): Promise<LineItemAdjustmentDTO[]>
addLineItemAdjustments(
data: CreateAdjustmentDTO
data: CreateLineItemAdjustmentDTO
): Promise<LineItemAdjustmentDTO[]>
addLineItemAdjustments(
cartId: string,
data: CreateAdjustmentDTO[]
data: CreateLineItemAdjustmentDTO[]
): Promise<LineItemAdjustmentDTO[]>
setLineItemAdjustments(
@@ -12,11 +12,11 @@ import {
SoftDeleteReturn,
} from "@medusajs/types"
import {
MapToConfig,
isString,
kebabCase,
lowerCaseFirst,
mapObjectTo,
MapToConfig,
pluralize,
upperCaseFirst,
} from "../common"
@@ -311,9 +311,8 @@ export function abstractModuleServiceFactory<
config: FindConfig<any> = {},
sharedContext: Context = {}
): Promise<T[]> {
const entities = await this.__container__[
serviceRegistrationName
].list(filters, config, sharedContext)
const service = this.__container__[serviceRegistrationName]
const entities = await service.list(filters, config, sharedContext)
return await this.baseRepository_.serialize<T[]>(entities, {
populate: true,
@@ -6,10 +6,10 @@ import {
ModuleServiceInitializeOptions,
RepositoryService,
} from "@medusajs/types"
import { lowerCaseFirst } from "../../common"
import { asClass } from "awilix"
import { internalModuleServiceFactory } from "../internal-module-service-factory"
import { lowerCaseFirst } from "../../common"
import { mikroOrmBaseRepositoryFactory } from "../../dal"
import { internalModuleServiceFactory } from "../internal-module-service-factory"
type RepositoryLoaderOptions = {
moduleModels: Record<string, any>
@@ -96,7 +96,10 @@ export function loadModuleServices({
const finalService = moduleServicesMap.get(mappedServiceName)
if (!finalService) {
moduleServicesMap.set(mappedServiceName, internalModuleServiceFactory(Model))
moduleServicesMap.set(
mappedServiceName,
internalModuleServiceFactory(Model)
)
}
})
@@ -5,7 +5,7 @@ import {
WorkflowManager,
} from "@medusajs/orchestration"
import { LoadedModule, MedusaContainer } from "@medusajs/types"
import { isString, OrchestrationUtils } from "@medusajs/utils"
import { OrchestrationUtils, isString } from "@medusajs/utils"
import { ExportedWorkflow, exportWorkflow } from "../../helper"
import { proxify } from "./helpers/proxy"
import {
@@ -42,6 +42,7 @@ export type WorkflowData<T = unknown> = (T extends object
[Key in keyof T]: WorkflowData<T[Key]>
}
: T & WorkflowDataProperties<T>) &
T &
WorkflowDataProperties<T> & {
config(
config: { name?: string } & Omit<