diff --git a/.changeset/public-brooms-give.md b/.changeset/public-brooms-give.md new file mode 100644 index 0000000000..ac4729372d --- /dev/null +++ b/.changeset/public-brooms-give.md @@ -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 diff --git a/integration-tests/http/__tests__/exchanges/exchanges.spec.ts b/integration-tests/http/__tests__/exchanges/exchanges.spec.ts index 0b1d0f14eb..53978b4e25 100644 --- a/integration-tests/http/__tests__/exchanges/exchanges.spec.ts +++ b/integration-tests/http/__tests__/exchanges/exchanges.spec.ts @@ -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, + }), + ], + }), + ]) + ) + }) }) }) }, diff --git a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts index c97e36dda6..a8d0e29590 100644 --- a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts +++ b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts @@ -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 diff --git a/packages/admin/dashboard/src/hooks/api/orders.tsx b/packages/admin/dashboard/src/hooks/api/orders.tsx index aaa0684943..d7bfffe7ac 100644 --- a/packages/admin/dashboard/src/hooks/api/orders.tsx +++ b/packages/admin/dashboard/src/hooks/api/orders.tsx @@ -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, + }) +} diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index f413a59c52..9b6147c0f7 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -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", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 945ba1ec01..1bfd787c58 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -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": { diff --git a/packages/admin/dashboard/src/lib/client/client.ts b/packages/admin/dashboard/src/lib/client/client.ts index 382aa9e6c5..19b8b7d649 100644 --- a/packages/admin/dashboard/src/lib/client/client.ts +++ b/packages/admin/dashboard/src/lib/client/client.ts @@ -8,7 +8,7 @@ export const sdk = new Medusa({ baseUrl: backendUrl, auth: { type: authType, - jwtTokenStorageKey + jwtTokenStorageKey, }, }) diff --git a/packages/admin/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-create-form.tsx b/packages/admin/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-create-form.tsx index ab419e0c6c..320d7290b6 100644 --- a/packages/admin/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-create-form.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-create-form.tsx @@ -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 = ({ {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 = ({ + + {/* CARRY OVER PROMOTION */} + {hasPromotions && ( +
+ { + return ( + +
+ + { + onChange(checked) + if (preview?.order_change?.id) { + await updateOrderChange({ + carry_over_promotions: checked, + }) + } + }} + {...field} + /> + +
+ + {t("orders.exchanges.carryOverPromotion")} + + + + + + + + {t("orders.exchanges.carryOverPromotionHint")} + +
+
+ +
+ ) + }} + /> +
+ )} + {/* SEND NOTIFICATION*/}
diff --git a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx index 0d588127e1..09f89f2068 100644 --- a/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx @@ -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 ( <>
-
- -
- - {item.title} - +
+
+ +
+ + {item.title} + - {item.variant_sku && ( -
- {item.variant_sku} - -
- )} - - {item.variant?.options?.map((o) => o.value).join(" · ")} - + {item.variant_sku && ( +
+ {item.variant_sku} + +
+ )} + + {item.variant?.options?.map((o) => o.value).join(" · ")} + +
+ {appliedPromoCodes.length > 0 && ( + + {appliedPromoCodes.map((code) => ( +
{code}
+ ))} + + } + > + +
+ )}
diff --git a/packages/core/core-flows/src/order/steps/index.ts b/packages/core/core-flows/src/order/steps/index.ts index 4196d783c9..026948b95d 100644 --- a/packages/core/core-flows/src/order/steps/index.ts +++ b/packages/core/core-flows/src/order/steps/index.ts @@ -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" diff --git a/packages/core/core-flows/src/order/steps/list-order-change-actions-by-type.ts b/packages/core/core-flows/src/order/steps/list-order-change-actions-by-type.ts new file mode 100644 index 0000000000..c58bb81c3e --- /dev/null +++ b/packages/core/core-flows/src/order/steps/list-order-change-actions-by-type.ts @@ -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(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)) + } +) diff --git a/packages/core/core-flows/src/order/workflows/compute-adjustments-for-preview.ts b/packages/core/core-flows/src/order/workflows/compute-adjustments-for-preview.ts new file mode 100644 index 0000000000..c8b11a77cb --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/compute-adjustments-for-preview.ts @@ -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) { + 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 }) + }) + } +) diff --git a/packages/core/core-flows/src/order/workflows/exchange/exchange-add-new-item.ts b/packages/core/core-flows/src/order/workflows/exchange/exchange-add-new-item.ts index 15da5854b5..d5072fd9c1 100644 --- a/packages/core/core-flows/src/order/workflows/exchange/exchange-add-new-item.ts +++ b/packages/core/core-flows/src/order/workflows/exchange/exchange-add-new-item.ts @@ -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, }, }) diff --git a/packages/core/core-flows/src/order/workflows/exchange/exchange-request-item-return.ts b/packages/core/core-flows/src/order/workflows/exchange/exchange-request-item-return.ts index 118d3c648f..999950de0f 100644 --- a/packages/core/core-flows/src/order/workflows/exchange/exchange-request-item-return.ts +++ b/packages/core/core-flows/src/order/workflows/exchange/exchange-request-item-return.ts @@ -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, }, }) diff --git a/packages/core/core-flows/src/order/workflows/exchange/update-exchange-add-item.ts b/packages/core/core-flows/src/order/workflows/exchange/update-exchange-add-item.ts index 87579a356c..a0e4b865d9 100644 --- a/packages/core/core-flows/src/order/workflows/exchange/update-exchange-add-item.ts +++ b/packages/core/core-flows/src/order/workflows/exchange/update-exchange-add-item.ts @@ -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, }) diff --git a/packages/core/core-flows/src/order/workflows/index.ts b/packages/core/core-flows/src/order/workflows/index.ts index 7da3479153..a972ad1905 100644 --- a/packages/core/core-flows/src/order/workflows/index.ts +++ b/packages/core/core-flows/src/order/workflows/index.ts @@ -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" diff --git a/packages/core/core-flows/src/order/workflows/on-carry-promotions-flag-set.ts b/packages/core/core-flows/src/order/workflows/on-carry-promotions-flag-set.ts new file mode 100644 index 0000000000..ccac690607 --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/on-carry-promotions-flag-set.ts @@ -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 + ): WorkflowResponse { + 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) + } +) diff --git a/packages/core/core-flows/src/order/workflows/order-edit/compute-adjustments-for-preview.ts b/packages/core/core-flows/src/order/workflows/order-edit/compute-adjustments-for-preview.ts deleted file mode 100644 index 5ea22e39ba..0000000000 --- a/packages/core/core-flows/src/order/workflows/order-edit/compute-adjustments-for-preview.ts +++ /dev/null @@ -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) { - 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" }) - } - ) - } -) diff --git a/packages/core/core-flows/src/order/workflows/order-edit/order-edit-add-new-item.ts b/packages/core/core-flows/src/order/workflows/order-edit/order-edit-add-new-item.ts index 50171a09d9..525123318f 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/order-edit-add-new-item.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/order-edit-add-new-item.ts @@ -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], diff --git a/packages/core/core-flows/src/order/workflows/order-edit/order-edit-update-item-quantity.ts b/packages/core/core-flows/src/order/workflows/order-edit/order-edit-update-item-quantity.ts index 57e8a14d9f..594034e85c 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/order-edit-update-item-quantity.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/order-edit-update-item-quantity.ts @@ -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], diff --git a/packages/core/core-flows/src/order/workflows/order-edit/remove-order-edit-item-action.ts b/packages/core/core-flows/src/order/workflows/order-edit/remove-order-edit-item-action.ts index f44f03970f..53805d4b7d 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/remove-order-edit-item-action.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/remove-order-edit-item-action.ts @@ -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], diff --git a/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-add-item.ts b/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-add-item.ts index 272ba12b32..7213f15c78 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-add-item.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-add-item.ts @@ -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], diff --git a/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-item-quantity.ts b/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-item-quantity.ts index 6ee6d094e8..09a758eb09 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-item-quantity.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/update-order-edit-item-quantity.ts @@ -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" /** diff --git a/packages/core/core-flows/src/order/workflows/update-order-change.ts b/packages/core/core-flows/src/order/workflows/update-order-change.ts new file mode 100644 index 0000000000..99b779533d --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/update-order-change.ts @@ -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 + ): WorkflowResponse { + 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] + ) + ) + } +) diff --git a/packages/core/js-sdk/src/admin/order.ts b/packages/core/js-sdk/src/admin/order.ts index 122392e4de..63ae9ce5e2 100644 --- a/packages/core/js-sdk/src/admin/order.ts +++ b/packages/core/js-sdk/src/admin/order.ts @@ -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( + `/admin/order-changes/${id}`, + { + method: "POST", + headers, + body, + query, + } + ) + } } diff --git a/packages/core/types/src/http/order/admin/responses.ts b/packages/core/types/src/http/order/admin/responses.ts index 261f862fa4..548e2631a1 100644 --- a/packages/core/types/src/http/order/admin/responses.ts +++ b/packages/core/types/src/http/order/admin/responses.ts @@ -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. diff --git a/packages/core/types/src/http/order/common.ts b/packages/core/types/src/http/order/common.ts index f6b7be2510..586bd049ff 100644 --- a/packages/core/types/src/http/order/common.ts +++ b/packages/core/types/src/http/order/common.ts @@ -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 */ diff --git a/packages/core/types/src/order/common.ts b/packages/core/types/src/order/common.ts index 963efdf447..f6c9994f3d 100644 --- a/packages/core/types/src/order/common.ts +++ b/packages/core/types/src/order/common.ts @@ -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 */ diff --git a/packages/core/types/src/order/mutations.ts b/packages/core/types/src/order/mutations.ts index e2bfeabf2f..d9933064b1 100644 --- a/packages/core/types/src/order/mutations.ts +++ b/packages/core/types/src/order/mutations.ts @@ -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 | null + + /** + * Whether to carry over promotions to outbound exchange items. + */ + carry_over_promotions?: boolean | null } /** diff --git a/packages/medusa/src/api/admin/order-changes/[id]/route.ts b/packages/medusa/src/api/admin/order-changes/[id]/route.ts new file mode 100644 index 0000000000..5e9c571685 --- /dev/null +++ b/packages/medusa/src/api/admin/order-changes/[id]/route.ts @@ -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, + res: MedusaResponse +) => { + const { id } = req.params + const { carry_over_promotions } = req.validatedBody + const query = req.scope.resolve( + 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] }) +} diff --git a/packages/medusa/src/api/admin/order-changes/middlewares.ts b/packages/medusa/src/api/admin/order-changes/middlewares.ts new file mode 100644 index 0000000000..8a73263fa6 --- /dev/null +++ b/packages/medusa/src/api/admin/order-changes/middlewares.ts @@ -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 + ), + ], + }, +] diff --git a/packages/medusa/src/api/admin/order-changes/query-config.ts b/packages/medusa/src/api/admin/order-changes/query-config.ts new file mode 100644 index 0000000000..994878695b --- /dev/null +++ b/packages/medusa/src/api/admin/order-changes/query-config.ts @@ -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, +} diff --git a/packages/medusa/src/api/admin/order-changes/validators.ts b/packages/medusa/src/api/admin/order-changes/validators.ts new file mode 100644 index 0000000000..814ec78829 --- /dev/null +++ b/packages/medusa/src/api/admin/order-changes/validators.ts @@ -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 +> diff --git a/packages/medusa/src/api/admin/orders/query-config.ts b/packages/medusa/src/api/admin/orders/query-config.ts index 6fb4a358bb..4d3bac7dd5 100644 --- a/packages/medusa/src/api/admin/orders/query-config.ts +++ b/packages/medusa/src/api/admin/orders/query-config.ts @@ -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 = [ diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index ec8f03f310..7f0e85422c 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -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, diff --git a/packages/modules/order/src/migrations/.snapshot-medusa-order.json b/packages/modules/order/src/migrations/.snapshot-medusa-order.json index 91b2e0c5ae..c174cf53b5 100644 --- a/packages/modules/order/src/migrations/.snapshot-medusa-order.json +++ b/packages/modules/order/src/migrations/.snapshot-medusa-order.json @@ -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": [], diff --git a/packages/modules/order/src/migrations/Migration20251125164002.ts b/packages/modules/order/src/migrations/Migration20251125164002.ts new file mode 100644 index 0000000000..39360ba666 --- /dev/null +++ b/packages/modules/order/src/migrations/Migration20251125164002.ts @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20251125164002 extends Migration { + override async up(): Promise { + this.addSql( + `alter table if exists "order_change" add column if not exists "carry_over_promotions" boolean null;` + ) + } + + override async down(): Promise { + this.addSql( + `alter table if exists "order_change" drop column if exists "carry_over_promotions";` + ) + } +} diff --git a/packages/modules/order/src/models/order-change.ts b/packages/modules/order/src/models/order-change.ts index e833036f30..fda45c211e 100644 --- a/packages/modules/order/src/models/order-change.ts +++ b/packages/modules/order/src/models/order-change.ts @@ -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", }), diff --git a/packages/modules/order/src/services/order-module-service.ts b/packages/modules/order/src/services/order-module-service.ts index 788f602c23..8e5fd92849 100644 --- a/packages/modules/order/src/services/order-module-service.ts +++ b/packages/modules/order/src/services/order-module-service.ts @@ -3336,6 +3336,7 @@ export default class OrderModuleService "status", "description", "internal_note", + "carry_over_promotions", ], relations: [] as string[], order: {},