feat(core-flows,medusa,types,utils): adds update cart API with promotions (#6514)

what:

- adds update cart API
  - workflow
  - promotions
  - sales channel
  - region
  - customer
This commit is contained in:
Riqwan Thamir
2024-02-27 17:39:30 +05:30
committed by GitHub
parent 608c10383a
commit f5c2256286
20 changed files with 435 additions and 96 deletions

View File

@@ -0,0 +1,8 @@
---
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat(core-flows,medusa,types,utils): adds update cart API with promotions

View File

@@ -4,9 +4,11 @@ import {
ICustomerModuleService,
IPricingModuleService,
IProductModuleService,
IPromotionModuleService,
IRegionModuleService,
ISalesChannelModuleService,
} from "@medusajs/types"
import { PromotionRuleOperator, PromotionType } from "@medusajs/utils"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
@@ -30,6 +32,7 @@ describe("Store Carts API", () => {
let productModule: IProductModuleService
let pricingModule: IPricingModuleService
let remoteLink
let promotionModule: IPromotionModuleService
let defaultRegion
@@ -45,6 +48,7 @@ describe("Store Carts API", () => {
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
remoteLink = appContainer.resolve("remoteLink")
promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION)
})
afterAll(async () => {
@@ -288,6 +292,171 @@ describe("Store Carts API", () => {
})
})
describe("POST /store/carts/:id", () => {
it("should update a cart with promo codes with a replace action", async () => {
const targetRules = [
{
attribute: "product_id",
operator: PromotionRuleOperator.IN,
values: ["prod_tshirt"],
},
]
const appliedPromotion = await promotionModule.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: targetRules,
},
})
const createdPromotion = await promotionModule.create({
code: "PROMOTION_TEST",
type: PromotionType.STANDARD,
application_method: {
type: "fixed",
target_type: "items",
allocation: "across",
value: "1000",
apply_to_quantity: 1,
target_rules: targetRules,
},
})
const cart = await cartModuleService.create({
currency_code: "usd",
email: "tony@stark.com",
items: [
{
id: "item-1",
unit_price: 2000,
quantity: 1,
title: "Test item",
product_id: "prod_tshirt",
} as any,
],
})
const [adjustment] = await cartModuleService.addLineItemAdjustments([
{
code: appliedPromotion.code!,
amount: 300,
item_id: "item-1",
promotion_id: appliedPromotion.id,
},
])
const api = useApi() as any
// Should remove earlier adjustments from other promocodes
let updated = await api.post(`/store/carts/${cart.id}`, {
promo_codes: [createdPromotion.code],
})
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
items: [
expect.objectContaining({
id: "item-1",
adjustments: [
expect.objectContaining({
id: expect.not.stringContaining(adjustment.id),
code: createdPromotion.code,
}),
],
}),
],
})
)
// Should remove all adjustments from other promo codes
updated = await api.post(`/store/carts/${cart.id}`, {
promo_codes: [],
})
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
items: [
expect.objectContaining({
id: "item-1",
adjustments: [],
}),
],
})
)
})
it("should update a cart's region, sales channel and customer data", async () => {
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const salesChannel = await scModuleService.create({
name: "Webshop",
})
const cart = await cartModuleService.create({
currency_code: "eur",
})
const api = useApi() as any
let updated = await api.post(`/store/carts/${cart.id}`, {
region_id: region.id,
email: "tony@stark.com",
sales_channel_id: salesChannel.id,
})
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
region: expect.objectContaining({
id: region.id,
currency_code: "usd",
}),
email: "tony@stark.com",
customer: expect.objectContaining({
email: "tony@stark.com",
}),
sales_channel_id: salesChannel.id,
})
)
updated = await api.post(`/store/carts/${cart.id}`, {
email: null,
sales_channel_id: null,
})
expect(updated.status).toEqual(200)
expect(updated.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
email: null,
customer_id: null,
region: expect.objectContaining({
id: region.id,
currency_code: "usd",
}),
sales_channel_id: null,
})
)
})
})
describe("GET /store/carts/:id", () => {
it("should create and update a cart", async () => {
const region = await regionModuleService.create({

View File

@@ -14,11 +14,11 @@ export interface CreateCartDTO {
export interface UpdateCartDTO {
id: string
region_id?: string | null
region_id?: string
customer_id?: string | null
sales_channel_id?: string | null
email?: string | null
currency_code?: string | null
currency_code?: string
metadata?: Record<string, unknown> | null
adjustments?: (CreateLineItemAdjustmentDTO | UpdateLineItemAdjustmentDTO)[]

View File

@@ -4,13 +4,13 @@ import { validateEmail } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
customerId?: string
email?: string
customerId?: string | null
email?: string | null
}
interface StepOutput {
customer?: CustomerDTO
email?: string
customer?: CustomerDTO | null
email?: string | null
}
interface StepCompensateInput {
@@ -22,11 +22,27 @@ export const findOrCreateCustomerStepId = "find-or-create-customer"
export const findOrCreateCustomerStep = createStep(
findOrCreateCustomerStepId,
async (data: StepInput, { container }) => {
if (
typeof data.customerId === undefined &&
typeof data.email === undefined
) {
return new StepResponse(
{
customer: undefined,
email: undefined,
},
{ customerWasCreated: false }
)
}
const service = container.resolve<ICustomerModuleService>(
ModuleRegistrationName.CUSTOMER
)
const customerData: StepOutput = {}
const customerData: StepOutput = {
customer: null,
email: null,
}
let customerWasCreated = false
if (data.customerId) {

View File

@@ -4,7 +4,7 @@ import { MedusaError } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
salesChannelId?: string
salesChannelId?: string | null
}
export const findSalesChannelStepId = "find-sales-channel"
@@ -15,6 +15,10 @@ export const findSalesChannelStep = createStep(
ModuleRegistrationName.SALES_CHANNEL
)
if (data.salesChannelId === null) {
return new StepResponse(null)
}
let salesChannel: SalesChannelDTO | undefined
if (data.salesChannelId) {
salesChannel = await salesChannelService.retrieve(data.salesChannelId)

View File

@@ -1,12 +1,15 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CartDTO, IPromotionModuleService } from "@medusajs/types"
import { deduplicate, isString } from "@medusajs/utils"
import { PromotionActions, deduplicate, isString } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
cart: CartDTO
promoCodes: string[]
removePromotions: boolean
promoCodes?: string[]
action:
| PromotionActions.ADD
| PromotionActions.REMOVE
| PromotionActions.REPLACE
}
export const getActionsToComputeFromPromotionsStepId =
@@ -18,7 +21,11 @@ export const getActionsToComputeFromPromotionsStep = createStep(
ModuleRegistrationName.PROMOTION
)
const { removePromotions = false, promoCodes = [], cart } = data
const { action = PromotionActions.ADD, promoCodes, cart } = data
if (!Array.isArray(promoCodes)) {
return new StepResponse([])
}
const appliedItemPromoCodes = cart.items
?.map((item) => item.adjustments?.map((adjustment) => adjustment.code))
@@ -38,12 +45,16 @@ export const getActionsToComputeFromPromotionsStep = createStep(
...appliedShippingMethodPromoCodes,
])
if (removePromotions) {
if (action === PromotionActions.REMOVE) {
promotionCodesToApply = promotionCodesToApply.filter(
(code) => !promoCodes.includes(code)
)
}
if (action === PromotionActions.REPLACE) {
promotionCodesToApply = promoCodes
}
const actionsToCompute = await promotionModuleService.computeActions(
promotionCodesToApply,
cart as any

View File

@@ -3,7 +3,7 @@ import { CartDTO, FindConfig, ICartModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
cartId: string
id: string
config: FindConfig<CartDTO>
}
@@ -15,7 +15,7 @@ export const retrieveCartStep = createStep(
ModuleRegistrationName.CART
)
const cart = await cartModuleService.retrieve(data.cartId, data.config)
const cart = await cartModuleService.retrieve(data.id, data.config)
// TODO: remove this when cart handles totals calculation
cart.items = cart.items?.map((item) => {

View File

@@ -1,61 +1,53 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
CartDTO,
FilterableCartProps,
ICartModuleService,
UpdateCartDataDTO,
UpdateCartDTO,
UpdateCartWorkflowInputDTO,
} from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type UpdateCartsStepInput = {
selector: FilterableCartProps
update: UpdateCartDataDTO
}
export const updateCartsStepId = "update-carts"
export const updateCartsStep = createStep(
updateCartsStepId,
async (data: UpdateCartsStepInput, { container }) => {
const service = container.resolve<ICartModuleService>(
async (data: UpdateCartWorkflowInputDTO[], { container }) => {
const cartModule = container.resolve<ICartModuleService>(
ModuleRegistrationName.CART
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
data.update,
])
const prevCarts = await service.list(data.selector, {
select: selects,
relations,
})
const updatedCarts = await service.update(
data.selector as Partial<CartDTO>,
data.update
const { selects, relations } = getSelectsAndRelationsFromObjectArray(data)
const cartsBeforeUpdate = await cartModule.list(
{ id: data.map((d) => d.id) },
{ select: selects, relations }
)
return new StepResponse(updatedCarts, prevCarts)
const updatedCart = await cartModule.update(data)
return new StepResponse(updatedCart, cartsBeforeUpdate)
},
async (previousCarts, { container }) => {
if (!previousCarts?.length) {
async (cartsBeforeUpdate, { container }) => {
if (!cartsBeforeUpdate) {
return
}
const service = container.resolve<ICartModuleService>(
const cartModule = container.resolve<ICartModuleService>(
ModuleRegistrationName.CART
)
const toRestore = previousCarts.map((c) => ({
id: c.id,
region_id: c.region_id,
customer_id: c.customer_id,
sales_channel_id: c.sales_channel_id,
email: c.email,
currency_code: c.currency_code,
metadata: c.metadata,
}))
const dataToUpdate: UpdateCartDTO[] = []
await service.update(toRestore)
for (const cart of cartsBeforeUpdate) {
dataToUpdate.push({
id: cart.id,
region_id: cart.region_id,
customer_id: cart.customer_id,
sales_channel_id: cart.sales_channel_id,
email: cart.email,
currency_code: cart.currency_code,
metadata: cart.metadata,
})
}
await cartModule.update(dataToUpdate)
}
)

View File

@@ -1,4 +1,4 @@
export * from "./add-to-cart"
export * from "./create-carts"
export * from "./update-cart"
export * from "./update-cart-promotions"
export * from "./update-carts"

View File

@@ -1,4 +1,5 @@
import { CartDTO } from "@medusajs/types"
import { PromotionActions } from "@medusajs/utils"
import {
WorkflowData,
createWorkflow,
@@ -17,7 +18,10 @@ import {
type WorkflowInput = {
promoCodes: string[]
cartId: string
removePromotions?: boolean
action?:
| PromotionActions.ADD
| PromotionActions.REMOVE
| PromotionActions.REPLACE
}
export const updateCartPromotionsWorkflowId = "update-cart-promotions"
@@ -25,7 +29,7 @@ export const updateCartPromotionsWorkflow = createWorkflow(
updateCartPromotionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<CartDTO> => {
const retrieveCartInput = {
cartId: input.cartId,
id: input.cartId,
config: {
relations: [
"items",
@@ -40,7 +44,7 @@ export const updateCartPromotionsWorkflow = createWorkflow(
const actions = getActionsToComputeFromPromotionsStep({
cart,
promoCodes: input.promoCodes,
removePromotions: input.removePromotions || false,
action: input.action || PromotionActions.ADD,
})
const {

View File

@@ -0,0 +1,112 @@
import { CartDTO, UpdateCartWorkflowInputDTO } from "@medusajs/types"
import { PromotionActions, isPresent } from "@medusajs/utils"
import {
WorkflowData,
createWorkflow,
parallelize,
transform,
} from "@medusajs/workflows-sdk"
import {
createLineItemAdjustmentsStep,
createShippingMethodAdjustmentsStep,
findOneOrAnyRegionStep,
findOrCreateCustomerStep,
findSalesChannelStep,
getActionsToComputeFromPromotionsStep,
prepareAdjustmentsFromPromotionActionsStep,
removeLineItemAdjustmentsStep,
removeShippingMethodAdjustmentsStep,
retrieveCartStep,
updateCartsStep,
} from "../steps"
export const updateCartWorkflowId = "update-cart"
export const updateCartWorkflow = createWorkflow(
updateCartWorkflowId,
(input: WorkflowData<UpdateCartWorkflowInputDTO>): WorkflowData<CartDTO> => {
const retrieveCartInput = {
id: input.id,
config: {
relations: [
"items",
"items.adjustments",
"shipping_methods",
"shipping_methods.adjustments",
],
},
}
const [salesChannel, region, customerData] = parallelize(
findSalesChannelStep({
salesChannelId: input.sales_channel_id,
}),
findOneOrAnyRegionStep({
regionId: input.region_id,
}),
findOrCreateCustomerStep({
customerId: input.customer_id,
email: input.email,
})
)
const cartInput = transform(
{ input, region, customerData, salesChannel },
(data) => {
const { promo_codes, ...updateCartData } = data.input
const data_ = { ...updateCartData }
if (isPresent(updateCartData.region_id)) {
data_.currency_code = data.region.currency_code
data_.region_id = data.region.id
}
if (
updateCartData.customer_id !== undefined ||
updateCartData.email !== undefined
) {
data_.customer_id = data.customerData.customer?.id || null
data_.email =
data.input?.email ?? (data.customerData.customer?.email || null)
}
if (updateCartData.sales_channel_id !== undefined) {
data_.sales_channel_id = data.salesChannel?.id || null
}
return data_
}
)
updateCartsStep([cartInput])
const cart = retrieveCartStep(retrieveCartInput)
const actions = getActionsToComputeFromPromotionsStep({
cart,
promoCodes: input.promo_codes,
action: PromotionActions.REPLACE,
})
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",
})
}
)

View File

@@ -1,20 +0,0 @@
import {
CartDTO,
FilterableCartProps,
UpdateCartDataDTO,
} from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updateCartsStep } from "../steps/update-carts"
type WorkflowInput = {
selector: FilterableCartProps
update: UpdateCartDataDTO
}
export const updateCartsWorkflowId = "update-carts"
export const updateCartsWorkflow = createWorkflow(
updateCartsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<CartDTO[]> => {
return updateCartsStep(input)
}
)

View File

@@ -1,4 +1,5 @@
import { updateCartPromotionsWorkflow } from "@medusajs/core-flows"
import { PromotionActions } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { StorePostCartsCartPromotionsReq } from "../../validators"
@@ -10,6 +11,7 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
input: {
promoCodes: payload.promo_codes,
cartId: req.params.id,
action: PromotionActions.ADD,
},
throwOnError: false,
})
@@ -29,7 +31,7 @@ export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
input: {
promoCodes: payload.promo_codes,
cartId: req.params.id,
removePromotions: true,
action: PromotionActions.REMOVE,
},
throwOnError: false,
})

View File

@@ -1,9 +1,9 @@
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { updateCartWorkflow } from "@medusajs/core-flows"
import { UpdateCartDataDTO } from "@medusajs/types"
import { defaultStoreCartFields } from "../query-config"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { updateCartsWorkflow } from "@medusajs/core-flows"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { defaultStoreCartFields } from "../query-config"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const remoteQuery = req.scope.resolve("remoteQuery")
@@ -24,15 +24,13 @@ export const POST = async (
req: MedusaRequest<UpdateCartDataDTO>,
res: MedusaResponse
) => {
const updateCartWorkflow = updateCartsWorkflow(req.scope)
const workflow = updateCartWorkflow(req.scope)
const workflowInput = {
selector: { id: req.params.id },
update: req.validatedBody,
}
const { result, errors } = await updateCartWorkflow.run({
input: workflowInput,
const { errors } = await workflow.run({
input: {
...(req.validatedBody as UpdateCartDataDTO),
id: req.params.id,
},
throwOnError: false,
})
@@ -40,5 +38,16 @@ export const POST = async (
throw errors[0].error
}
res.status(200).json({ cart: result[0] })
const remoteQuery = req.scope.resolve("remoteQuery")
const query = remoteQueryObjectFromString({
entryPoint: "cart",
fields: defaultStoreCartFields,
})
const [updatedCart] = await remoteQuery(query, {
cart: { id: req.params.id },
})
res.status(200).json({ cart: updatedCart })
}

View File

@@ -10,8 +10,14 @@ export const defaultStoreCartFields = [
"items.title",
"items.quantity",
"items.unit_price",
"items.adjustments.id",
"items.adjustments.code",
"items.adjustments.amount",
"customer.id",
"customer.email",
"shipping_methods.adjustments.id",
"shipping_methods.adjustments.code",
"shipping_methods.adjustments.amount",
"shipping_address.id",
"shipping_address.first_name",
"shipping_address.last_name",
@@ -40,20 +46,24 @@ export const defaultStoreCartFields = [
export const defaultStoreCartRelations = [
"items",
"items.adjustments",
"region",
"customer",
"shipping_address",
"billing_address",
"shipping_methods",
"shipping_methods.adjustments",
]
export const allowedRelations = [
"items",
"items.adjustments",
"region",
"customer",
"shipping_address",
"billing_address",
"shipping_methods",
"shipping_methods.adjustments",
"sales_channel",
]

View File

@@ -81,14 +81,19 @@ export class StorePostCartsCartReq {
@IsType([AddressPayload, String])
shipping_address?: AddressPayload | string
@IsEmail()
@IsOptional()
@IsString()
sales_channel_id?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
@IsOptional()
@IsArray()
@Type(() => String)
promo_codes?: string[]
// @IsOptional()
// @IsArray()
// @ValidateNested({ each: true })

View File

@@ -41,12 +41,12 @@ export interface CreateCartDTO {
}
export interface UpdateCartDataDTO {
region_id?: string | null
region_id?: string
customer_id?: string | null
sales_channel_id?: string | null
email?: string | null
currency_code?: string | null
currency_code?: string
shipping_address_id?: string | null
billing_address_id?: string | null
@@ -58,7 +58,7 @@ export interface UpdateCartDataDTO {
}
export interface UpdateCartDTO extends UpdateCartDataDTO {
id?: string
id: string
}
/** CART END */
@@ -176,7 +176,7 @@ export interface UpdateLineItemWithSelectorDTO {
export interface UpdateCartWithSelectorDTO {
selector: Partial<CartDTO>
data: UpdateCartDTO
data: UpdateCartDataDTO
}
export interface UpdateLineItemDTO

View File

@@ -65,3 +65,14 @@ export interface AddToCartWorkflowInputDTO {
items: CreateCartCreateLineItemDTO[]
cart: CartDTO
}
export interface UpdateCartWorkflowInputDTO {
id: string
promo_codes?: string[]
region_id?: string
customer_id?: string | null
sales_channel_id?: string | null
email?: string | null
currency_code?: string
metadata?: Record<string, unknown> | null
}

View File

@@ -1,4 +1,5 @@
export * from "./address"
export * from "./api-key"
export * from "./auth"
export * from "./bundles"
export * from "./cache"
@@ -27,10 +28,9 @@ export * from "./sales-channel"
export * from "./search"
export * from "./shared-context"
export * from "./stock-location"
export * from "./store"
export * from "./tax"
export * from "./totals"
export * from "./transaction-base"
export * from "./user"
export * from "./workflow"
export * from "./api-key"
export * from "./store"

View File

@@ -41,3 +41,9 @@ export enum ComputedActions {
REMOVE_SHIPPING_METHOD_ADJUSTMENT = "removeShippingMethodAdjustment",
CAMPAIGN_BUDGET_EXCEEDED = "campaignBudgetExceeded",
}
export enum PromotionActions {
ADD = "add",
REMOVE = "remove",
REPLACE = "replace",
}