feat: carry over promotions toggle on exchanges (#14128)

* feat: carry over promotions toggle on exchanges

* fix: inital flag value, return the flag on preview

* fix: validation of allocation type

* fix: revert client changes

* fix: invert condition

* feat: recompute adjustments when outbound item is updated

* fix: condition again

* fix: display more accurate inbound/outbound totals for exchanges

* fix: make exchanges specs green

* feat: more testing cases

* wip: pr feedback

* fix: use plural for the flag on Admin

* fix: schema test, route refactor

* feat: tooltip

* feat: refactor to use update workflow

* feat: display applied promotion per item on order details, show copy sku on hover

* feat: refactor edits and exchanges to have common flag toggle flow

* fix: delete empty file

* fix: exchange_id param query
This commit is contained in:
Frane Polić
2025-11-30 19:31:31 +01:00
committed by GitHub
parent 9d1f09ac7b
commit 5da51064d7
40 changed files with 1367 additions and 214 deletions

View File

@@ -0,0 +1,10 @@
---
"@medusajs/dashboard": patch
"@medusajs/core-flows": patch
"@medusajs/order": patch
"@medusajs/js-sdk": patch
"@medusajs/types": patch
"@medusajs/medusa": patch
---
feat: carry over promotions toggle on exchanges

View File

@@ -1089,6 +1089,19 @@ medusaIntegrationTestRunner({
expect(result.original_total).toEqual(11) // $10 + 10% tax
expect(result.total).toEqual(10 * 0.9 * 1.1) // ($10 - 10% discount) + 10% tax
const orderChange = (
await api.get(`/admin/orders/${orderId}/preview`, adminHeaders)
).data.order.order_change
// opt in for carry over promotions
await api.post(
`/admin/order-changes/${orderChange.id}`,
{
carry_over_promotions: true,
},
adminHeaders
)
// Add outbound item with price $12, 10% discount and 10% tax
result = (
await api
@@ -1214,6 +1227,363 @@ medusaIntegrationTestRunner({
expect(orderResult2.total).toEqual(11.016)
expect(orderResult2.original_total).toEqual(12.24)
})
it("should enable carry_over_promotions flag and apply promotions to outbound items (flag disabled before request)", async () => {
// fulfill item so it can be returned
await api.post(
`/admin/orders/${orderWithPromotion.id}/fulfillments`,
{
items: [
{
id: orderWithPromotion.items[0].id,
quantity: 1,
},
],
},
adminHeaders
)
let result = await api.post(
"/admin/exchanges",
{
order_id: orderWithPromotion.id,
description: "Test",
},
adminHeaders
)
const exchangeId = result.data.exchange.id
// Query order change for the exchange
const orderChange = (
await api.get(
`/admin/orders/${orderWithPromotion.id}/preview`,
adminHeaders
)
).data.order.order_change
const orderChangeId = orderChange.id
// return original item
await api.post(
`/admin/exchanges/${exchangeId}/inbound/items`,
{
items: [
{
id: orderWithPromotion.items[0].id,
quantity: 1,
},
],
},
adminHeaders
)
// add outbound item
await api.post(
`/admin/exchanges/${exchangeId}/outbound/items`,
{
items: [
{
variant_id: productForAdjustmentTest.variants[0].id,
quantity: 1,
},
],
},
adminHeaders
)
// Initially, promotions should be disabled by default when adding outbound items
let orderPreview = (
await api.get(
`/admin/orders/${orderWithPromotion.id}/preview`,
adminHeaders
)
).data.order
expect(orderPreview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "item-1", // original item
adjustments: [
expect.objectContaining({
amount: 1,
}),
],
}),
expect.objectContaining({
variant_id: productForAdjustmentTest.variants[0].id,
adjustments: [], // outbound item has no adjustments initially
}),
])
)
// Enable carry_over_promotions
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
// Verify adjustments are added
orderPreview = (
await api.get(
`/admin/orders/${orderWithPromotion.id}/preview`,
adminHeaders
)
).data.order
expect(orderPreview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "item-1", // original item
adjustments: [
expect.objectContaining({
amount: 1,
}),
],
}),
expect.objectContaining({
variant_id: productForAdjustmentTest.variants[0].id,
adjustments: [
// outbound item has adjustments after carry_over_promotions is enabled
expect.objectContaining({
amount: 1.2,
}),
],
}),
])
)
// Disable carry_over_promotions
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: false,
},
adminHeaders
)
// Verify adjustments are removed again
orderPreview = (
await api.get(
`/admin/orders/${orderWithPromotion.id}/preview`,
adminHeaders
)
).data.order
expect(orderPreview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "item-1", // original item
adjustments: [
expect.objectContaining({
amount: 1,
}),
],
}),
expect.objectContaining({
variant_id: productForAdjustmentTest.variants[0].id,
adjustments: [], // outbound item has no adjustments
}),
])
)
await api.post(
`/admin/exchanges/${exchangeId}/request`,
{},
adminHeaders
)
const finalOrder = (
await api.get(
`/admin/orders/${orderWithPromotion.id}`,
adminHeaders
)
).data.order
// items adjustment state is equal to the last state of the order preview (flag disabled)
expect(finalOrder.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "item-1", // original item
adjustments: [
expect.objectContaining({
amount: 1,
}),
],
}),
expect.objectContaining({
variant_id: productForAdjustmentTest.variants[0].id,
adjustments: [],
}),
])
)
})
it("should enable carry_over_promotions flag and apply promotions to outbound items (flag enabled before request)", async () => {
// fulfill item so it can be returned
await api.post(
`/admin/orders/${orderWithPromotion.id}/fulfillments`,
{
items: [
{
id: orderWithPromotion.items[0].id,
quantity: 1,
},
],
},
adminHeaders
)
let result = await api.post(
"/admin/exchanges",
{
order_id: orderWithPromotion.id,
description: "Test",
},
adminHeaders
)
const exchangeId = result.data.exchange.id
// Query order change for the exchange
const orderChange = (
await api.get(
`/admin/orders/${orderWithPromotion.id}/preview`,
adminHeaders
)
).data.order.order_change
const orderChangeId = orderChange.id
// return original item
await api.post(
`/admin/exchanges/${exchangeId}/inbound/items`,
{
items: [
{
id: orderWithPromotion.items[0].id,
quantity: 1,
},
],
},
adminHeaders
)
// add outbound item
await api.post(
`/admin/exchanges/${exchangeId}/outbound/items`,
{
items: [
{
variant_id: productForAdjustmentTest.variants[0].id,
quantity: 1,
},
],
},
adminHeaders
)
let orderPreview = (
await api.get(
`/admin/orders/${orderWithPromotion.id}/preview`,
adminHeaders
)
).data.order
expect(orderPreview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "item-1", // original item
adjustments: [
expect.objectContaining({
amount: 1,
}),
],
}),
expect.objectContaining({
variant_id: productForAdjustmentTest.variants[0].id,
adjustments: [], // outbound item has no adjustments initially
}),
])
)
// Enable carry_over_promotions
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
// Verify adjustments are added
orderPreview = (
await api.get(
`/admin/orders/${orderWithPromotion.id}/preview`,
adminHeaders
)
).data.order
expect(orderPreview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "item-1", // original item
adjustments: [
expect.objectContaining({
amount: 1,
}),
],
}),
expect.objectContaining({
variant_id: productForAdjustmentTest.variants[0].id,
adjustments: [
// outbound item has adjustments after carry_over_promotions is enabled
expect.objectContaining({
amount: 1.2,
}),
],
}),
])
)
await api.post(
`/admin/exchanges/${exchangeId}/request`,
{},
adminHeaders
)
const finalOrder = (
await api.get(
`/admin/orders/${orderWithPromotion.id}`,
adminHeaders
)
).data.order
// items adjustment state is equal to the last state of the order preview (flag enabled)
expect(finalOrder.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "item-1", // original item
adjustments: [
expect.objectContaining({
amount: 1,
}),
],
}),
expect.objectContaining({
variant_id: productForAdjustmentTest.variants[0].id,
adjustments: [
expect.objectContaining({
amount: 1.2,
}),
],
}),
])
)
})
})
})
},

View File

@@ -1297,6 +1297,16 @@ medusaIntegrationTestRunner({
adminHeaders
)
// allow carry over promotions flag on the edit
const orderChangeId = result.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
const orderId = result.data.order_change.order_id
result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data
@@ -1375,6 +1385,16 @@ medusaIntegrationTestRunner({
const orderId = result.data.order_change.order_id
// allow carry over promotions flag on the edit
const orderChangeId = result.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
const item = order.items[0]
result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data
@@ -1451,6 +1471,16 @@ medusaIntegrationTestRunner({
const orderId = result.data.order_change.order_id
// allow carry over promotions flag on the edit
const orderChangeId = result.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
const item = order.items[0]
result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data
@@ -1559,6 +1589,16 @@ medusaIntegrationTestRunner({
)
const orderChange1 = response.data.order_change
// allow carry over promotions flag on the edit
const orderChangeId = response.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
// 2. Add a new item in the first edit
response = await api.post(
`/admin/order-edits/${orderChange1.order_id}/items`,
@@ -1648,6 +1688,13 @@ medusaIntegrationTestRunner({
adminHeaders
)
const orderChange2 = response.data.order_change
await api.post(
`/admin/order-changes/${orderChange2.id}`,
{
carry_over_promotions: true,
},
adminHeaders
)
// 5. Add another productExtra item
response = await api.post(
@@ -1943,6 +1990,17 @@ medusaIntegrationTestRunner({
},
adminHeaders
)
// allow carry over promotions flag on the edit
const orderChangeId = editRes.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
const editOrderId = editRes.data.order_change.order_id
const extraVariantId = productExtra.variants[0].id
@@ -2038,6 +2096,16 @@ medusaIntegrationTestRunner({
const orderId = result.data.order_change.order_id
const originalItem = order.items[0]
// allow carry over promotions flag on the edit
const orderChangeId = result.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
// Add a new item
result = (
await api.post(
@@ -2154,6 +2222,16 @@ medusaIntegrationTestRunner({
adminHeaders
)
// allow carry over promotions flag on the edit
const orderChangeId = result.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
const orderId = result.data.order_change.order_id
result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data
@@ -2332,6 +2410,16 @@ medusaIntegrationTestRunner({
adminHeaders
)
// allow carry over promotions flag on the edit
const orderChangeId = result.data.order_change.id
await api.post(
`/admin/order-changes/${orderChangeId}`,
{
carry_over_promotions: true,
},
adminHeaders
)
const orderId = result.data.order_change.order_id
result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data

View File

@@ -10,8 +10,8 @@ import {
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory"
import { inventoryItemsQueryKeys } from "./inventory"
import { reservationItemsQueryKeys } from "./reservations"
import { inventoryItemsQueryKeys } from "./inventory"
const ORDERS_QUERY_KEY = "orders" as const
const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY) as TQueryKey<"orders"> & {
@@ -406,3 +406,35 @@ export const useCreateOrderCreditLine = (
...options,
})
}
export const useUpdateOrderChange = (
orderChangeId: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderChangeResponse,
FetchError,
{ carry_over_promotions: boolean }
>
) => {
return useMutation({
mutationFn: (payload: { carry_over_promotions: boolean }) =>
sdk.admin.order.updateOrderChange(orderChangeId, payload),
onSuccess: (data, variables, context) => {
const orderId = data.order_change.order_id
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -2008,6 +2008,7 @@
"uploadImagesLabel",
"uploadImagesHint",
"invalidFileType",
"fileTooLarge",
"failedToUpload",
"deleteWarning_one",
"deleteWarning_other",
@@ -4907,6 +4908,15 @@
"refundAmount": {
"type": "string"
},
"carryOverPromotion": {
"type": "string"
},
"carryOverPromotionHint": {
"type": "string"
},
"carryOverPromotionTooltip": {
"type": "string"
},
"activeChangeError": {
"type": "string"
},
@@ -4988,6 +4998,9 @@
"outboundShipping",
"outboundShippingHint",
"refundAmount",
"carryOverPromotion",
"carryOverPromotionHint",
"carryOverPromotionTooltip",
"activeChangeError",
"actions",
"cancel",

View File

@@ -1305,6 +1305,9 @@
"outboundShipping": "Outbound shipping",
"outboundShippingHint": "Choose which method you want to use.",
"refundAmount": "Estimated difference",
"carryOverPromotion": "Carry over promotions",
"carryOverPromotionHint": "Apply the order's promotions to the exchange items",
"carryOverPromotionTooltip": "Only fixed type promotions with EACH allocation and percentage type promotions with EACH or ACROSS allocation can be carried over to outbound exchange items.",
"activeChangeError": "There is an active order change on this order. Please finish or discard the previous change.",
"actions": {
"cancelExchange": {

View File

@@ -8,7 +8,7 @@ export const sdk = new Medusa({
baseUrl: backendUrl,
auth: {
type: authType,
jwtTokenStorageKey
jwtTokenStorageKey,
},
})

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PencilSquare } from "@medusajs/icons"
import { InformationCircleSolid, PencilSquare } from "@medusajs/icons"
import { AdminExchange, AdminOrder, AdminOrderPreview } from "@medusajs/types"
import {
Button,
@@ -8,6 +8,7 @@ import {
IconButton,
Switch,
toast,
Tooltip,
usePrompt,
} from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
@@ -31,6 +32,7 @@ import {
useUpdateExchangeInboundShipping,
useUpdateExchangeOutboundShipping,
} from "../../../../../hooks/api/exchanges"
import { useUpdateOrderChange } from "../../../../../hooks/api/orders"
import { currencies } from "../../../../../lib/data/currencies"
import { ExchangeInboundSection } from "./exchange-inbound-section.tsx"
import { ExchangeOutboundSection } from "./exchange-outbound-section"
@@ -92,6 +94,15 @@ export const ExchangeCreateForm = ({
isPending: isUpdatingInboundShipping,
} = useUpdateExchangeOutboundShipping(exchange.id, order.id)
const { mutateAsync: updateOrderChange } = useUpdateOrderChange(
preview?.order_change?.id!,
{
onError: (error) => {
toast.error(error.message)
},
}
)
const isRequestLoading =
isConfirming ||
isCanceling ||
@@ -117,6 +128,14 @@ export const ExchangeCreateForm = ({
(item) => !!item.actions?.find((a) => a.action === "ITEM_ADD")
)
const hasPromotions = useMemo(() => {
return (
(order as any).promotions &&
Array.isArray((order as any).promotions) &&
(order as any).promotions.length > 0
)
}, [order])
/**
* FORM
*/
@@ -161,6 +180,8 @@ export const ExchangeCreateForm = ({
: "",
location_id: orderReturn?.location_id,
send_notification: false,
carry_over_promotions:
preview?.order_change?.carry_over_promotions ?? false,
})
},
resolver: zodResolver(ExchangeCreateSchema),
@@ -306,7 +327,14 @@ export const ExchangeCreateForm = ({
const action = item.actions?.find(
(act) => act.action === "RETURN_ITEM"
)
acc = acc + (action?.amount || 0)
/**
* TODO: update this when the change actions return amounts are revamped
* it is might not cover all the cases but is more accurate then just using `unit_price` which does't consider adjustments
*/
acc =
acc +
((action?.details.quantity || 0) / item.quantity) *
item.total
return acc
}, 0) * -1,
@@ -323,10 +351,7 @@ export const ExchangeCreateForm = ({
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(
outboundPreviewItems.reduce((acc, item) => {
const action = item.actions?.find(
(act) => act.action === "ITEM_ADD"
)
acc = acc + (action?.amount || 0)
acc = acc + (item.total || 0) // outbound items entire quantity is used for calculating outbound total
return acc
}, 0),
@@ -498,6 +523,59 @@ export const ExchangeCreateForm = ({
</span>
</div>
</div>
{/* CARRY OVER PROMOTION */}
{hasPromotions && (
<div className="bg-ui-bg-field mt-4 rounded-lg border py-2 pl-2 pr-4">
<Form.Field
control={form.control}
name="carry_over_promotions"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center">
<Form.Control className="mr-4 self-start">
<Switch
dir="ltr"
className="mt-[2px] rtl:rotate-180"
checked={!!value}
onCheckedChange={async (checked) => {
onChange(checked)
if (preview?.order_change?.id) {
await updateOrderChange({
carry_over_promotions: checked,
})
}
}}
{...field}
/>
</Form.Control>
<div className="block">
<Form.Label className="flex items-center gap-x-2">
{t("orders.exchanges.carryOverPromotion")}
<Form.Hint>
<Tooltip
content={t(
"orders.exchanges.carryOverPromotionTooltip"
)}
>
<InformationCircleSolid />
</Tooltip>
</Form.Hint>
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.exchanges.carryOverPromotionHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
)}
{/* SEND NOTIFICATION*/}
<div className="bg-ui-bg-field mt-8 rounded-lg border py-2 pl-2 pr-4">
<Form.Field

View File

@@ -19,6 +19,7 @@ export const ExchangeCreateSchema = z.object({
inbound_option_id: z.string().nullish(),
outbound_option_id: z.string().nullish(),
send_notification: z.boolean().optional(),
carry_over_promotions: z.boolean().optional(),
})
export type CreateExchangeSchemaType = z.infer<typeof ExchangeCreateSchema>

View File

@@ -10,6 +10,7 @@ import {
DocumentText,
ExclamationCircle,
PencilSquare,
ReceiptPercent,
TriangleDownMini,
} from "@medusajs/icons"
import {
@@ -402,29 +403,49 @@ const Item = ({
item.variant?.inventory_items?.some((i) => i.required_quantity > 1))
const hasUnfulfilledItems = item.quantity - item.detail.fulfilled_quantity > 0
const appliedPromoCodes = (item.adjustments || []).map((a) => a.code)
return (
<>
<div
key={item.id}
className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4"
>
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text size="small" leading="compact" className="text-ui-fg-base">
{item.title}
</Text>
<div className=" flex justify-between gap-x-2 ">
<div className=" group flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text size="small" leading="compact" className="text-ui-fg-base">
{item.title}
</Text>
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant_sku}</Text>
<Copy content={item.variant_sku} className="text-ui-fg-muted" />
</div>
)}
<Text size="small">
{item.variant?.options?.map((o) => o.value).join(" · ")}
</Text>
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant_sku}</Text>
<Copy
content={item.variant_sku}
className="text-ui-fg-muted hidden group-hover:block"
/>
</div>
)}
<Text size="small">
{item.variant?.options?.map((o) => o.value).join(" · ")}
</Text>
</div>
</div>
{appliedPromoCodes.length > 0 && (
<Tooltip
content={
<span className="text-pretty">
{appliedPromoCodes.map((code) => (
<div key={code}>{code}</div>
))}
</span>
}
>
<ReceiptPercent className="text-ui-fg-subtle flex-shrink self-center " />
</Tooltip>
)}
</div>
<div className="grid grid-cols-3 items-center gap-x-4">

View File

@@ -17,6 +17,7 @@ export * from "./delete-order-change-actions"
export * from "./delete-order-changes"
export * from "./delete-order-shipping-methods"
export * from "./exchange/cancel-exchange"
export * from "./list-order-change-actions-by-type"
export * from "./exchange/create-exchange"
export * from "./exchange/create-exchange-items-from-actions"
export * from "./exchange/delete-exchanges"

View File

@@ -0,0 +1,37 @@
import { IOrderModuleService } from "@medusajs/framework/types"
import { ChangeActionType, Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
* This step lists order change actions filtered by action type.
*/
export const listOrderChangeActionsByTypeStep = createStep(
"list-order-change-actions-by-type",
async function (
{
order_change_id,
action_type,
}: {
order_change_id: string
action_type: ChangeActionType
},
{ container }
) {
const service = container.resolve<IOrderModuleService>(Modules.ORDER)
const actions = await service.listOrderChangeActions(
{
order_change_id,
},
{
select: ["id", "action"],
}
)
const filteredActions = actions.filter(
(action) => action.action === action_type
)
return new StepResponse(filteredActions.map((action) => action.id))
}
)

View File

@@ -0,0 +1,166 @@
import {
ComputeActionContext,
OrderChangeDTO,
OrderDTO,
PromotionDTO,
} from "@medusajs/framework/types"
import { ChangeActionType } from "@medusajs/framework/utils"
import {
createWorkflow,
transform,
when,
WorkflowData,
} from "@medusajs/framework/workflows-sdk"
import {
getActionsToComputeFromPromotionsStep,
prepareAdjustmentsFromPromotionActionsStep,
} from "../../cart"
import { previewOrderChangeStep } from "../steps/preview-order-change"
import { createOrderChangeActionsWorkflow } from "./create-order-change-actions"
import {
deleteOrderChangeActionsStep,
listOrderChangeActionsByTypeStep,
} from "../steps"
/**
* The data to compute adjustments for an order edit, exchange, claim, or return.
*/
export type ComputeAdjustmentsForPreviewWorkflowInput = {
/**
* The order's details.
*/
order: OrderDTO & { promotions: PromotionDTO[] }
/**
* The order change's details.
*/
orderChange: OrderChangeDTO
}
export const computeAdjustmentsForPreviewWorkflowId =
"compute-adjustments-for-preview"
/**
* This workflow computes adjustments for an order change if the carry over promotions flag is true on the order change.
* If the flag is false, it deletes the existing adjustments replacement actions.
*
* It is currently used as a part of the order edit and exchange flows.
* It's used by the [Add Items to Order Edit Admin API Route](https://docs.medusajs.com/api/admin#order-edits_postordereditsiditems),
* [Add Outbound Items Admin API Route](https://docs.medusajs.com/api/admin#exchanges_postexchangesidoutbounditems),
* and [Add Inbound Items Admin API Route](https://docs.medusajs.com/api/admin#exchanges_postexchangesidinbounditems).
*
* You can use this workflow within your customizations or your own custom workflows, allowing you to compute adjustments
* in your custom flows.
*
* @example
* const { result } = await computeAdjustmentsForPreviewWorkflow(container)
* .run({
* input: {
* order: {
* id: "order_123",
* // other order details...
* },
* orderChange: {
* id: "orch_123",
* // other order change details...
* },
* exchange_id: "exchange_123", // optional, for exchanges
* }
* })
*
* @summary
*
* Compute adjustments for an order edit, exchange, claim, or return.
*/
export const computeAdjustmentsForPreviewWorkflow = createWorkflow(
computeAdjustmentsForPreviewWorkflowId,
function (input: WorkflowData<ComputeAdjustmentsForPreviewWorkflowInput>) {
const previewedOrder = previewOrderChangeStep(input.order.id)
when(
{ order: input.order },
({ order }) =>
/**
* Compute adjustments only if the flag on the order change is true
*/
!!order.promotions.length && !!input.orderChange.carry_over_promotions
).then(() => {
const actionsToComputeItemsInput = transform(
{ previewedOrder, order: input.order },
({ previewedOrder, order }) => {
return {
currency_code: order.currency_code,
items: previewedOrder.items.map((item) => ({
...item,
// Buy-Get promotions rely on the product ID, so we need to manually set it before refreshing adjustments
product: { id: item.product_id },
})),
} as ComputeActionContext
}
)
const orderPromotions = transform({ order: input.order }, ({ order }) => {
return order.promotions
.map((p) => p.code)
.filter((p) => p !== undefined)
})
const actions = getActionsToComputeFromPromotionsStep({
computeActionContext: actionsToComputeItemsInput,
promotionCodesToApply: orderPromotions,
})
const { lineItemAdjustmentsToCreate } =
prepareAdjustmentsFromPromotionActionsStep({ actions })
const orderChangeActionAdjustmentsInput = transform(
{
order: input.order,
previewedOrder,
orderChange: input.orderChange,
lineItemAdjustmentsToCreate,
},
({
order,
previewedOrder,
orderChange,
lineItemAdjustmentsToCreate,
}) => {
return previewedOrder.items.map((item) => {
const itemAdjustments = lineItemAdjustmentsToCreate.filter(
(adjustment) => adjustment.item_id === item.id
)
return {
order_change_id: orderChange.id,
order_id: order.id,
exchange_id: orderChange.exchange_id ?? undefined,
claim_id: orderChange.claim_id ?? undefined,
return_id: orderChange.return_id ?? undefined,
version: orderChange.version,
action: ChangeActionType.ITEM_ADJUSTMENTS_REPLACE,
details: {
reference_id: item.id,
adjustments: itemAdjustments,
},
}
})
}
)
createOrderChangeActionsWorkflow
.runAsStep({ input: orderChangeActionAdjustmentsInput })
.config({ name: "order-change-action-adjustments-input" })
})
when(
{ order: previewedOrder },
({ order }) => !order.order_change.carry_over_promotions
).then(() => {
const actionIds = listOrderChangeActionsByTypeStep({
order_change_id: input.orderChange.id,
action_type: ChangeActionType.ITEM_ADJUSTMENTS_REPLACE,
})
deleteOrderChangeActionsStep({ ids: actionIds })
})
}
)

View File

@@ -22,7 +22,7 @@ import {
} from "../../utils/order-validation"
import { addOrderLineItemsWorkflow } from "../add-line-items"
import { createOrderChangeActionsWorkflow } from "../create-order-change-actions"
import { computeAdjustmentsForPreviewWorkflow } from "../order-edit/compute-adjustments-for-preview"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
import { updateOrderTaxLinesWorkflow } from "../update-tax-lines"
import { refreshExchangeShippingWorkflow } from "./refresh-shipping"
@@ -140,7 +140,13 @@ export const orderExchangeAddNewItemWorkflow = createWorkflow(
const orderChange: OrderChangeDTO = useRemoteQueryStep({
entry_point: "order_change",
fields: ["id", "status", "version"],
fields: [
"id",
"status",
"version",
"exchange_id",
"carry_over_promotions",
],
variables: {
filters: {
order_id: orderExchange.order_id,
@@ -212,7 +218,6 @@ export const orderExchangeAddNewItemWorkflow = createWorkflow(
input: {
order: orderWithPromotions,
orderChange,
exchange_id: orderExchange.id,
},
})

View File

@@ -33,7 +33,7 @@ import {
throwIfOrderChangeIsNotActive,
} from "../../utils/order-validation"
import { createOrderChangeActionsWorkflow } from "../create-order-change-actions"
import { computeAdjustmentsForPreviewWorkflow } from "../order-edit/compute-adjustments-for-preview"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
import { refreshExchangeShippingWorkflow } from "./refresh-shipping"
/**
@@ -194,7 +194,13 @@ export const orderExchangeRequestItemReturnWorkflow = createWorkflow(
const orderChange: OrderChangeDTO = useRemoteQueryStep({
entry_point: "order_change",
fields: ["id", "status", "version"],
fields: [
"id",
"status",
"version",
"exchange_id",
"carry_over_promotions",
],
variables: {
filters: {
order_id: orderExchange.order_id,
@@ -321,7 +327,6 @@ export const orderExchangeRequestItemReturnWorkflow = createWorkflow(
input: {
order: orderWithPromotions,
orderChange,
exchange_id: orderExchange.id,
},
})

View File

@@ -3,6 +3,7 @@ import {
OrderChangeDTO,
OrderDTO,
OrderExchangeDTO,
PromotionDTO,
OrderPreviewDTO,
OrderWorkflow,
} from "@medusajs/framework/types"
@@ -24,6 +25,7 @@ import {
throwIfOrderChangeIsNotActive,
} from "../../utils/order-validation"
import { refreshExchangeShippingWorkflow } from "./refresh-shipping"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
/**
* The data to validate that an outbound or new item in an exchange can be updated.
@@ -150,7 +152,7 @@ export const updateExchangeAddItemWorkflow = createWorkflow(
const order: OrderDTO = useRemoteQueryStep({
entry_point: "orders",
fields: ["id", "status", "canceled_at", "items.*"],
fields: ["id", "status", "canceled_at", "items.*", "promotions.*"],
variables: { id: orderExchange.order_id },
list: false,
throw_if_key_not_found: true,
@@ -158,7 +160,14 @@ export const updateExchangeAddItemWorkflow = createWorkflow(
const orderChange: OrderChangeDTO = useRemoteQueryStep({
entry_point: "order_change",
fields: ["id", "status", "version", "actions.*"],
fields: [
"id",
"status",
"version",
"exchange_id",
"actions.*",
"carry_over_promotions",
],
variables: {
filters: {
order_id: orderExchange.order_id,
@@ -207,6 +216,20 @@ export const updateExchangeAddItemWorkflow = createWorkflow(
}
)
const orderWithPromotions = transform({ order }, ({ order }) => {
return {
...order,
promotions: (order as any).promotions ?? [],
} as OrderDTO & { promotions: PromotionDTO[] }
})
computeAdjustmentsForPreviewWorkflow.runAsStep({
input: {
order: orderWithPromotions,
orderChange,
},
})
refreshExchangeShippingWorkflow.runAsStep({
input: refreshArgs,
})

View File

@@ -50,7 +50,7 @@ export * from "./mark-payment-collection-as-paid"
export * from "./maybe-refresh-shipping-methods"
export * from "./order-edit/begin-order-edit"
export * from "./order-edit/cancel-begin-order-edit"
export * from "./order-edit/compute-adjustments-for-preview"
export * from "./compute-adjustments-for-preview"
export * from "./order-edit/confirm-order-edit-request"
export * from "./order-edit/create-order-edit-shipping-method"
export * from "./order-edit/order-edit-add-new-item"
@@ -81,11 +81,13 @@ export * from "./return/update-receive-item-return-request"
export * from "./return/update-request-item-return"
export * from "./return/update-return"
export * from "./return/update-return-shipping-method"
export * from "./on-carry-promotions-flag-set"
export * from "./transfer/accept-order-transfer"
export * from "./transfer/cancel-order-transfer"
export * from "./transfer/decline-order-transfer"
export * from "./transfer/request-order-transfer"
export * from "./update-order"
export * from "./update-order-change"
export * from "./update-order-change-actions"
export * from "./update-order-changes"
export * from "./update-tax-lines"

View File

@@ -0,0 +1,201 @@
import {
OrderChangeDTO,
OrderDTO,
PromotionDTO,
} from "@medusajs/framework/types"
import {
ApplicationMethodAllocation,
MedusaError,
} from "@medusajs/framework/utils"
import {
WorkflowData,
WorkflowResponse,
createStep,
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import { throwIfOrderChangeIsNotActive } from "../utils/order-validation"
import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview"
/**
* The data to set the carry over promotions flag for an order change.
*/
export type OnCarryPromotionsFlagSetWorkflowInput = {
/**
* The order change's ID.
*/
order_change_id: string
/**
* Whether to carry over promotions to outbound exchange items.
*/
carry_over_promotions: boolean
}
/**
* This step validates that the order change is an exchange and validates promotion allocation.
*/
export const validateCarryPromotionsFlagStep = createStep(
"validate-carry-promotions-flag",
async function ({
orderChange,
order,
input,
}: {
orderChange: OrderChangeDTO
order: OrderDTO & { promotions?: PromotionDTO[] }
input: OnCarryPromotionsFlagSetWorkflowInput
}) {
// Validate order change is active
throwIfOrderChangeIsNotActive({ orderChange })
// we don't need to validate promotion since we will be resetting the adjustments
if (!input.carry_over_promotions) {
return
}
// Validate promotion allocation if promotions exist
if (order.promotions && order.promotions.length > 0) {
const invalidPromotions: string[] = []
for (const promotion of order.promotions) {
const applicationMethod = (promotion as any).application_method
if (!applicationMethod) {
continue
}
const allocation = applicationMethod.allocation
const type = applicationMethod.type
if (
allocation !== ApplicationMethodAllocation.ACROSS &&
allocation !== ApplicationMethodAllocation.EACH
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Promotion ${
promotion.code || promotion.id
} has invalid allocation. Only promotions with EACH or ACROSS allocation can be carried over to outbound exchange items.`
)
}
// For fixed promotions, allocation must be EACH
if (
type === "fixed" &&
allocation !== ApplicationMethodAllocation.EACH
) {
invalidPromotions.push(promotion.code || promotion.id)
}
// For percentage promotions, allocation must be EACH or ACROSS
if (
type === "percentage" &&
allocation !== ApplicationMethodAllocation.EACH &&
allocation !== ApplicationMethodAllocation.ACROSS
) {
invalidPromotions.push(promotion.code || promotion.id)
}
}
if (invalidPromotions.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Promotions with codes ${invalidPromotions.join(
", "
)} have invalid allocation. Fixed promotions must have EACH allocation, and percentage promotions must have EACH or ACROSS allocation.`
)
}
}
}
)
export const onCarryPromotionsFlagSetId = "on-carry-promotions-flag-set"
/**
* This workflow sets the carry over promotions flag for an order change.
* It validates that the order change is active and is an exchange, validates promotion allocation,
* and either applies or removes promotion adjustments based on the flag value.
*
* @example
* const { result } = await onCarryPromotionsFlagSet(container)
* .run({
* input: {
* order_change_id: "orch_123",
* carry_over_promotions: true,
* }
* })
*
* @summary
*
* Set the carry over promotions flag for an order change.
*/
export const onCarryPromotionsFlagSet = createWorkflow(
onCarryPromotionsFlagSetId,
function (
input: WorkflowData<OnCarryPromotionsFlagSetWorkflowInput>
): WorkflowResponse<void> {
const orderChange: OrderChangeDTO = useRemoteQueryStep({
entry_point: "order_change",
fields: [
"id",
"status",
"version",
"exchange_id",
"claim_id",
"return_id",
"order_id",
"canceled_at",
"confirmed_at",
"declined_at",
"carry_over_promotions",
],
variables: {
filters: {
id: input.order_change_id,
},
},
list: false,
throw_if_key_not_found: true,
}).config({ name: "order-change-query" })
const order: OrderDTO & { promotions?: PromotionDTO[] } =
useRemoteQueryStep({
entry_point: "orders",
fields: [
"id",
"currency_code",
"promotions.*",
"promotions.application_method.*",
],
variables: {
id: orderChange.order_id,
},
list: false,
throw_if_key_not_found: true,
}).config({ name: "order-query" })
validateCarryPromotionsFlagStep({
orderChange,
order,
input,
})
const orderWithPromotions = transform({ order }, ({ order }) => {
return {
...order,
promotions: (order as any).promotions ?? [],
} as OrderDTO & { promotions: PromotionDTO[] }
})
computeAdjustmentsForPreviewWorkflow.runAsStep({
input: {
orderChange,
order: orderWithPromotions,
},
})
return new WorkflowResponse(void 0)
}
)

View File

@@ -1,163 +0,0 @@
import {
ComputeActionContext,
OrderChangeDTO,
OrderDTO,
PromotionDTO,
} from "@medusajs/framework/types"
import { ChangeActionType } from "@medusajs/framework/utils"
import {
createWorkflow,
transform,
when,
WorkflowData,
} from "@medusajs/framework/workflows-sdk"
import {
getActionsToComputeFromPromotionsStep,
prepareAdjustmentsFromPromotionActionsStep,
} from "../../../cart"
import { previewOrderChangeStep } from "../../steps/preview-order-change"
import { createOrderChangeActionsWorkflow } from "../create-order-change-actions"
/**
* The data to compute adjustments for an order edit, exchange, claim, or return.
*/
export type ComputeAdjustmentsForPreviewWorkflowInput = {
/**
* The order's details.
*/
order: OrderDTO & { promotions: PromotionDTO[] }
/**
* The order change's details.
*/
orderChange: OrderChangeDTO
/**
* Optional exchange ID to include in the order change action.
*/
exchange_id?: string
/**
* Optional claim ID to include in the order change action.
*/
claim_id?: string
/**
* Optional return ID to include in the order change action.
*/
return_id?: string
}
export const computeAdjustmentsForPreviewWorkflowId =
"compute-adjustments-for-preview"
/**
* This workflow computes adjustments for an order edit, exchange, claim, or return.
* It's used by the [Add Items to Order Edit Admin API Route](https://docs.medusajs.com/api/admin#order-edits_postordereditsiditems),
* [Add Outbound Items Admin API Route](https://docs.medusajs.com/api/admin#exchanges_postexchangesidoutbounditems),
* and [Add Inbound Items Admin API Route](https://docs.medusajs.com/api/admin#exchanges_postexchangesidinbounditems).
*
* You can use this workflow within your customizations or your own custom workflows, allowing you to compute adjustments
* in your custom flows.
*
* @example
* const { result } = await computeAdjustmentsForPreviewWorkflow(container)
* .run({
* input: {
* order: {
* id: "order_123",
* // other order details...
* },
* orderChange: {
* id: "orch_123",
* // other order change details...
* },
* exchange_id: "exchange_123", // optional, for exchanges
* }
* })
*
* @summary
*
* Compute adjustments for an order edit, exchange, claim, or return.
*/
export const computeAdjustmentsForPreviewWorkflow = createWorkflow(
computeAdjustmentsForPreviewWorkflowId,
function (input: WorkflowData<ComputeAdjustmentsForPreviewWorkflowInput>) {
const previewedOrder = previewOrderChangeStep(input.order.id)
when({ order: input.order }, ({ order }) => !!order.promotions.length).then(
() => {
const actionsToComputeItemsInput = transform(
{ previewedOrder, order: input.order },
({ previewedOrder, order }) => {
return {
currency_code: order.currency_code,
items: previewedOrder.items.map((item) => ({
...item,
// Buy-Get promotions rely on the product ID, so we need to manually set it before refreshing adjustments
product: { id: item.product_id },
})),
} as ComputeActionContext
}
)
const orderPromotions = transform(
{ order: input.order },
({ order }) => {
return order.promotions
.map((p) => p.code)
.filter((p) => p !== undefined)
}
)
const actions = getActionsToComputeFromPromotionsStep({
computeActionContext: actionsToComputeItemsInput,
promotionCodesToApply: orderPromotions,
})
const { lineItemAdjustmentsToCreate } =
prepareAdjustmentsFromPromotionActionsStep({ actions })
const orderChangeActionAdjustmentsInput = transform(
{
order: input.order,
previewedOrder,
orderChange: input.orderChange,
lineItemAdjustmentsToCreate,
exchangeId: input.exchange_id,
claimId: input.claim_id,
returnId: input.return_id,
},
({
order,
previewedOrder,
orderChange,
lineItemAdjustmentsToCreate,
exchangeId,
claimId,
returnId,
}) => {
return previewedOrder.items.map((item) => {
const itemAdjustments = lineItemAdjustmentsToCreate.filter(
(adjustment) => adjustment.item_id === item.id
)
return {
order_change_id: orderChange.id,
order_id: order.id,
...(exchangeId && { exchange_id: exchangeId }),
...(claimId && { claim_id: claimId }),
...(returnId && { return_id: returnId }),
version: orderChange.version,
action: ChangeActionType.ITEM_ADJUSTMENTS_REPLACE,
details: {
reference_id: item.id,
adjustments: itemAdjustments,
},
}
})
}
)
createOrderChangeActionsWorkflow
.runAsStep({ input: orderChangeActionAdjustmentsInput })
.config({ name: "order-change-action-adjustments-input" })
}
)
}
)

View File

@@ -22,7 +22,7 @@ import { addOrderLineItemsWorkflow } from "../add-line-items"
import { createOrderChangeActionsWorkflow } from "../create-order-change-actions"
import { updateOrderTaxLinesWorkflow } from "../update-tax-lines"
import { fieldsToRefreshOrderEdit } from "./utils/fields"
import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
/**
* The data to validate that new items can be added to an order edit.
@@ -118,7 +118,7 @@ export const orderEditAddNewItemWorkflow = createWorkflow(
const orderChangeResult = useQueryGraphStep({
entity: "order_change",
fields: ["id", "status", "version", "actions.*"],
fields: ["id", "status", "version", "actions.*", "carry_over_promotions"],
filters: {
order_id: input.order_id,
status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED],

View File

@@ -24,7 +24,7 @@ import {
throwIfOrderChangeIsNotActive,
} from "../../utils/order-validation"
import { createOrderChangeActionsWorkflow } from "../create-order-change-actions"
import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
import { fieldsToRefreshOrderEdit } from "./utils/fields"
/**
@@ -128,7 +128,7 @@ export const orderEditUpdateItemQuantityWorkflow = createWorkflow(
const orderChangeResult = useQueryGraphStep({
entity: "order_change",
fields: ["id", "status", "version", "actions.*"],
fields: ["id", "status", "version", "actions.*", "carry_over_promotions"],
filters: {
order_id: input.order_id,
status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED],

View File

@@ -22,7 +22,7 @@ import {
throwIfIsCancelled,
throwIfOrderChangeIsNotActive,
} from "../../utils/order-validation"
import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
import { fieldsToRefreshOrderEdit } from "./utils/fields"
/**
@@ -143,7 +143,7 @@ export const removeItemOrderEditActionWorkflow = createWorkflow(
const orderChangeResult = useQueryGraphStep({
entity: "order_change",
fields: ["id", "status", "version", "actions.*"],
fields: ["id", "status", "version", "actions.*", "carry_over_promotions"],
filters: {
order_id: input.order_id,
status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED],

View File

@@ -22,7 +22,7 @@ import {
throwIfIsCancelled,
throwIfOrderChangeIsNotActive,
} from "../../utils/order-validation"
import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
import { fieldsToRefreshOrderEdit } from "./utils/fields"
/**
@@ -142,7 +142,7 @@ export const updateOrderEditAddItemWorkflow = createWorkflow(
const orderChangeResult = useQueryGraphStep({
entity: "order_change",
fields: ["id", "status", "version", "actions.*"],
fields: ["id", "status", "version", "actions.*", "carry_over_promotions"],
filters: {
order_id: input.order_id,
status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED],

View File

@@ -22,7 +22,7 @@ import {
throwIfIsCancelled,
throwIfOrderChangeIsNotActive,
} from "../../utils/order-validation"
import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview"
import { computeAdjustmentsForPreviewWorkflow } from "../compute-adjustments-for-preview"
import { fieldsToRefreshOrderEdit } from "./utils/fields"
/**

View File

@@ -0,0 +1,59 @@
import { OrderChangeDTO, UpdateOrderChangeDTO } from "@medusajs/framework/types"
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
transform,
when,
} from "@medusajs/framework/workflows-sdk"
import { updateOrderChangesStep } from "../steps/update-order-changes"
import { onCarryPromotionsFlagSet } from "./on-carry-promotions-flag-set"
export const updateOrderChangeWorkflowId = "update-order-change-workflow"
/**
* This workflow updates an order change.
* If the carry_over_promotions flag is provided, it calls onCarryPromotionsFlagSet
* to handle the promotion logic. Otherwise, it updates the order change directly.
*
* @example
* const { result } = await updateOrderChangeWorkflow(container)
* .run({
* input: {
* id: "orch_123",
* carry_over_promotions: true,
* }
* })
*
* @summary
*
* Update an order change, conditionally handling promotion carry-over if specified.
*/
export const updateOrderChangeWorkflow = createWorkflow(
updateOrderChangeWorkflowId,
function (
input: WorkflowData<UpdateOrderChangeDTO>
): WorkflowResponse<OrderChangeDTO> {
const updatedOrderChange = updateOrderChangesStep([input])
when(
"should-call-carry-over-promotion-workflow",
input,
({ carry_over_promotions }) => typeof carry_over_promotions === "boolean"
).then(() => {
return onCarryPromotionsFlagSet.runAsStep({
input: {
order_change_id: input.id,
carry_over_promotions: input.carry_over_promotions!,
},
})
})
return new WorkflowResponse(
transform(
{ updatedOrderChange },
({ updatedOrderChange }) => updatedOrderChange?.[0]
)
)
}
)

View File

@@ -633,4 +633,43 @@ export class Order {
}
)
}
/**
* This method updates an order change. It sends a request to the
* [Update Order Change](https://docs.medusajs.com/api/admin#order-changes_postorder-changesid)
* API route.
*
* @param id - The order change's ID.
* @param body - The update details.
* @param query - Configure the fields to retrieve in the order change.
* @param headers - Headers to pass in the request
* @returns The order change's details.
*
* @example
* sdk.admin.order.updateOrderChange(
* "ordch_123",
* {
* carry_over_promotions: true
* }
* )
* .then(({ order_change }) => {
* console.log(order_change)
* })
*/
async updateOrderChange(
id: string,
body: { carry_over_promotions: boolean },
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminOrderChangeResponse>(
`/admin/order-changes/${id}`,
{
method: "POST",
headers,
body,
query,
}
)
}
}

View File

@@ -20,6 +20,13 @@ export interface AdminOrderChangesResponse {
order_changes: AdminOrderChange[]
}
export interface AdminOrderChangeResponse {
/**
* The order change's details.
*/
order_change: AdminOrderChange
}
export type AdminOrderListResponse = PaginatedResponse<{
/**
* The list of orders.

View File

@@ -1012,6 +1012,11 @@ export interface BaseOrderChange {
*/
actions: BaseOrderChangeAction[]
/**
* Whether to carry over promotions (apply promotions to outbound exchange items).
*/
carry_over_promotions?: boolean | null
/**
* The status of the order change
*/

View File

@@ -2117,6 +2117,11 @@ export interface OrderChangeDTO {
*/
change_type?: "return" | "exchange" | "claim" | "edit" | "transfer"
/**
* Whether to carry over promotions (apply promotions to outbound exchange items).
*/
carry_over_promotions?: boolean | null
/**
* The ID of the associated order
*/

View File

@@ -908,6 +908,11 @@ export interface CreateOrderChangeDTO {
*/
internal_note?: string | null
/**
* Whether to carry over promotions (apply promotions to outbound exchange items).
*/
carry_over_promotions?: boolean | null
/**
* The user or customer that requested the order change.
*/
@@ -1016,6 +1021,11 @@ export interface UpdateOrderChangeDTO {
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown> | null
/**
* Whether to carry over promotions to outbound exchange items.
*/
carry_over_promotions?: boolean | null
}
/**

View File

@@ -0,0 +1,38 @@
import { updateOrderChangeWorkflow } from "@medusajs/core-flows"
import { HttpTypes, RemoteQueryFunction } from "@medusajs/framework/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { AdminPostOrderChangesReqSchemaType } from "../validators"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminPostOrderChangesReqSchemaType>,
res: MedusaResponse<HttpTypes.AdminOrderChangeResponse>
) => {
const { id } = req.params
const { carry_over_promotions } = req.validatedBody
const query = req.scope.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.QUERY
)
const workflow = updateOrderChangeWorkflow(req.scope)
await workflow.run({
input: {
id,
carry_over_promotions,
},
})
const result = await query.graph({
entity: "order_change",
filters: {
...req.filterableFields,
id,
},
fields: req.queryConfig.fields,
})
res.status(200).json({ order_change: result.data[0] })
}

View File

@@ -0,0 +1,24 @@
import { MiddlewareRoute } from "@medusajs/framework/http"
import {
validateAndTransformBody,
validateAndTransformQuery,
} from "@medusajs/framework"
import * as QueryConfig from "./query-config"
import {
AdminPostOrderChangesReqSchema,
AdminOrderChangeParams,
} from "./validators"
export const adminOrderChangesRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["POST"],
matcher: "/admin/order-changes/:id",
middlewares: [
validateAndTransformBody(AdminPostOrderChangesReqSchema),
validateAndTransformQuery(
AdminOrderChangeParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
]

View File

@@ -0,0 +1,32 @@
export const defaultAdminRetrieveOrderChangeFields = [
"id",
"order_id",
"return_id",
"claim_id",
"exchange_id",
"version",
"change_type",
"*actions",
"description",
"status",
"internal_note",
"created_by",
"requested_by",
"requested_at",
"confirmed_by",
"confirmed_at",
"declined_by",
"declined_reason",
"metadata",
"declined_at",
"canceled_by",
"canceled_at",
"created_at",
"updated_at",
"carry_over_promotions",
]
export const retrieveTransformQueryConfig = {
defaults: defaultAdminRetrieveOrderChangeFields,
isList: false,
}

View File

@@ -0,0 +1,22 @@
import { z } from "zod"
import { createOperatorMap, createSelectParams } from "../../utils/validators"
export const AdminOrderChangeParams = createSelectParams().merge(
z.object({
id: z.union([z.string(), z.array(z.string())]).optional(),
status: z.union([z.string(), z.array(z.string())]).optional(),
change_type: z.union([z.string(), z.array(z.string())]).optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
})
)
export const AdminPostOrderChangesReqSchema = z.object({
carry_over_promotions: z.boolean(),
})
export type AdminPostOrderChangesReqSchemaType = z.infer<
typeof AdminPostOrderChangesReqSchema
>

View File

@@ -17,7 +17,6 @@ export const defaultAdminRetrieveOrderFields = [
"total",
"subtotal",
"tax_total",
"order_change",
"discount_total",
"discount_tax_total",
"original_total",
@@ -81,6 +80,7 @@ export const defaultAdminRetrieveOrderChangesFields = [
"canceled_at",
"created_at",
"updated_at",
"carry_over_promotions",
]
export const defaultAdminOrderItemsFields = [

View File

@@ -14,6 +14,7 @@ import { adminFulfillmentsRoutesMiddlewares } from "./admin/fulfillments/middlew
import { adminInventoryRoutesMiddlewares } from "./admin/inventory-items/middlewares"
import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares"
import { adminNotificationRoutesMiddlewares } from "./admin/notifications/middlewares"
import { adminOrderChangesRoutesMiddlewares } from "./admin/order-changes/middlewares"
import { adminOrderEditRoutesMiddlewares } from "./admin/order-edits/middlewares"
import { adminOrderRoutesMiddlewares } from "./admin/orders/middlewares"
import { adminPaymentCollectionsMiddlewares } from "./admin/payment-collections/middlewares"
@@ -129,6 +130,7 @@ export default defineMiddlewares([
...adminExchangeRoutesMiddlewares,
...adminProductVariantRoutesMiddlewares,
...adminTaxProviderRoutesMiddlewares,
...adminOrderChangesRoutesMiddlewares,
...adminOrderEditRoutesMiddlewares,
...adminPaymentCollectionsMiddlewares,
...viewConfigurationRoutesMiddlewares,

View File

@@ -721,6 +721,15 @@
"length": 6,
"mappedType": "datetime"
},
"carry_over_promotions": {
"name": "carry_over_promotions",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "boolean"
},
"order_id": {
"name": "order_id",
"type": "text",
@@ -1978,15 +1987,6 @@
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_order_item_order_id_version\" ON \"order_item\" (\"order_id\", \"version\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_order_item_version",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_order_item_version\" ON \"order_item\" (version) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_order_item_item_id",
"columnNames": [],

View File

@@ -0,0 +1,15 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20251125164002 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table if exists "order_change" add column if not exists "carry_over_promotions" boolean null;`
)
}
override async down(): Promise<void> {
this.addSql(
`alter table if exists "order_change" drop column if exists "carry_over_promotions";`
)
}
}

View File

@@ -27,6 +27,7 @@ const _OrderChange = model
declined_at: model.dateTime().nullable(),
canceled_by: model.text().nullable(),
canceled_at: model.dateTime().nullable(),
carry_over_promotions: model.boolean().nullable(),
order: model.belongsTo<() => typeof Order>(() => Order, {
mappedBy: "changes",
}),

View File

@@ -3336,6 +3336,7 @@ export default class OrderModuleService
"status",
"description",
"internal_note",
"carry_over_promotions",
],
relations: [] as string[],
order: {},